diff --git a/.editorconfig b/.editorconfig index 63c49d65d..e99fe5d60 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,17 +5,15 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = tab -insert_final_newline = true +insert_final_newline = false max_line_length = 120 tab_width = 4 # noinspection EditorConfigKeyCorrectness -disabled_rules = no-wildcard-imports, no-unused-imports +disabled_rules=no-wildcard-imports,no-unused-imports [{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] ij_continuation_indent_size = 4 -ij_xml_attribute_wrap = on_every_item [{*.kt,*.kts}] -ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_allow_trailing_comma = true ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..02304a073 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://yoomoney.ru/to/410012543938752"] diff --git a/.github/ISSUE_TEMPLATE/report_bug.yml b/.github/ISSUE_TEMPLATE/report_bug.yml index 26ef27e7f..261f51945 100644 --- a/.github/ISSUE_TEMPLATE/report_bug.yml +++ b/.github/ISSUE_TEMPLATE/report_bug.yml @@ -61,6 +61,4 @@ body: label: Acknowledgements options: - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - required: true - - label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose). - required: true + required: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index cda03a674..5611db9cb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,18 +7,13 @@ /.idea/modules.xml /.idea/misc.xml /.idea/discord.xml -/.idea/compiler.xml /.idea/workspace.xml /.idea/navEditor.xml -/.idea/ktlint-plugin.xml /.idea/assetWizardSettings.xml /.idea/kotlinScripting.xml -/.idea/kotlinc.xml /.idea/deploymentTargetDropDown.xml /.idea/androidTestResultsUserPreferences.xml -/.idea/deploymentTargetSelector.xml /.idea/render.experimental.xml -/.idea/inspectionProfiles/ .DS_Store /build /captures diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 9f674b306..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -/migrations.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..fb7f4a8a4 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 9f47dfb43..a0de2a152 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -5,15 +5,15 @@ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..2bcd23609 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 000000000..0dd4b3546 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/ktlint.xml b/.idea/ktlint.xml index 5b8abea19..e1ecd151a 100644 --- a/.idea/ktlint.xml +++ b/.idea/ktlint.xml @@ -1,13 +1,7 @@ - false true false - - - - \ No newline at end of file diff --git a/.weblate b/.weblate deleted file mode 100644 index 4a120e1a3..000000000 --- a/.weblate +++ /dev/null @@ -1,3 +0,0 @@ -[weblate] -url = https://hosted.weblate.org/api/ -translation = kotatsu/strings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4eed8178b..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,11 +0,0 @@ -## Kotatsu contribution guidelines - -- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it. -- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted. -- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform. -- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers). - -Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles: -- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority. -- Please, do not modify readme and other information files (except for typos). -- Avoid adding new dependencies unless required. APK size is important. diff --git a/README.md b/README.md index ab5cbe31e..82e4fada7 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,30 @@ Kotatsu is a free and open source manga reader for Android. -![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![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) +![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ### 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. +[](https://f-droid.org/packages/org.koitharu.kotatsu) + +Download APK directly from GitHub: + +- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)** ### Main Features -* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) +* Online manga catalogues * Search manga by name and genres * Reading history and bookmarks * Favourites organized by user-defined categories * Downloading manga and reading it offline. Third-party CBZ archives also supported -* Tablet-optimized Material You UI +* Tablet-optimized material design UI * Standard and Webtoon-optimized reader * Notifications about new chapters with updates feed -* Integration with manga tracking services: Shikimori, AniList, MyAnimeList +* Shikimori integration (manga tracking) * Password/fingerprint protect access to the app -* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices ### Screenshots @@ -39,10 +43,6 @@ Kotatsu is a free and open source manga reader for Android. 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. - ### License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) diff --git a/app/build.gradle b/app/build.gradle index b83b9b333..455908436 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,37 +2,39 @@ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' - id 'com.google.devtools.ksp' id 'kotlin-parcelize' - id 'dagger.hilt.android.plugin' } android { - compileSdk = 34 - buildToolsVersion = '34.0.0' - namespace = 'org.koitharu.kotatsu' + compileSdkVersion 32 + buildToolsVersion '32.0.0' + namespace 'org.koitharu.kotatsu' defaultConfig { applicationId 'org.koitharu.kotatsu' - minSdk = 21 - targetSdk = 34 - versionCode = 612 - versionName = '6.6.1' + minSdkVersion 21 + targetSdkVersion 32 + versionCode 418 + versionName '3.4.6' generatedDensities = [] - testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' - ksp { - arg('room.generateKotlin', 'true') - arg('room.schemaLocation', "$projectDir/schemas") - } - androidResources { - generateLocaleConfig true + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + kapt { + arguments { + arg 'room.schemaLocation', "$projectDir/schemas".toString() + } } + + // define this values in your local.properties file + buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" + buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" } buildTypes { debug { applicationIdSuffix = '.debug' } release { + multiDexEnabled false minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' @@ -40,21 +42,17 @@ android { } buildFeatures { viewBinding true - buildConfig true } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) - main.java.srcDirs += 'src/main/kotlin/' } compileOptions { - coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += [ - '-opt-in=kotlin.ExperimentalStdlibApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlin.contracts.ExperimentalContracts', @@ -62,8 +60,8 @@ android { ] } lint { - abortOnError true - disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled' + abortOnError false + disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged' } testOptions { unitTests.includeAndroidResources true @@ -81,82 +79,64 @@ afterEvaluate { } } dependencies { - //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:0e8579017b') { + implementation('com.github.nv95:kotatsu-parsers:fadb06aabb') { exclude group: 'org.json', module: 'json' } - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' - implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.activity:activity-ktx:1.8.2' - implementation 'androidx.fragment:fragment-ktx:1.6.2' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' - implementation 'androidx.lifecycle:lifecycle-service:2.6.2' - implementation 'androidx.lifecycle:lifecycle-process:2.6.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + + implementation 'androidx.core:core-ktx:1.8.0' + implementation 'androidx.activity:activity-ktx:1.5.0' + implementation 'androidx.fragment:fragment-ktx:1.5.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0' + implementation 'androidx.lifecycle:lifecycle-service:2.5.0' + implementation 'androidx.lifecycle:lifecycle-process:2.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' - implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' - - implementation 'androidx.work:work-runtime:2.9.0' - //noinspection GradleDependency - implementation('com.google.guava:guava:32.0.1-android') { - exclude group: 'com.google.guava', module: 'failureaccess' - exclude group: 'org.checkerframework', module: 'checker-qual' - exclude group: 'com.google.j2objc', module: 'j2objc-annotations' - } - - implementation 'androidx.room:room-runtime:2.6.1' - implementation 'androidx.room:room-ktx:2.6.1' - ksp 'androidx.room:room-compiler:2.6.1' - - implementation 'com.squareup.okhttp3:okhttp:4.12.0' - implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' - implementation 'com.squareup.okio:okio:3.7.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' + implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' + implementation 'com.google.android.material:material:1.7.0-alpha03' + //noinspection LifecycleAnnotationProcessorWithJava8 + kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0' + + implementation 'androidx.room:room-runtime:2.4.2' + implementation 'androidx.room:room-ktx:2.4.2' + kapt 'androidx.room:room-compiler:2.4.2' + + implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3' + implementation 'com.squareup.okio:okio:3.2.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - implementation 'com.google.dagger:hilt-android:2.50' - kapt 'com.google.dagger:hilt-compiler:2.50' - implementation 'androidx.hilt:hilt-work:1.1.0' - kapt 'androidx.hilt:hilt-compiler:1.1.0' - - implementation 'io.coil-kt:coil-base:2.5.0' - implementation 'io.coil-kt:coil-svg:2.5.0' - implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' + implementation 'io.insert-koin:koin-android:3.2.0' + implementation 'io.coil-kt:coil-base:2.1.0' + implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.github.solkin:disk-lru-cache:1.4' - implementation 'io.noties.markwon:core:4.6.2' - implementation 'ch.acra:acra-http:5.11.3' - implementation 'ch.acra:acra-dialog:5.11.3' - compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1' - ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0' + implementation 'ch.acra:acra-mail:5.9.5' + implementation 'ch.acra:acra-dialog:5.9.5' - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' testImplementation 'junit:junit:4.13.2' - testImplementation 'org.json:json:20231013' - testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + testImplementation 'org.json:json:20220320' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test:rules:1.5.0' - androidTestImplementation 'androidx.test:core-ktx:1.5.0' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:core-ktx:1.4.0' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' - androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' + androidTestImplementation 'io.insert-koin:koin-test:3.2.0' + androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0' - androidTestImplementation 'androidx.room:room-testing:2.6.1' - androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' - - androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50' - kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50' -} + androidTestImplementation 'androidx.room:room-testing:2.4.2' + androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a7e0e91ee..fb3509dc2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -8,16 +8,6 @@ public static void checkParameterIsNotNull(...); public static void checkNotNullParameter(...); } --keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment +-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment -keep class org.koitharu.kotatsu.core.db.entity.* { *; } --dontwarn okhttp3.internal.platform.** --dontwarn org.conscrypt.** --dontwarn org.bouncycastle.** --dontwarn org.openjsse.** - --keep class org.koitharu.kotatsu.core.exceptions.* { *; } --keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment --keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } --keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; } --keep class org.jsoup.parser.Tag --keep class org.jsoup.internal.StringUtil +-dontwarn okhttp3.internal.platform.ConscryptPlatform \ No newline at end of file diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json index 58a2ab058..90f6ecf1a 100644 --- a/app/src/androidTest/assets/categories/simple.json +++ b/app/src/androidTest/assets/categories/simple.json @@ -4,6 +4,5 @@ "sortKey": 1, "order": "NEWEST", "createdAt": 1335906000000, - "isTrackingEnabled": true, - "isVisibleInLibrary": true -} + "isTrackingEnabled": true +} \ No newline at end of file diff --git a/app/src/androidTest/assets/kotatsu_test.bak b/app/src/androidTest/assets/kotatsu_test.bak deleted file mode 100755 index a6eae4cdc..000000000 Binary files a/app/src/androidTest/assets/kotatsu_test.bak and /dev/null differ diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/Instrumentation.kt b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt similarity index 99% rename from app/src/androidTest/kotlin/org/koitharu/kotatsu/Instrumentation.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt index dbf4ec642..b9ef582c1 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/Instrumentation.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt @@ -6,4 +6,4 @@ import kotlin.coroutines.suspendCoroutine suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont -> waitForIdle { cont.resume(Unit) } -} +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt b/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt similarity index 100% rename from app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt similarity index 88% rename from app/src/androidTest/kotlin/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt index 523da54c1..23c8b9796 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt @@ -3,10 +3,10 @@ package org.koitharu.kotatsu.core.db import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) class MangaDatabaseTest { @@ -17,7 +17,7 @@ class MangaDatabaseTest { MangaDatabase::class.java, ) - private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext) + private val migrations = databaseMigrations @Test fun versions() { @@ -37,7 +37,7 @@ class MangaDatabaseTest { TEST_DB, migration.endVersion, true, - migration, + migration ).close() } } diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt similarity index 68% rename from app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt index bbc70b8db..ec4c04edc 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManagerTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt @@ -6,40 +6,28 @@ import android.os.Build import androidx.core.content.getSystemService import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.koin.test.KoinTest +import org.koin.test.inject import org.koitharu.kotatsu.SampleData import org.koitharu.kotatsu.awaitForIdle import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.history.data.HistoryRepository -import javax.inject.Inject +import org.koitharu.kotatsu.history.domain.HistoryRepository +import kotlin.test.assertEquals +import kotlin.test.assertTrue -@HiltAndroidTest @RunWith(AndroidJUnit4::class) -class AppShortcutManagerTest { +class ShortcutsUpdaterTest : KoinTest { - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var historyRepository: HistoryRepository - - @Inject - lateinit var appShortcutManager: AppShortcutManager - - @Inject - lateinit var database: MangaDatabase + private val historyRepository by inject() + private val shortcutsUpdater by inject() + private val database by inject() @Before fun setUp() { - hiltRule.inject() database.clearAllTables() } @@ -48,7 +36,6 @@ class AppShortcutManagerTest { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { return@runTest } - database.invalidationTracker.addObserver(appShortcutManager) awaitUpdate() assertTrue(getShortcuts().isEmpty()) historyRepository.addOrUpdate( @@ -56,7 +43,7 @@ class AppShortcutManagerTest { chapterId = SampleData.chapter.id, page = 4, scroll = 2, - percent = 0.3f, + percent = 0.3f ) awaitUpdate() @@ -73,6 +60,6 @@ class AppShortcutManagerTest { private suspend fun awaitUpdate() { val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.awaitForIdle() - appShortcutManager.await() + shortcutsUpdater.await() } -} +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt new file mode 100644 index 000000000..1d0ca5498 --- /dev/null +++ b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt @@ -0,0 +1,67 @@ +package org.koitharu.kotatsu.settings.backup + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.KoinTest +import org.koin.test.get +import org.koin.test.inject +import org.koitharu.kotatsu.SampleData +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.toMangaTags +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.history.domain.HistoryRepository +import kotlin.test.* + +@RunWith(AndroidJUnit4::class) +class AppBackupAgentTest : KoinTest { + + private val historyRepository by inject() + private val favouritesRepository by inject() + private val backupRepository by inject() + private val database by inject() + + @Before + fun setUp() { + database.clearAllTables() + } + + @Test + fun testBackupRestore() = runTest { + val category = favouritesRepository.createCategory( + title = SampleData.favouriteCategory.title, + sortOrder = SampleData.favouriteCategory.order, + isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled, + ) + favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga)) + historyRepository.addOrUpdate( + manga = SampleData.mangaDetails, + chapterId = SampleData.mangaDetails.chapters!![2].id, + page = 3, + scroll = 40, + percent = 0.2f, + ) + val history = checkNotNull(historyRepository.getOne(SampleData.manga)) + + val agent = AppBackupAgent() + val backup = agent.createBackupFile(get(), backupRepository) + + database.clearAllTables() + assertTrue(favouritesRepository.getAllManga().isEmpty()) + assertNull(historyRepository.getLastOrNull()) + + backup.inputStream().use { + agent.restoreBackupFile(it.fd, backup.length(), backupRepository) + } + + assertEquals(category, favouritesRepository.getCategory(category.id)) + assertEquals(history, historyRepository.getOne(SampleData.manga)) + assertContentEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) + + val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags() + assertContains(allTags, SampleData.tag) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt similarity index 90% rename from app/src/androidTest/kotlin/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt rename to app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt index 5e38b4d15..a1ea460f6 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt @@ -1,39 +1,24 @@ package org.koitharu.kotatsu.tracker.domain import androidx.test.ext.junit.runners.AndroidJUnit4 -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import junit.framework.TestCase.* import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.koin.test.KoinTest +import org.koin.test.inject import org.koitharu.kotatsu.SampleData -import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue -@HiltAndroidTest @RunWith(AndroidJUnit4::class) -class TrackerTest { +class TrackerTest : KoinTest { - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var repository: TrackingRepository - - @Inject - lateinit var dataRepository: MangaDataRepository - - @Inject - lateinit var tracker: Tracker - - @Before - fun setUp() { - hiltRule.inject() - } + private val repository by inject() + private val dataRepository by inject() + private val tracker by inject() @Test fun noUpdates() = runTest { @@ -195,4 +180,4 @@ class TrackerTest { dataRepository.storeManga(manga) return manga } -} +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/HiltTestRunner.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/HiltTestRunner.kt deleted file mode 100644 index 0c0a60f73..000000000 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/HiltTestRunner.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication - -class HiltTestRunner : AndroidJUnitRunner() { - - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } -} diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt deleted file mode 100644 index 40e97ea7c..000000000 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.res.AssetManager -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.koitharu.kotatsu.SampleData -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.data.HistoryRepository -import java.io.File -import javax.inject.Inject - -@HiltAndroidTest -@RunWith(AndroidJUnit4::class) -class AppBackupAgentTest { - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @Inject - lateinit var historyRepository: HistoryRepository - - @Inject - lateinit var favouritesRepository: FavouritesRepository - - @Inject - lateinit var backupRepository: BackupRepository - - @Inject - lateinit var database: MangaDatabase - - @Before - fun setUp() { - hiltRule.inject() - database.clearAllTables() - } - - @Test - fun backupAndRestore() = runTest { - val category = favouritesRepository.createCategory( - title = SampleData.favouriteCategory.title, - sortOrder = SampleData.favouriteCategory.order, - isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled, - isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary, - ) - favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga)) - historyRepository.addOrUpdate( - manga = SampleData.mangaDetails, - chapterId = SampleData.mangaDetails.chapters!![2].id, - page = 3, - scroll = 40, - percent = 0.2f, - ) - val history = checkNotNull(historyRepository.getOne(SampleData.manga)) - - val agent = AppBackupAgent() - val backup = agent.createBackupFile( - context = InstrumentationRegistry.getInstrumentation().targetContext, - repository = backupRepository, - ) - - database.clearAllTables() - assertTrue(favouritesRepository.getAllManga().isEmpty()) - assertNull(historyRepository.getLastOrNull()) - - backup.inputStream().use { - agent.restoreBackupFile(it.fd, backup.length(), backupRepository) - } - - assertEquals(category, favouritesRepository.getCategory(category.id)) - assertEquals(history, historyRepository.getOne(SampleData.manga)) - assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) - - val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags() - assertTrue(SampleData.tag in allTags) - } - - @Test - fun restoreOldBackup() { - val agent = AppBackupAgent() - val backup = File.createTempFile("backup_", ".tmp") - InstrumentationRegistry.getInstrumentation().context.assets - .open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING) - .use { input -> - backup.outputStream().use { output -> - input.copyTo(output) - } - } - backup.inputStream().use { - agent.restoreBackupFile(it.fd, backup.length(), backupRepository) - } - runTest { - assertEquals(6, historyRepository.observeAll().first().size) - assertEquals(2, favouritesRepository.observeCategories().first().size) - assertEquals(15, favouritesRepository.getAllManga().size) - } - } -} diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt new file mode 100644 index 000000000..9ebcba9f4 --- /dev/null +++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.core.parser + +import java.util.* +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* + +/** + * This parser is just for parser development, it should not be used in releases + */ +class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) { + + override val configKeyDomain: ConfigKey.Domain + get() = ConfigKey.Domain("", null) + + override val sortOrders: Set + get() = EnumSet.allOf(SortOrder::class.java) + + override suspend fun getDetails(manga: Manga): Manga { + TODO("Not yet implemented") + } + + override suspend fun getList( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder, + ): List { + TODO("Not yet implemented") + } + + override suspend fun getPages(chapter: MangaChapter): List { + TODO("Not yet implemented") + } + + override suspend fun getTags(): Set { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt similarity index 82% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaParser.kt rename to app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt index 769ca8e52..596d4c626 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaParser.kt +++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt @@ -3,11 +3,12 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.newParser fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser { return if (source == MangaSource.DUMMY) { DummyParser(loaderContext) } else { - loaderContext.newParserInstance(source) + source.newParser(loaderContext) } -} +} \ No newline at end of file diff --git a/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt new file mode 100644 index 000000000..e00bb6a83 --- /dev/null +++ b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.utils.ext + +fun Throwable.printStackTraceDebug() = printStackTrace() \ No newline at end of file diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt deleted file mode 100644 index 69b456c85..000000000 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu - -import android.content.Context -import android.os.StrictMode -import androidx.fragment.app.strictmode.FragmentStrictMode -import org.koitharu.kotatsu.core.BaseApp -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.reader.domain.PageLoader - -class KotatsuApp : BaseApp() { - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - enableStrictMode() - } - - private fun enableStrictMode() { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectAll() - .penaltyLog() - .build(), - ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectAll() - .setClassInstanceLimit(LocalMangaRepository::class.java, 1) - .setClassInstanceLimit(PagesCache::class.java, 1) - .setClassInstanceLimit(MangaLoaderContext::class.java, 1) - .setClassInstanceLimit(PageLoader::class.java, 1) - .penaltyLog() - .build(), - ) - FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() - .penaltyDeath() - .detectFragmentReuse() - // .detectWrongFragmentContainer() FIXME: migrate to ViewPager2 - .detectRetainInstanceUsage() - .detectSetUserVisibleHint() - .detectFragmentTagUsage() - .build() - } -} diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt deleted file mode 100644 index cc6f6a0a7..000000000 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import android.util.Log -import okhttp3.Interceptor -import okhttp3.Response -import okio.Buffer -import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING - -class CurlLoggingInterceptor( - private val curlOptions: String? = null -) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - var isCompressed = false - - val curlCmd = StringBuilder() - curlCmd.append("curl") - if (curlOptions != null) { - curlCmd.append(' ').append(curlOptions) - } - curlCmd.append(" -X ").append(request.method) - - for ((name, value) in request.headers) { - if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) { - isCompressed = true - } - curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"') - } - - val body = request.body - if (body != null) { - val buffer = Buffer() - body.writeTo(buffer) - val charset = body.contentType()?.charset() ?: Charsets.UTF_8 - curlCmd.append(" --data-raw '") - .append(buffer.readString(charset).replace("\n", "\\n")) - .append("'") - } - if (isCompressed) { - curlCmd.append(" --compressed") - } - curlCmd.append(" \"").append(request.url).append('"') - - log("---cURL (" + request.url + ")") - log(curlCmd.toString()) - - return chain.proceed(request) - } - - private fun String.escape() = replace("\"", "\\\"") - - private fun log(msg: String) { - Log.d("CURL", msg) - } -} diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt deleted file mode 100644 index 906269bdf..000000000 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaParser -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.EnumSet - -/** - * This parser is just for parser development, it should not be used in releases - */ -class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { - - override val configKeyDomain: ConfigKey.Domain - get() = ConfigKey.Domain("") - - override val availableSortOrders: Set - get() = EnumSet.allOf(SortOrder::class.java) - - override suspend fun getDetails(manga: Manga): Manga { - TODO("Not yet implemented") - } - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { - TODO("Not yet implemented") - } - - override suspend fun getPages(chapter: MangaChapter): List { - TODO("Not yet implemented") - } - - override suspend fun getAvailableTags(): Set { - TODO("Not yet implemented") - } -} diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/util/ext/DebugExt.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/util/ext/DebugExt.kt deleted file mode 100644 index 62af20cbc..000000000 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/util/ext/DebugExt.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -fun Throwable.printStackTraceDebug() = printStackTrace() diff --git a/app/src/debug/res/values/bools.xml b/app/src/debug/res/values/bools.xml index 36b9b0867..037cba998 100644 --- a/app/src/debug/res/values/bools.xml +++ b/app/src/debug/res/values/bools.xml @@ -1,4 +1,4 @@ false - + \ No newline at end of file diff --git a/app/src/debug/res/values/constants.xml b/app/src/debug/res/values/constants.xml deleted file mode 100644 index 44e14a54f..000000000 --- a/app/src/debug/res/values/constants.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - org.kotatsu.debug.sync - org.koitharu.kotatsu.debug.history - org.koitharu.kotatsu.debug.favourites - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 97d9cefbb..22bad7dd9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,46 +10,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:label="@string/search_manga" /> - - - - - - + android:name="org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity" + android:label="@string/favourites_categories" + android:windowSoftInputMode="stateAlwaysHidden" /> - - - - - + android:label="@string/manga_shelf" + android:theme="@style/Theme.Kotatsu.DialogWhenLarge"> @@ -191,89 +100,25 @@ android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity" android:windowSoftInputMode="adjustResize" /> + android:launchMode="singleTop" + android:theme="@style/Theme.Kotatsu.DialogWhenLarge" /> - - - - - - - - - - - - - - - - - - + android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" + android:theme="@style/Theme.Kotatsu.DialogWhenLarge" /> - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt new file mode 100644 index 000000000..fcbc79742 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -0,0 +1,153 @@ +package org.koitharu.kotatsu + +import android.app.Application +import android.content.Context +import android.os.StrictMode +import androidx.appcompat.app.AppCompatDelegate +import androidx.fragment.app.strictmode.FragmentStrictMode +import androidx.room.InvalidationTracker +import org.acra.ReportField +import org.acra.config.dialog +import org.acra.config.mailSender +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.koin.android.ext.android.get +import org.koin.android.ext.android.getKoin +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koitharu.kotatsu.bookmarks.bookmarksModule +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.databaseModule +import org.koitharu.kotatsu.core.github.githubModule +import org.koitharu.kotatsu.core.network.networkModule +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.uiModule +import org.koitharu.kotatsu.details.detailsModule +import org.koitharu.kotatsu.favourites.favouritesModule +import org.koitharu.kotatsu.history.historyModule +import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.localModule +import org.koitharu.kotatsu.main.mainModule +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.reader.readerModule +import org.koitharu.kotatsu.remotelist.remoteListModule +import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule +import org.koitharu.kotatsu.search.searchModule +import org.koitharu.kotatsu.settings.settingsModule +import org.koitharu.kotatsu.suggestions.suggestionsModule +import org.koitharu.kotatsu.tracker.trackerModule +import org.koitharu.kotatsu.widget.appWidgetModule + +class KotatsuApp : Application() { + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + enableStrictMode() + } + initKoin() + AppCompatDelegate.setDefaultNightMode(get().theme) + setupActivityLifecycleCallbacks() + setupDatabaseObservers() + } + + private fun initKoin() { + startKoin { + androidContext(this@KotatsuApp) + modules( + networkModule, + databaseModule, + githubModule, + uiModule, + mainModule, + searchModule, + localModule, + favouritesModule, + historyModule, + remoteListModule, + detailsModule, + trackerModule, + settingsModule, + readerModule, + appWidgetModule, + suggestionsModule, + shikimoriModule, + bookmarksModule, + ) + } + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.KEY_VALUE_LIST + reportContent = listOf( + ReportField.PACKAGE_NAME, + ReportField.APP_VERSION_CODE, + ReportField.APP_VERSION_NAME, + ReportField.ANDROID_VERSION, + ReportField.PHONE_MODEL, + ReportField.CRASH_CONFIGURATION, + ReportField.STACK_TRACE, + ReportField.CUSTOM_DATA, + ReportField.SHARED_PREFERENCES, + ) + dialog { + text = getString(R.string.crash_text) + title = getString(R.string.error_occurred) + positiveButtonText = getString(R.string.send) + resIcon = R.drawable.ic_alert_outline + resTheme = android.R.style.Theme_Material_Light_Dialog_Alert + } + mailSender { + mailTo = getString(R.string.email_error_report) + reportAsFile = true + reportFileName = "stacktrace.txt" + } + } + } + + private fun setupDatabaseObservers() { + val observers = getKoin().getAll() + val database = get() + val tracker = database.invalidationTracker + observers.forEach { + tracker.addObserver(it) + } + } + + private fun setupActivityLifecycleCallbacks() { + val callbacks = getKoin().getAll() + callbacks.forEach { + registerActivityLifecycleCallbacks(it) + } + } + + private fun enableStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .setClassInstanceLimit(LocalMangaRepository::class.java, 1) + .setClassInstanceLimit(PagesCache::class.java, 1) + .setClassInstanceLimit(MangaLoaderContext::class.java, 1) + .penaltyLog() + .build() + ) + FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder() + .penaltyDeath() + .detectFragmentReuse() + .detectWrongFragmentContainer() + .detectRetainInstanceUsage() + .detectSetUserVisibleHint() + .detectFragmentTagUsage() + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt new file mode 100644 index 000000000..179d87868 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.base.domain + +import androidx.room.withTransaction +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag + +class MangaDataRepository(private val db: MangaDatabase) { + + suspend fun savePreferences(manga: Manga, mode: ReaderMode) { + val tags = manga.tags.toEntities() + db.withTransaction { + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga.toEntity(), tags) + db.preferencesDao.upsert( + MangaPrefsEntity( + mangaId = manga.id, + mode = mode.id + ) + ) + } + } + + suspend fun getReaderMode(mangaId: Long): ReaderMode? { + return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) } + } + + suspend fun findMangaById(mangaId: Long): Manga? { + return db.mangaDao.find(mangaId)?.toManga() + } + + suspend fun resolveIntent(intent: MangaIntent): Manga? = when { + intent.manga != null -> intent.manga + intent.mangaId != 0L -> findMangaById(intent.mangaId) + else -> null // TODO resolve uri + } + + suspend fun storeManga(manga: Manga) { + val tags = manga.tags.toEntities() + db.withTransaction { + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga.toEntity(), tags) + } + } + + suspend fun findTags(source: MangaSource): Set { + return db.tagsDao.findTags(source.name).toMangaTags() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt new file mode 100644 index 000000000..fccd1e9fd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.base.domain + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.parsers.model.Manga + +class MangaIntent private constructor( + val manga: Manga?, + val mangaId: Long, + val uri: Uri?, +) { + + constructor(intent: Intent?) : this( + manga = intent?.getParcelableExtra(KEY_MANGA)?.manga, + mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, + uri = intent?.data + ) + + constructor(args: Bundle?) : this( + manga = args?.getParcelable(KEY_MANGA)?.manga, + mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, + uri = null + ) + + companion object { + + const val ID_NONE = 0L + + const val KEY_MANGA = "manga" + const val KEY_ID = "id" + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt new file mode 100644 index 000000000..b3b32dc1f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt @@ -0,0 +1,76 @@ +package org.koitharu.kotatsu.base.domain + +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Size +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.util.await +import java.io.File +import java.io.InputStream +import java.util.zip.ZipFile +import kotlin.math.roundToInt + +object MangaUtils : KoinComponent { + + private const val MIN_WEBTOON_RATIO = 2 + + /** + * Automatic determine type of manga by page size + * @return ReaderMode.WEBTOON if page is wide + */ + suspend fun determineMangaIsWebtoon(pages: List): Boolean { + val pageIndex = (pages.size * 0.3).roundToInt() + val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } + val url = MangaRepository(page.source).getPageUrl(page) + val uri = Uri.parse(url) + val size = if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + getBitmapSize(it) + } + } + } else { + val request = Request.Builder() + .url(url) + .get() + .header(CommonHeaders.REFERER, page.referer) + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .build() + get().newCall(request).await().use { + runInterruptible(Dispatchers.IO) { + getBitmapSize(it.body?.byteStream()) + } + } + } + return size.width * MIN_WEBTOON_RATIO < size.height + } + + 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 fun getBitmapSize(input: InputStream?): Size { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(input, null, options)?.recycle() + val imageHeight: Int = options.outHeight + val imageWidth: Int = options.outWidth + check(imageHeight > 0 && imageWidth > 0) + return Size(imageWidth, imageHeight) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt new file mode 100644 index 000000000..43c9bf7e4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.base.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.utils.ext.processLifecycleScope + +fun interface ReversibleHandle { + + suspend fun reverse() +} + +fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) { + reverse() +} + +operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle { + this.reverse() + other.reverse() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt new file mode 100644 index 000000000..e1e328c8f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.base.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.fragment.app.DialogFragment +import androidx.viewbinding.ViewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +abstract class AlertDialogFragment : DialogFragment() { + + private var viewBinding: B? = null + + protected val binding: B + get() = checkNotNull(viewBinding) + + final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = onInflateView(layoutInflater, null) + viewBinding = binding + return MaterialAlertDialogBuilder(requireContext(), theme) + .setView(binding.root) + .also(::onBuildDialog) + .create() + } + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = viewBinding?.root + + @CallSuper + override fun onDestroyView() { + viewBinding = null + super.onDestroyView() + } + + open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit + + protected fun bindingOrNull(): B? = viewBinding + + protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt index bb27f8a59..9cdce9654 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt @@ -1,11 +1,10 @@ -package org.koitharu.kotatsu.core.ui +package org.koitharu.kotatsu.base.ui -import android.content.Intent import android.content.res.Configuration -import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.KeyEvent +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.CallSuper @@ -14,65 +13,47 @@ import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.viewbinding.ViewBinding -import dagger.hilt.android.EntryPointAccessors +import org.koin.android.ext.android.get import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate +import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate -import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.prefs.AppSettings -@Suppress("LeakingThis") abstract class BaseActivity : AppCompatActivity(), WindowInsetsDelegate.WindowInsetsListener { - private var isAmoledTheme = false - - lateinit var viewBinding: B + protected lateinit var binding: B private set - @JvmField + @Suppress("LeakingThis") protected val exceptionResolver = ExceptionResolver(this) - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() + @Suppress("LeakingThis") + protected val insetsDelegate = WindowInsetsDelegate(this) - @JvmField val actionModeDelegate = ActionModeDelegate() - private var defaultStatusBarColor = Color.TRANSPARENT - override fun onCreate(savedInstanceState: Bundle?) { - val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings - isAmoledTheme = settings.isAmoledTheme - setTheme(settings.colorScheme.styleResId) - if (isAmoledTheme) { - setTheme(R.style.ThemeOverlay_Kotatsu_Amoled) + val settings = get() + val isAmoled = settings.isAmoledTheme + val isDynamic = settings.isDynamicTheme + // TODO support DialogWhenLarge theme + when { + isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled) + isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled) + isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet) } super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) insetsDelegate.handleImeInsets = true - insetsDelegate.addInsetsListener(this) - putDataToExtras(intent) - } - - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) - onBackPressedDispatcher.addCallback(actionModeDelegate) - } - - override fun onNewIntent(intent: Intent?) { - putDataToExtras(intent) - super.onNewIntent(intent) } @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) @@ -88,20 +69,20 @@ abstract class BaseActivity : } protected fun setContentView(binding: B) { - this.viewBinding = binding + this.binding = binding super.setContentView(binding.root) val toolbar = (binding.root.findViewById(R.id.toolbar) as? Toolbar) toolbar?.let(this::setSupportActionBar) insetsDelegate.onViewCreated(binding.root) } - override fun onSupportNavigateUp(): Boolean { - dispatchNavigateUp() - return true - } + override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { + onBackPressed() + true + } else super.onOptionsItemSelected(item) override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove ActivityCompat.recreate(this) return true } @@ -115,57 +96,36 @@ abstract class BaseActivity : protected fun isDarkAmoledTheme(): Boolean { val uiMode = resources.configuration.uiMode val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - return isNight && isAmoledTheme + return isNight && get().isAmoledTheme } @CallSuper override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) actionModeDelegate.onSupportActionModeStarted(mode) - val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ColorUtils.compositeColors( - ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), - getThemeColor(R.attr.m3ColorBackground), - ) - } else { - ContextCompat.getColor(this, R.color.kotatsu_m3_background) - } - val insets = ViewCompat.getRootWindowInsets(viewBinding.root) + val insets = ViewCompat.getRootWindowInsets(binding.root) ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return - findViewById(androidx.appcompat.R.id.action_mode_bar).apply { - setBackgroundColor(actionModeColor) - updateLayoutParams { - topMargin = insets.top - } + val view = findViewById(androidx.appcompat.R.id.action_mode_bar) + view?.updateLayoutParams { + topMargin = insets.top } - defaultStatusBarColor = window.statusBarColor - window.statusBarColor = actionModeColor } @CallSuper override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) actionModeDelegate.onSupportActionModeFinished(mode) - window.statusBarColor = defaultStatusBarColor } - protected open fun dispatchNavigateUp() { - val upIntent = parentActivityIntent - if (upIntent != null) { - if (!navigateUpTo(upIntent)) { - startActivity(upIntent) - } - } else { + override fun onBackPressed() { + if ( // https://issuetracker.google.com/issues/139738913 + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && + isTaskRoot && + supportFragmentManager.backStackEntryCount == 0 + ) { finishAfterTransition() + } else { + super.onBackPressed() } } - - private fun putDataToExtras(intent: Intent?) { - intent?.putExtra(EXTRA_DATA, intent.data) - } - - companion object { - - const val EXTRA_DATA = "data" - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt new file mode 100644 index 000000000..c8c4051b3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt @@ -0,0 +1,86 @@ +package org.koitharu.kotatsu.base.ui + +import android.app.Dialog +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import androidx.core.view.updateLayoutParams +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog +import org.koitharu.kotatsu.utils.ext.displayCompat +import com.google.android.material.R as materialR + +abstract class BaseBottomSheet : BottomSheetDialogFragment() { + + private var viewBinding: B? = null + + protected val binding: B + get() = checkNotNull(viewBinding) + + protected val behavior: BottomSheetBehavior<*>? + get() = (dialog as? BottomSheetDialog)?.behavior + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = onInflateView(inflater, container) + viewBinding = binding + + // Enforce max width for tablets + val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width) + if (width > 0) { + behavior?.maxWidth = width + } + + // Set peek height to 50% display height + requireContext().displayCompat?.let { + val metrics = DisplayMetrics() + it.getRealMetrics(metrics) + behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt() + } + + return binding.root + } + + override fun onDestroyView() { + viewBinding = null + super.onDestroyView() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return AppBottomSheetDialog(requireContext(), theme) + } + + fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) { + val b = behavior ?: return + b.addBottomSheetCallback(callback) + val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) + if (rootView != null) { + callback.onStateChanged(rootView, b.state) + } + } + + protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B + + protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { + val b = behavior ?: return + if (isExpanded) { + b.state = BottomSheetBehavior.STATE_EXPANDED + } + b.isFitToContents = !isExpanded + val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) + rootView?.updateLayoutParams { + height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT + } + b.isDraggable = !isLocked + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt new file mode 100644 index 000000000..17c8931b1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt @@ -0,0 +1,55 @@ +package org.koitharu.kotatsu.base.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate +import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver + +abstract class BaseFragment : + Fragment(), + WindowInsetsDelegate.WindowInsetsListener { + + private var viewBinding: B? = null + + protected val binding: B + get() = checkNotNull(viewBinding) + + @Suppress("LeakingThis") + protected val exceptionResolver = ExceptionResolver(this) + + @Suppress("LeakingThis") + protected val insetsDelegate = WindowInsetsDelegate(this) + + protected val actionModeDelegate: ActionModeDelegate + get() = (requireActivity() as BaseActivity<*>).actionModeDelegate + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val binding = onInflateView(inflater, container) + viewBinding = binding + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + insetsDelegate.onViewCreated(view) + } + + override fun onDestroyView() { + viewBinding = null + insetsDelegate.onDestroyView() + super.onDestroyView() + } + + protected fun bindingOrNull() = viewBinding + + protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt new file mode 100644 index 000000000..e43ca8877 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.base.ui + +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.viewbinding.ViewBinding + +@Suppress("DEPRECATION") +private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + +@Suppress("DEPRECATION") +private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + +abstract class BaseFullscreenActivity : + BaseActivity(), + View.OnSystemUiVisibilityChangeListener { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(window) { + statusBarColor = Color.TRANSPARENT + navigationBarColor = Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + decorView.setOnSystemUiVisibilityChangeListener(this@BaseFullscreenActivity) + } + showSystemUI() + } + + @Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith") + @Deprecated("Deprecated in Java") + final override fun onSystemUiVisibilityChange(visibility: Int) { + onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0) + } + + // TODO WindowInsetsControllerCompat works incorrect + @Suppress("DEPRECATION") + protected fun hideSystemUI() { + window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN + } + + @Suppress("DEPRECATION") + protected fun showSystemUI() { + window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN + } + + protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt new file mode 100644 index 000000000..7db0f6e22 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt @@ -0,0 +1,60 @@ +package org.koitharu.kotatsu.base.ui + +import android.os.Bundle +import android.view.View +import androidx.annotation.CallSuper +import androidx.annotation.StringRes +import androidx.core.graphics.Insets +import androidx.core.view.updatePadding +import androidx.preference.PreferenceFragmentCompat +import androidx.recyclerview.widget.RecyclerView +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner +import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.SettingsHeadersFragment + +abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : + PreferenceFragmentCompat(), + WindowInsetsDelegate.WindowInsetsListener, + RecyclerViewOwner { + + protected val settings by inject(mode = LazyThreadSafetyMode.NONE) + + @Suppress("LeakingThis") + protected val insetsDelegate = WindowInsetsDelegate(this) + + override val recyclerView: RecyclerView + get() = listView + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listView.clipToPadding = false + insetsDelegate.onViewCreated(view) + } + + override fun onDestroyView() { + insetsDelegate.onDestroyView() + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + if (titleId != 0) { + setTitle(getString(titleId)) + } + } + + @CallSuper + override fun onWindowInsetsChanged(insets: Insets) { + listView.updatePadding( + bottom = insets.bottom + ) + } + + @Suppress("UsePropertyAccessSyntax") + protected fun setTitle(title: CharSequence) { + (parentFragment as? SettingsHeadersFragment)?.setTitle(title) + ?: activity?.setTitle(title) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt new file mode 100644 index 000000000..05e07729e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt @@ -0,0 +1,5 @@ +package org.koitharu.kotatsu.base.ui + +import androidx.lifecycle.LifecycleService + +abstract class BaseService : LifecycleService() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt new file mode 100644 index 000000000..f17e3aa9f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.base.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.* +import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +abstract class BaseViewModel : ViewModel() { + + protected val loadingCounter = CountedBooleanLiveData() + protected val errorEvent = SingleLiveEvent() + + val onError: LiveData + get() = errorEvent + + val isLoading: LiveData + get() = loadingCounter + + protected fun launchJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start, block) + + protected fun launchLoadingJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start) { + loadingCounter.increment() + try { + block() + } finally { + loadingCounter.decrement() + } + } + + private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> + throwable.printStackTraceDebug() + if (throwable !is CancellationException) { + errorEvent.postCall(throwable) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt new file mode 100644 index 000000000..241d13f94 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.base.ui + +import android.app.Service +import android.content.Intent +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +abstract class CoroutineIntentService : BaseService() { + + private val mutex = Mutex() + protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + launchCoroutine(intent, startId) + return Service.START_REDELIVER_INTENT + } + + private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch { + mutex.withLock { + try { + withContext(dispatcher) { + processIntent(intent) + } + } finally { + stopSelf(startId) + } + } + } + + protected abstract suspend fun processIntent(intent: Intent?) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt new file mode 100644 index 000000000..8b6da8d3d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.base.ui.dialog + +import android.content.Context +import android.graphics.Color +import android.view.View +import com.google.android.material.bottomsheet.BottomSheetDialog + +class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) { + + /** + * https://github.com/material-components/material-components-android/issues/2582 + */ + @Suppress("DEPRECATION") + override fun onAttachedToWindow() { + val window = window + val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0 + super.onAttachedToWindow() + if (window != null) { + // If the navigation bar is translucent at all, the BottomSheet should be edge to edge + val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF + if (drawEdgeToEdge) { + // Copied from super.onAttachedToWindow: + val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + // Fix super-class's window flag bug by respecting the initial system UI visibility: + window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt similarity index 97% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt index f246aba42..c452bd1ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.dialog +package org.koitharu.kotatsu.base.ui.dialog import android.content.Context import android.content.DialogInterface @@ -77,4 +77,4 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) fun create() = CheckBoxAlertDialog(delegate.create()) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt new file mode 100644 index 000000000..58e353ca8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt @@ -0,0 +1,101 @@ +package org.koitharu.kotatsu.base.ui.dialog + +import android.content.Context +import android.content.DialogInterface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.runBlocking +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemStorageBinding +import org.koitharu.kotatsu.local.data.LocalStorageManager +import java.io.File + +class StorageSelectDialog private constructor(private val delegate: AlertDialog) : + DialogInterface by delegate { + + fun show() = delegate.show() + + class Builder(context: Context, storageManager: LocalStorageManager, listener: OnStorageSelectListener) { + + private val adapter = VolumesAdapter(storageManager) + private val delegate = MaterialAlertDialogBuilder(context) + + init { + if (adapter.isEmpty) { + delegate.setMessage(R.string.cannot_find_available_storage) + } else { + val defaultValue = runBlocking { + storageManager.getDefaultWriteableDir() + } + adapter.selectedItemPosition = adapter.volumes.indexOfFirst { + it.first.canonicalPath == defaultValue?.canonicalPath + } + delegate.setAdapter(adapter) { d, i -> + listener.onStorageSelected(adapter.getItem(i).first) + d.dismiss() + } + } + } + + fun setTitle(@StringRes titleResId: Int): Builder { + delegate.setTitle(titleResId) + return this + } + + fun setTitle(title: CharSequence): Builder { + delegate.setTitle(title) + return this + } + + fun setNegativeButton(@StringRes textId: Int): Builder { + delegate.setNegativeButton(textId, null) + return this + } + + fun create() = StorageSelectDialog(delegate.create()) + } + + private class VolumesAdapter(storageManager: LocalStorageManager) : BaseAdapter() { + + var selectedItemPosition: Int = -1 + val volumes = getAvailableVolumes(storageManager) + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false) + val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also { + view.tag = it + } + val item = volumes[position] + binding.imageViewIndicator.isChecked = selectedItemPosition == position + binding.textViewTitle.text = item.second + binding.textViewSubtitle.text = item.first.path + return view + } + + override fun getItem(position: Int): Pair = volumes[position] + + override fun getItemId(position: Int) = position.toLong() + + override fun getCount() = volumes.size + + override fun hasStableIds() = true + + private fun getAvailableVolumes(storageManager: LocalStorageManager): List> { + return runBlocking { + storageManager.getWriteableDirs().map { + it to storageManager.getStorageDisplayName(it) + } + } + } + } + + fun interface OnStorageSelectListener { + + fun onStorageSelected(file: File) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt index a9e6e13ea..650e816c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import android.view.View import android.view.View.OnClickListener @@ -6,7 +6,7 @@ import android.view.View.OnLongClickListener import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder class AdapterDelegateClickListenerAdapter( - private val adapterDelegate: AdapterDelegateViewBindingViewHolder, + private val adapterDelegate: AdapterDelegateViewBindingViewHolder, private val clickListener: OnListItemClickListener, ) : OnClickListener, OnLongClickListener { @@ -17,4 +17,4 @@ class AdapterDelegateClickListenerAdapter( override fun onLongClick(v: View): Boolean { return clickListener.onItemLongClick(adapterDelegate.item, v) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt similarity index 57% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt index b173df3c8..4a412d081 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt @@ -1,49 +1,31 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -abstract class BoundsScrollListener( - @JvmField protected val offsetTop: Int, - @JvmField protected val offsetBottom: Int -) : RecyclerView.OnScrollListener() { +abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) : + RecyclerView.OnScrollListener() { + + constructor(offset: Int = 0) : this(offset, offset) override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) - if (recyclerView.hasPendingAdapterUpdates()) { - return - } val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() if (firstVisibleItemPosition == RecyclerView.NO_POSITION) { return } + if (firstVisibleItemPosition <= offsetTop) { + onScrolledToStart(recyclerView) + } val visibleItemCount = layoutManager.childCount val totalItemCount = layoutManager.itemCount if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) { onScrolledToEnd(recyclerView) } - if (firstVisibleItemPosition <= offsetTop) { - onScrolledToStart(recyclerView) - } - onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount) } abstract fun onScrolledToStart(recyclerView: RecyclerView) abstract fun onScrolledToEnd(recyclerView: RecyclerView) - - protected open fun onPostScrolled( - recyclerView: RecyclerView, - firstVisibleItemPosition: Int, - visibleItemCount: Int - ) = Unit - - fun invalidate(recyclerView: RecyclerView) { - onScrolled(recyclerView, 0, 0) - } - - fun postInvalidate(recyclerView: RecyclerView) = recyclerView.post { - invalidate(recyclerView) - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt index ddb94ce34..fc6564beb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import android.content.Context import android.util.AttributeSet @@ -34,4 +34,4 @@ class FitHeightGridLayoutManager : GridLayoutManager { super.layoutDecoratedWithMargins(child, left, top, right, bottom) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt index f4a36a227..64e73198a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import android.content.Context import android.util.AttributeSet @@ -34,4 +34,4 @@ class FitHeightLinearLayoutManager : LinearLayoutManager { super.layoutDecoratedWithMargins(child, left, top, right, bottom) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt similarity index 64% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt index e552e1098..b3171a9df 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import android.app.Activity import android.os.Bundle @@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner import kotlinx.coroutines.Dispatchers -import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import kotlin.coroutines.EmptyCoroutineContext private const val KEY_SELECTION = "selection" @@ -23,18 +23,15 @@ class ListSelectionController( private val activity: Activity, private val decoration: AbstractSelectionItemDecoration, private val registryOwner: SavedStateRegistryOwner, - private val callback: Callback2, + private val callback: Callback, ) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider { private var actionMode: ActionMode? = null + private val stateEventObserver = StateEventObserver() val count: Int get() = decoration.checkedItemsCount - init { - registryOwner.lifecycle.addObserver(StateEventObserver()) - } - fun snapshot(): Set { return peekCheckedIds().toSet() } @@ -58,6 +55,7 @@ class ListSelectionController( fun attachToRecyclerView(recyclerView: RecyclerView) { recyclerView.addItemDecoration(decoration) + registryOwner.lifecycle.addObserver(stateEventObserver) } override fun saveState(): Bundle { @@ -89,19 +87,19 @@ class ListSelectionController( } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - return callback.onCreateActionMode(this, mode, menu) + return callback.onCreateActionMode(mode, menu) } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - return callback.onPrepareActionMode(this, mode, menu) + return callback.onPrepareActionMode(mode, menu) } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - return callback.onActionItemClicked(this, mode, item) + return callback.onActionItemClicked(mode, item) } override fun onDestroyActionMode(mode: ActionMode) { - callback.onDestroyActionMode(this, mode) + callback.onDestroyActionMode(mode) clear() actionMode = null } @@ -114,7 +112,7 @@ class ListSelectionController( private fun notifySelectionChanged() { val count = decoration.checkedItemsCount - callback.onSelectionChanged(this, count) + callback.onSelectionChanged(count) if (count == 0) { actionMode?.finish() } else { @@ -131,56 +129,17 @@ class ListSelectionController( notifySelectionChanged() } - @Deprecated("") - interface Callback : Callback2 { + interface Callback : ActionMode.Callback { fun onSelectionChanged(count: Int) - fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean - - fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean - - fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean - - fun onDestroyActionMode(mode: ActionMode) = Unit - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - onSelectionChanged(count) - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - return onCreateActionMode(mode, menu) - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - return onPrepareActionMode(mode, menu) - } - - override fun onActionItemClicked( - controller: ListSelectionController, - mode: ActionMode, - item: MenuItem, - ): Boolean = onActionItemClicked(mode, item) - - override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) { - onDestroyActionMode(mode) - } - } + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean - interface Callback2 { - - fun onSelectionChanged(controller: ListSelectionController, count: Int) - - fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean - - fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.title = controller.count.toString() - return true - } + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean - fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean - fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit + override fun onDestroyActionMode(mode: ActionMode) = Unit } private inner class StateEventObserver : LifecycleEventObserver { @@ -200,4 +159,4 @@ class ListSelectionController( } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt similarity index 57% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt index e394740b9..f39b81d14 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt @@ -1,10 +1,10 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import android.view.View -fun interface OnListItemClickListener { +interface OnListItemClickListener { fun onItemClick(item: I, view: View) fun onItemLongClick(item: I, view: View) = false -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt index 4f70dcd4d..5681cae23 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list +package org.koitharu.kotatsu.base.ui.list import androidx.recyclerview.widget.RecyclerView @@ -15,4 +15,4 @@ class PaginationScrollListener(offset: Int, private val callback: Callback) : fun onScrolledToEnd() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt new file mode 100644 index 000000000..2d91e71c7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt @@ -0,0 +1,87 @@ +package org.koitharu.kotatsu.base.ui.list.decor + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import androidx.core.content.res.getColorOrThrow +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.R as materialR + +@SuppressLint("PrivateResource") +abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val bounds = Rect() + private val thickness: Int + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + init { + paint.style = Paint.Style.FILL + val ta = context.obtainStyledAttributes( + null, + materialR.styleable.MaterialDivider, + materialR.attr.materialDividerStyle, + materialR.style.Widget_Material3_MaterialDivider, + ) + paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor) + thickness = ta.getDimensionPixelSize( + materialR.styleable.MaterialDivider_dividerThickness, + context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness), + ) + ta.recycle() + } + + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + outRect.set(0, thickness, 0, 0) + } + + // TODO implement for horizontal lists on demand + override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) { + if (parent.layoutManager == null || thickness == 0) { + return + } + canvas.save() + val left: Float + val right: Float + if (parent.clipToPadding) { + left = parent.paddingLeft.toFloat() + right = (parent.width - parent.paddingRight).toFloat() + canvas.clipRect( + left, + parent.paddingTop.toFloat(), + right, + (parent.height - parent.paddingBottom).toFloat() + ) + } else { + left = 0f + right = parent.width.toFloat() + } + + var previous: RecyclerView.ViewHolder? = null + for (child in parent.children) { + val holder = parent.getChildViewHolder(child) + if (previous != null && shouldDrawDivider(previous, holder)) { + parent.getDecoratedBoundsWithMargins(child, bounds) + val top: Float = bounds.top + child.translationY + val bottom: Float = top + thickness + canvas.drawRect(left, top, right, bottom, paint) + } + previous = holder + } + canvas.restore() + } + + protected abstract fun shouldDrawDivider( + above: RecyclerView.ViewHolder, + below: RecyclerView.ViewHolder, + ): Boolean +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt index 20e3aef78..1974f6a5d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list.decor +package org.koitharu.kotatsu.base.ui.list.decor import android.graphics.Canvas import android.graphics.Rect @@ -67,7 +67,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() { if (parent.clipToPadding) { canvas.clipRect( parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight, - parent.height - parent.paddingBottom, + parent.height - parent.paddingBottom ) } @@ -108,4 +108,4 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() { bounds: RectF, state: RecyclerView.State, ) = Unit -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt similarity index 82% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt index 88f3593ac..b86a87dce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.list.decor +package org.koitharu.kotatsu.base.ui.list.decor import android.graphics.Rect import android.view.View @@ -11,8 +11,8 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec outRect: Rect, view: View, parent: RecyclerView, - state: RecyclerView.State, + state: RecyclerView.State ) { outRect.set(spacing, spacing, spacing, spacing) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt new file mode 100644 index 000000000..5662f026a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.base.ui.list.decor + +import android.graphics.Rect +import android.util.SparseIntArray +import android.view.View +import androidx.core.util.getOrDefault +import androidx.core.util.set +import androidx.recyclerview.widget.RecyclerView + +class TypedSpacingItemDecoration( + vararg spacingMapping: Pair, + private val fallbackSpacing: Int = 0, +) : RecyclerView.ItemDecoration() { + + private val mapping = SparseIntArray(spacingMapping.size) + + init { + spacingMapping.forEach { (k, v) -> mapping[k] = v } + } + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val itemType = parent.getChildViewHolder(view)?.itemViewType + val spacing = if (itemType == null) { + fallbackSpacing + } else { + mapping.getOrDefault(itemType, fallbackSpacing) + } + outRect.set(spacing, spacing, spacing, spacing) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt similarity index 79% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt index 2296aef53..68a65e638 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt @@ -1,11 +1,10 @@ -package org.koitharu.kotatsu.core.ui.util +package org.koitharu.kotatsu.base.ui.util -import androidx.activity.OnBackPressedCallback import androidx.appcompat.view.ActionMode import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -class ActionModeDelegate : OnBackPressedCallback(false) { +class ActionModeDelegate { private var activeActionMode: ActionMode? = null private var listeners: MutableList? = null @@ -13,19 +12,13 @@ class ActionModeDelegate : OnBackPressedCallback(false) { val isActionModeStarted: Boolean get() = activeActionMode != null - override fun handleOnBackPressed() { - finishActionMode() - } - fun onSupportActionModeStarted(mode: ActionMode) { activeActionMode = mode - isEnabled = true listeners?.forEach { it.onActionModeStarted(mode) } } fun onSupportActionModeFinished(mode: ActionMode) { activeActionMode = null - isEnabled = false listeners?.forEach { it.onActionModeFinished(mode) } } @@ -45,10 +38,6 @@ class ActionModeDelegate : OnBackPressedCallback(false) { owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) } - fun finishActionMode() { - activeActionMode?.finish() - } - private inner class ListenerLifecycleObserver( private val listener: ActionModeListener, ) : DefaultLifecycleObserver { @@ -58,4 +47,4 @@ class ActionModeDelegate : OnBackPressedCallback(false) { removeListener(listener) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt similarity index 78% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt index fde599ede..0c87ff612 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.util +package org.koitharu.kotatsu.base.ui.util import androidx.appcompat.view.ActionMode @@ -7,4 +7,4 @@ interface ActionModeListener { fun onActionModeStarted(mode: ActionMode) fun onActionModeFinished(mode: ActionMode) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt index f97db54ae..f072da2fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt @@ -1,12 +1,17 @@ -package org.koitharu.kotatsu.core.ui +package org.koitharu.kotatsu.base.ui.util import android.app.Activity import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle +import java.util.* -interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks { +class ActivityRecreationHandle : ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + private val activities = WeakHashMap() + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + activities[activity] = Unit + } override fun onActivityStarted(activity: Activity) = Unit @@ -18,5 +23,12 @@ interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks { override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - override fun onActivityDestroyed(activity: Activity) = Unit -} + override fun onActivityDestroyed(activity: Activity) { + activities.remove(activity) + } + + fun recreateAll() { + val snapshot = activities.keys.toList() + snapshot.forEach { it.recreate() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt new file mode 100644 index 000000000..d654e541d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.base.ui.util + +import androidx.annotation.AnyThread +import androidx.lifecycle.LiveData +import java.util.concurrent.atomic.AtomicInteger + +class CountedBooleanLiveData : LiveData(false) { + + private val counter = AtomicInteger(0) + + @AnyThread + fun increment() { + if (counter.getAndIncrement() == 0) { + postValue(true) + } + } + + @AnyThread + fun decrement() { + if (counter.decrementAndGet() == 0) { + postValue(false) + } + } + + @AnyThread + fun reset() { + if (counter.getAndSet(0) != 0) { + postValue(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt similarity index 72% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt index f34963f15..9b0976d51 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt @@ -1,8 +1,8 @@ -package org.koitharu.kotatsu.core.ui.util +package org.koitharu.kotatsu.base.ui.util import androidx.recyclerview.widget.RecyclerView interface RecyclerViewOwner { val recyclerView: RecyclerView -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt similarity index 79% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt index 8d648ec3a..526c1e986 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.util +package org.koitharu.kotatsu.base.ui.util import android.content.Context import android.util.AttributeSet @@ -8,10 +8,10 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior import androidx.core.view.ViewCompat import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton -open class ShrinkOnScrollBehavior : Behavior { +class ShrinkOnScrollBehavior : Behavior { - constructor() : super() - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + @Suppress("unused") constructor() : super() + @Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, @@ -45,4 +45,4 @@ open class ShrinkOnScrollBehavior : Behavior { } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt index aa3ce78d1..c0d9c8c53 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt @@ -1,21 +1,19 @@ -package org.koitharu.kotatsu.core.ui.util +package org.koitharu.kotatsu.base.ui.util import android.view.View import androidx.core.graphics.Insets import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import java.util.LinkedList -class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { +class WindowInsetsDelegate( + private val listener: WindowInsetsListener, +) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { - @JvmField var handleImeInsets: Boolean = false - @JvmField var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null - private val listeners = LinkedList() private var lastInsets: Insets? = null override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { @@ -29,7 +27,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()) } if (newInsets != lastInsets) { - listeners.forEach { it.onWindowInsetsChanged(newInsets) } + listener.onWindowInsetsChanged(newInsets) lastInsets = newInsets } return handledInsets @@ -52,15 +50,6 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis } } - fun addInsetsListener(listener: WindowInsetsListener) { - listeners.add(listener) - lastInsets?.let { listener.onWindowInsetsChanged(it) } - } - - fun removeInsetsListener(listener: WindowInsetsListener) { - listeners.remove(listener) - } - fun onViewCreated(view: View) { ViewCompat.setOnApplyWindowInsetsListener(view, this) view.addOnLayoutChangeListener(this) @@ -70,8 +59,8 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis lastInsets = null } - fun interface WindowInsetsListener { + interface WindowInsetsListener { fun onWindowInsetsChanged(insets: Insets) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt new file mode 100644 index 000000000..77d4acc2c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.annotation.AttrRes +import androidx.annotation.IdRes +import androidx.core.view.children +import com.google.android.material.button.MaterialButton + +class CheckableButtonGroup @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener { + + var onCheckedChangeListener: OnCheckedChangeListener? = null + + override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) { + if (child is MaterialButton) { + child.setOnClickListener(this) + } + super.addView(child, index, params) + } + + override fun onClick(v: View) { + setCheckedId(v.id) + } + + fun setCheckedId(@IdRes viewRes: Int) { + children.forEach { + (it as? MaterialButton)?.isChecked = it.id == viewRes + } + onCheckedChangeListener?.onCheckedChanged(this, viewRes) + } + + fun interface OnCheckedChangeListener { + fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt index b872917c0..5d601d67c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.widgets +package org.koitharu.kotatsu.base.ui.widgets import android.content.Context import android.os.Parcel @@ -10,7 +10,6 @@ import android.widget.Checkable import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatImageView import androidx.core.os.ParcelCompat -import androidx.customview.view.AbsSavedState class CheckableImageView @JvmOverloads constructor( context: Context, @@ -74,7 +73,7 @@ class CheckableImageView @JvmOverloads constructor( fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) } - private class SavedState : AbsSavedState { + private class SavedState : BaseSavedState { val isChecked: Boolean @@ -82,7 +81,7 @@ class CheckableImageView @JvmOverloads constructor( isChecked = checked } - constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { + constructor(source: Parcel) : super(source) { isChecked = ParcelCompat.readBoolean(source) } @@ -92,13 +91,12 @@ class CheckableImageView @JvmOverloads constructor( } companion object { - @Suppress("unused") @JvmField val CREATOR: Creator = object : Creator { - override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) + override fun createFromParcel(`in`: Parcel) = SavedState(`in`) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt index 812e56285..c20b615cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt @@ -1,32 +1,29 @@ -package org.koitharu.kotatsu.core.ui.widgets +package org.koitharu.kotatsu.base.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.View.OnClickListener -import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.core.view.children import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.castOrNull class ChipsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle, + defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle ) : ChipGroup(context, attrs, defStyleAttr) { private var isLayoutSuppressedCompat = false private var isLayoutCalledOnSuppressed = false - private val chipOnClickListener = OnClickListener { + private var chipOnClickListener = OnClickListener { onChipClickListener?.onChipClick(it as Chip, it.tag) } - private val chipOnCloseListener = OnClickListener { + private var chipOnCloseListener = OnClickListener { onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) } - private val chipStyle: Int var onChipClickListener: OnChipClickListener? = null set(value) { field = value @@ -40,20 +37,6 @@ class ChipsView @JvmOverloads constructor( children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible } } - init { - val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) - chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) - ta.recycle() - - if (isInEditMode) { - setChips( - List(5) { - ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false) - }, - ) - } - } - override fun requestLayout() { if (isLayoutSuppressedCompat) { isLayoutCalledOnSuppressed = true @@ -77,41 +60,27 @@ class ChipsView @JvmOverloads constructor( } } - fun getCheckedData(cls: Class): Set { - val result = LinkedHashSet(childCount) - for (child in children) { - if (child is Chip && child.isChecked) { - result += cls.castOrNull(child.tag) ?: continue - } - } - return result - } - private fun bindChip(chip: Chip, model: ChipModel) { chip.text = model.title - chip.isClickable = onChipClickListener != null || model.isCheckable - chip.isCheckable = model.isCheckable if (model.icon == 0) { - chip.chipIcon = null chip.isChipIconVisible = false } else { + chip.isCheckedIconVisible = true chip.setChipIconResource(model.icon) - chip.isChipIconVisible = true } - chip.isChecked = model.isChecked + chip.isClickable = onChipClickListener != null chip.tag = model.data } private fun addChip(): Chip { val chip = Chip(context) - val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) + val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) chip.setChipDrawable(drawable) - chip.isCheckedIconVisible = true - chip.isChipIconVisible = false chip.isCloseIconVisible = onChipCloseClickListener != null chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setEnsureMinTouchTargetSize(false) chip.setOnClickListener(chipOnClickListener) + chip.isCheckable = false addView(chip) return chip } @@ -126,14 +95,32 @@ class ChipsView @JvmOverloads constructor( } } - data class ChipModel( - @ColorRes val tint: Int, - val title: CharSequence, + class ChipModel( @DrawableRes val icon: Int, - val isCheckable: Boolean, - val isChecked: Boolean, - val data: Any? = null, - ) + val title: CharSequence, + val data: Any? = null + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChipModel + + if (icon != other.icon) return false + if (title != other.title) return false + if (data != other.data) return false + + return true + } + + override fun hashCode(): Int { + var result = icon + result = 31 * result + title.hashCode() + result = 31 * result + data.hashCode() + return result + } + } fun interface OnChipClickListener { @@ -144,4 +131,4 @@ class ChipsView @JvmOverloads constructor( fun onChipCloseClick(chip: Chip, data: Any?) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt index 9bcd4ca60..3a52eb237 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.widgets +package org.koitharu.kotatsu.base.ui.widgets import android.content.Context import android.util.AttributeSet @@ -40,4 +40,4 @@ class CoverImageView @JvmOverloads constructor( } setMeasuredDimension(desiredWidth, desiredHeight) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt new file mode 100644 index 000000000..909252b48 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.postDelayed +import com.google.android.material.color.MaterialColors +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding +import org.koitharu.kotatsu.utils.ext.getThemeColorStateList +import com.google.android.material.R as materialR + +private const val ENTER_DURATION = 300L +private const val EXIT_DURATION = 200L +private const val SHORT_DURATION_MS = 1_500L +private const val LONG_DURATION_MS = 2_750L + +/** + * A custom snackbar implementation allowing more control over placement and entry/exit animations. + * + * Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google. + * + * https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt + */ +class FadingSnackbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this) + + init { + binding.snackbarLayout.background = createThemedBackground() + } + + fun dismiss() { + if (visibility == VISIBLE && alpha == 1f) { + animate() + .alpha(0f) + .withEndAction { visibility = GONE } + .duration = EXIT_DURATION + } + } + + fun show( + messageText: CharSequence?, + @StringRes actionId: Int = 0, + duration: Int = Snackbar.LENGTH_SHORT, + onActionClick: (FadingSnackbar.() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, + ) { + binding.snackbarText.text = messageText + if (actionId != 0) { + with(binding.snackbarAction) { + visibility = VISIBLE + text = context.getString(actionId) + setOnClickListener { + onActionClick?.invoke(this@FadingSnackbar) ?: dismiss() + } + } + } else { + binding.snackbarAction.visibility = GONE + } + alpha = 0f + visibility = VISIBLE + animate() + .alpha(1f) + .duration = ENTER_DURATION + if (duration == Snackbar.LENGTH_INDEFINITE) { + return + } + val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS + postDelayed(durationMs) { + dismiss() + onDismiss?.invoke() + } + } + + private fun createThemedBackground(): Drawable { + val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f) + val shapeAppearanceModel = ShapeAppearanceModel.builder( + context, + materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall, + 0 + ).build() + val background = createMaterialShapeDrawableBackground( + backgroundColor, + shapeAppearanceModel, + ) + val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse) + return if (backgroundTint != null) { + val wrappedDrawable = DrawableCompat.wrap(background) + DrawableCompat.setTintList(wrappedDrawable, backgroundTint) + wrappedDrawable + } else { + DrawableCompat.wrap(background) + } + } + + private fun createMaterialShapeDrawableBackground( + @ColorInt backgroundColor: Int, + shapeAppearanceModel: ShapeAppearanceModel, + ): MaterialShapeDrawable { + val background = MaterialShapeDrawable(shapeAppearanceModel) + background.fillColor = ColorStateList.valueOf(backgroundColor) + return background + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt index 71d9314f7..bdaf8f476 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.widgets +package org.koitharu.kotatsu.base.ui.widgets import android.annotation.SuppressLint import android.content.Context @@ -9,17 +9,16 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.RoundRectShape +import android.graphics.drawable.shapes.RectShape import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatCheckedTextView -import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import com.google.android.material.ripple.RippleUtils import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.resolveDp +import org.koitharu.kotatsu.utils.ext.getThemeColorStateList @SuppressLint("RestrictedApi") class ListItemTextView @JvmOverloads constructor( @@ -39,11 +38,10 @@ class ListItemTextView @JvmOverloads constructor( context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) { val itemRippleColor = getRippleColor(context) val shape = createShapeDrawable(this) - val roundCorners = FloatArray(8) { resources.resolveDp(32f) } background = RippleDrawable( RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), shape, - ShapeDrawable(RoundRectShape(roundCorners, null, null)), + ShapeDrawable(RectShape()), ) checkedDrawableStart = getDrawable(R.styleable.ListItemTextView_checkedDrawableStart) checkedDrawableEnd = getDrawable(R.styleable.ListItemTextView_checkedDrawableEnd) @@ -120,7 +118,7 @@ class ListItemTextView @JvmOverloads constructor( } private fun getRippleColor(context: Context): ColorStateList { - return ContextCompat.getColorStateList(context, R.color.selector_overlay) + return context.getThemeColorStateList(android.R.attr.colorControlHighlight) ?: ColorStateList.valueOf(Color.TRANSPARENT) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt new file mode 100644 index 000000000..589d75382 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout + +class SquareLayout @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, widthMeasureSpec) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt similarity index 72% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt index 57870cf19..0d915dc4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt @@ -1,5 +1,6 @@ -package org.koitharu.kotatsu.core.ui.widgets +package org.koitharu.kotatsu.base.ui.widgets +import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.Gravity @@ -20,7 +21,8 @@ class WindowInsetHolder @JvmOverloads constructor( private var desiredHeight = 0 private var desiredWidth = 0 - override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + @SuppressLint("RtlHardcoded") + override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this) .getInsets(WindowInsetsCompat.Type.systemBars()) val gravity = getLayoutGravity() @@ -39,26 +41,24 @@ class WindowInsetHolder @JvmOverloads constructor( desiredHeight = newHeight requestLayout() } - return super.onApplyWindowInsets(insets) + return super.dispatchApplyWindowInsets(insets) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthMode = MeasureSpec.getMode(widthMeasureSpec) - val widthSize = MeasureSpec.getSize(widthMeasureSpec) val heightMode = MeasureSpec.getMode(heightMeasureSpec) - val heightSize = MeasureSpec.getSize(heightMeasureSpec) - - val width: Int = when (widthMode) { - MeasureSpec.EXACTLY -> widthSize - MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize) - else -> desiredWidth - } - val height = when (heightMode) { - MeasureSpec.EXACTLY -> heightSize - MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize) - else -> desiredHeight - } - setMeasuredDimension(width, height) + super.onMeasure( + if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) { + widthMeasureSpec + } else { + MeasureSpec.makeMeasureSpec(desiredWidth, widthMode) + }, + if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) { + heightMeasureSpec + } else { + MeasureSpec.makeMeasureSpec(desiredHeight, heightMode) + }, + ) } private fun getLayoutGravity(): Int { @@ -69,4 +69,4 @@ class WindowInsetHolder @JvmOverloads constructor( else -> Gravity.NO_GRAVITY } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt new file mode 100644 index 000000000..4a8294765 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.bookmarks + +import org.koin.dsl.module +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository + +val bookmarksModule + get() = module { + + factory { BookmarksRepository(get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt new file mode 100644 index 000000000..4bd63d65d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkWithManga.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity + +class BookmarkWithManga( + @Embedded val bookmark: BookmarkEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "manga_id" + ) + val manga: MangaEntity, + @Relation( + parentColumn = "manga_id", + entityColumn = "tag_id", + associateBy = Junction(MangaTagsEntity::class) + ) + val tags: List, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt new file mode 100644 index 000000000..dd023be7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -0,0 +1,26 @@ +package org.koitharu.kotatsu.bookmarks.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class BookmarksDao { + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") + abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC") + abstract fun observe(mangaId: Long): Flow> + + @Insert + abstract suspend fun insert(entity: BookmarkEntity) + + @Delete + abstract suspend fun delete(entity: BookmarkEntity) + + @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + abstract suspend fun delete(mangaId: Long, pageId: Long) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt similarity index 65% rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt index 20dd8a77a..0ab69dd18 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt @@ -1,8 +1,14 @@ package org.koitharu.kotatsu.bookmarks.data import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.parsers.model.Manga -import java.time.Instant +import java.util.* + +fun BookmarkWithManga.toBookmark() = bookmark.toBookmark( + manga.toManga(tags.toMangaTags()) +) fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( manga = manga, @@ -11,7 +17,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( page = page, scroll = scroll, imageUrl = imageUrl, - createdAt = Instant.ofEpochMilli(createdAt), + createdAt = Date(createdAt), percent = percent, ) @@ -22,13 +28,6 @@ fun Bookmark.toEntity() = BookmarkEntity( page = page, scroll = scroll, imageUrl = imageUrl, - createdAt = createdAt.toEpochMilli(), + createdAt = createdAt.time, percent = percent, -) - -fun Collection.toBookmarks(manga: Manga) = map { - it.toBookmark(manga) -} - -@JvmName("bookmarksIds") -fun Collection.ids() = map { it.pageId } +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt new file mode 100644 index 000000000..5b6ff3bf0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import java.util.* + +class Bookmark( + val manga: Manga, + val pageId: Long, + val chapterId: Long, + val page: Int, + val scroll: Int, + val imageUrl: String, + val createdAt: Date, + val percent: Float, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Bookmark + + if (manga != other.manga) return false + if (pageId != other.pageId) return false + if (chapterId != other.chapterId) return false + if (page != other.page) return false + if (scroll != other.scroll) return false + if (imageUrl != other.imageUrl) return false + if (createdAt != other.createdAt) return false + if (percent != other.percent) return false + + return true + } + + override fun hashCode(): Int { + var result = manga.hashCode() + result = 31 * result + pageId.hashCode() + result = 31 * result + chapterId.hashCode() + result = 31 * result + page + result = 31 * result + scroll + result = 31 * result + imageUrl.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + percent.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt new file mode 100644 index 000000000..df63c03aa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.bookmarks.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.bookmarks.data.toBookmark +import org.koitharu.kotatsu.bookmarks.data.toEntity +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.toEntities +import org.koitharu.kotatsu.core.db.entity.toEntity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.mapItems + +class BookmarksRepository( + private val db: MangaDatabase, +) { + + fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow { + return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } + } + + fun observeBookmarks(manga: Manga): Flow> { + return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) } + } + + suspend fun addBookmark(bookmark: Bookmark) { + db.withTransaction { + val tags = bookmark.manga.tags.toEntities() + db.tagsDao.upsert(tags) + db.mangaDao.upsert(bookmark.manga.toEntity(), tags) + db.bookmarksDao.insert(bookmark.toEntity()) + } + } + + suspend fun removeBookmark(mangaId: Long, pageId: Long) { + db.bookmarksDao.delete(mangaId, pageId) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt index c947e0669..38d749c10 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt @@ -1,42 +1,44 @@ -package org.koitharu.kotatsu.bookmarks.ui.adapter +package org.koitharu.kotatsu.bookmarks.ui import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.decodeRegion -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemBookmarkBinding +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.referer fun bookmarkListAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) } ) { + val listener = AdapterDelegateClickListenerAdapter(this, clickListener) binding.root.setOnClickListener(listener) binding.root.setOnLongClickListener(listener) bind { - binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { - size(CoverSizeResolver(binding.imageViewThumb)) + binding.imageViewThumb.newImageRequest(item.imageUrl)?.run { + referer(item.manga.publicUrl) placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) + error(R.drawable.ic_placeholder) allowRgb565(true) - tag(item) - decodeRegion(item.scroll) - source(item.manga.source) + lifecycle(lifecycleOwner) enqueueWith(coil) } } -} + + onViewRecycled { + binding.imageViewThumb.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt new file mode 100644 index 000000000..92040bc97 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.bookmarks.ui + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.bookmarks.domain.Bookmark + +class BookmarksAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter( + DiffCallback(), + bookmarkListAD(coil, lifecycleOwner, clickListener) +) { + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId + } + + override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean { + return oldItem.imageUrl == newItem.imageUrl + } + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt similarity index 64% rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt index af49d1dcd..442808bad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -8,40 +8,31 @@ 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.R as materialR import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.databinding.ActivityBrowserBinding -import org.koitharu.kotatsu.parsers.network.UserAgents -import com.google.android.material.R as materialR @SuppressLint("SetJavaScriptEnabled") class BrowserActivity : BaseActivity(), BrowserCallback { - private lateinit var onBackPressedCallback: WebViewBackPressedCallback - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { - return - } + setContentView(ActivityBrowserBinding.inflate(layoutInflater)) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) } - with(viewBinding.webView.settings) { + with(binding.webView.settings) { javaScriptEnabled = true - userAgentString = UserAgents.CHROME_MOBILE + userAgentString = UserAgentInterceptor.userAgent } - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - viewBinding.webView.webViewClient = BrowserClient(this) - viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) - onBackPressedDispatcher.addCallback(onBackPressedCallback) + binding.webView.webViewClient = BrowserClient(this) + binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar) if (savedInstanceState != null) { return } @@ -51,66 +42,70 @@ class BrowserActivity : BaseActivity(), BrowserCallback } else { onTitleChanged( intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_), - url, + url ) - viewBinding.webView.loadUrl(url) + binding.webView.loadUrl(url) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - viewBinding.webView.saveState(outState) + binding.webView.saveState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - viewBinding.webView.restoreState(savedInstanceState) + binding.webView.restoreState(savedInstanceState) } override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.opt_browser, menu) - return true + return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { - viewBinding.webView.stopLoading() + binding.webView.stopLoading() finishAfterTransition() true } - R.id.action_browser -> { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(viewBinding.webView.url) + intent.data = Uri.parse(binding.webView.url) try { startActivity(Intent.createChooser(intent, item.title)) } catch (_: ActivityNotFoundException) { } true } - else -> super.onOptionsItemSelected(item) } + override fun onBackPressed() { + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + super.onBackPressed() + } + } + override fun onPause() { - viewBinding.webView.onPause() + binding.webView.onPause() super.onPause() } override fun onResume() { super.onResume() - viewBinding.webView.onResume() + binding.webView.onResume() } override fun onDestroy() { super.onDestroy() - viewBinding.webView.stopLoading() - viewBinding.webView.destroy() + binding.webView.destroy() } override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading + binding.progressBar.isVisible = isLoading } override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { @@ -118,15 +113,13 @@ class BrowserActivity : BaseActivity(), BrowserCallback supportActionBar?.subtitle = subtitle } - override fun onHistoryChanged() { - onBackPressedCallback.onHistoryChanged() - } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding( + binding.appbar.updatePadding( top = insets.top, + left = insets.left, + right = insets.right, ) - viewBinding.root.updatePadding( + binding.root.updatePadding( left = insets.left, right = insets.right, bottom = insets.bottom, @@ -143,4 +136,4 @@ class BrowserActivity : BaseActivity(), BrowserCallback .putExtra(EXTRA_TITLE, title) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt similarity index 72% rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt index b85fdaa39..e6f3fae84 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.browser -interface BrowserCallback : OnHistoryChangedListener { +interface BrowserCallback { fun onLoadingStateChanged(isLoading: Boolean) fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt index e6906014e..0beaa7566 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt @@ -4,7 +4,7 @@ import android.graphics.Bitmap import android.webkit.WebView import android.webkit.WebViewClient -open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() { +class BrowserClient(private val callback: BrowserCallback) : WebViewClient() { override fun onPageFinished(webView: WebView, url: String) { super.onPageFinished(webView, url) @@ -20,9 +20,4 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient( super.onPageCommitVisible(view, url) callback.onTitleChanged(view.title.orEmpty(), url) } - - override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { - super.doUpdateVisitedHistory(view, url, isReload) - callback.onHistoryChanged() - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt similarity index 83% rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt rename to app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt index 55bdc9707..27e7f9b38 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt @@ -2,13 +2,14 @@ package org.koitharu.kotatsu.browser import android.webkit.WebChromeClient import android.webkit.WebView +import android.widget.ProgressBar import androidx.core.view.isVisible -import com.google.android.material.progressindicator.BaseProgressIndicator +import org.koitharu.kotatsu.utils.ext.setProgressCompat private const val PROGRESS_MAX = 100 class ProgressChromeClient( - private val progressIndicator: BaseProgressIndicator<*>, + private val progressIndicator: ProgressBar, ) : WebChromeClient() { init { @@ -27,4 +28,4 @@ class ProgressChromeClient( progressIndicator.isIndeterminate = true } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt new file mode 100644 index 000000000..aedd22605 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.browser.cloudflare + +interface CloudFlareCallback { + + fun onPageLoaded() + + fun onCheckPassed() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt similarity index 65% rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt rename to app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index 9bdaed17d..264134e52 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -2,32 +2,32 @@ package org.koitharu.kotatsu.browser.cloudflare import android.graphics.Bitmap import android.webkit.WebView +import android.webkit.WebViewClient import okhttp3.HttpUrl.Companion.toHttpUrl -import org.koitharu.kotatsu.browser.BrowserClient -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.network.AndroidCookieJar private const val CF_CLEARANCE = "cf_clearance" class CloudFlareClient( - private val cookieJar: MutableCookieJar, + private val cookieJar: AndroidCookieJar, private val callback: CloudFlareCallback, private val targetUrl: String, -) : BrowserClient(callback) { +) : WebViewClient() { private val oldClearance = getClearance() - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) checkClearance() } - override fun onPageCommitVisible(view: WebView, url: String?) { + override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) callback.onPageLoaded() } - override fun onPageFinished(webView: WebView, url: String) { - super.onPageFinished(webView, url) + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) callback.onPageLoaded() } @@ -42,4 +42,4 @@ class CloudFlareClient( return cookieJar.loadForRequest(targetUrl.toHttpUrl()) .find { it.name == CF_CLEARANCE }?.value } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt new file mode 100644 index 000000000..8c1de2625 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.browser.cloudflare + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebSettings +import androidx.core.view.isInvisible +import androidx.fragment.app.setFragmentResult +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.network.UserAgentInterceptor +import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding +import org.koitharu.kotatsu.utils.ext.stringArgument +import org.koitharu.kotatsu.utils.ext.withArgs + +class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback { + + private val url by stringArgument(ARG_URL) + private val pendingResult = Bundle(1) + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentCloudflareBinding.inflate(inflater, container, false) + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(binding.webView.settings) { + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + domStorageEnabled = true + databaseEnabled = true + userAgentString = UserAgentInterceptor.userAgent + } + binding.webView.webViewClient = CloudFlareClient(get(), this, url.orEmpty()) + CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true) + if (url.isNullOrEmpty()) { + dismissAllowingStateLoss() + } else { + binding.webView.loadUrl(url.orEmpty()) + } + } + + override fun onDestroyView() { + binding.webView.stopLoading() + super.onDestroyView() + } + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setNegativeButton(android.R.string.cancel, null) + } + + override fun onResume() { + super.onResume() + binding.webView.onResume() + } + + override fun onPause() { + binding.webView.onPause() + super.onPause() + } + + override fun onDismiss(dialog: DialogInterface) { + setFragmentResult(TAG, pendingResult) + super.onDismiss(dialog) + } + + override fun onPageLoaded() { + bindingOrNull()?.progressBar?.isInvisible = true + } + + override fun onCheckPassed() { + pendingResult.putBoolean(EXTRA_RESULT, true) + dismiss() + } + + companion object { + + const val TAG = "CloudFlareDialog" + const val EXTRA_RESULT = "result" + private const val ARG_URL = "url" + + fun newInstance(url: String) = CloudFlareDialog().withArgs(1) { + putString(ARG_URL, url) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt new file mode 100644 index 000000000..407365d3c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.core.backup + +import org.json.JSONArray + +class BackupEntry( + val name: String, + val data: JSONArray +) { + + companion object Names { + + const val INDEX = "index" + const val HISTORY = "history" + const val CATEGORIES = "categories" + const val FAVOURITES = "favourites" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt new file mode 100644 index 000000000..27dd10255 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -0,0 +1,128 @@ +package org.koitharu.kotatsu.core.backup + +import androidx.room.withTransaction +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.util.json.JSONIterator +import org.koitharu.kotatsu.parsers.util.json.mapJSON + +private const val PAGE_SIZE = 10 + +class BackupRepository(private val db: MangaDatabase) { + + suspend fun dumpHistory(): BackupEntry { + var offset = 0 + val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) + while (true) { + val history = db.historyDao.findAll(offset, PAGE_SIZE) + if (history.isEmpty()) { + break + } + offset += history.size + for (item in history) { + val manga = JsonSerializer(item.manga).toJson() + val tags = JSONArray() + item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } + manga.put("tags", tags) + val json = JsonSerializer(item.history).toJson() + json.put("manga", manga) + entry.data.put(json) + } + } + return entry + } + + suspend fun dumpCategories(): BackupEntry { + val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) + val categories = db.favouriteCategoriesDao.findAll() + for (item in categories) { + entry.data.put(JsonSerializer(item).toJson()) + } + return entry + } + + suspend fun dumpFavourites(): BackupEntry { + var offset = 0 + val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) + while (true) { + val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE) + if (favourites.isEmpty()) { + break + } + offset += favourites.size + for (item in favourites) { + val manga = JsonSerializer(item.manga).toJson() + val tags = JSONArray() + item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } + manga.put("tags", tags) + val json = JsonSerializer(item.favourite).toJson() + json.put("manga", manga) + entry.data.put(json) + } + } + return entry + } + + fun createIndex(): BackupEntry { + val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) + val json = JSONObject() + json.put("app_id", BuildConfig.APPLICATION_ID) + json.put("app_version", BuildConfig.VERSION_CODE) + json.put("created_at", System.currentTimeMillis()) + entry.data.put(json) + return entry + } + + suspend fun restoreHistory(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val mangaJson = item.getJSONObject("manga") + val manga = JsonDeserializer(mangaJson).toMangaEntity() + val tags = mangaJson.getJSONArray("tags").mapJSON { + JsonDeserializer(it).toTagEntity() + } + val history = JsonDeserializer(item).toHistoryEntity() + result += runCatching { + db.withTransaction { + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga, tags) + db.historyDao.upsert(history) + } + } + } + return result + } + + suspend fun restoreCategories(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val category = JsonDeserializer(item).toFavouriteCategoryEntity() + result += runCatching { + db.favouriteCategoriesDao.upsert(category) + } + } + return result + } + + suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val mangaJson = item.getJSONObject("manga") + val manga = JsonDeserializer(mangaJson).toMangaEntity() + val tags = mangaJson.getJSONArray("tags").mapJSON { + JsonDeserializer(it).toTagEntity() + } + val favourite = JsonDeserializer(item).toFavouriteEntity() + result += runCatching { + db.withTransaction { + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga, tags) + db.favouritesDao.upsert(favourite) + } + } + } + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt new file mode 100644 index 000000000..25e1d3688 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.backup + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.json.JSONArray +import java.io.File +import java.util.zip.ZipFile + +class BackupZipInput(val file: File) : Closeable { + + private val zipFile = ZipFile(file) + + suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name) + val json = zipFile.getInputStream(entry).use { + JSONArray(it.bufferedReader().readText()) + } + BackupEntry(name, json) + } + + override fun close() { + zipFile.close() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt similarity index 80% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt rename to app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt index 07bce8ea3..8a6217d04 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -6,10 +6,9 @@ import kotlinx.coroutines.runInterruptible import okio.Closeable import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.zip.ZipOutput +import org.koitharu.kotatsu.utils.ext.format import java.io.File -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale +import java.util.* import java.util.zip.Deflater class BackupZipOutput(val file: File) : Closeable { @@ -17,7 +16,7 @@ class BackupZipOutput(val file: File) : Closeable { private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { - output.put(entry.name.key, entry.data.toString(2)) + output.put(entry.name, entry.data.toString(2)) } suspend fun finish() = runInterruptible(Dispatchers.IO) { @@ -29,7 +28,7 @@ class BackupZipOutput(val file: File) : Closeable { } } -const val DIR_BACKUPS = "backups" +private const val DIR_BACKUPS = "backups" suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { val dir = context.run { @@ -39,8 +38,8 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl val filename = buildString { append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append('_') - append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy"))) + append(Date().format("ddMMyyyy")) append(".bk.zip") } BackupZipOutput(File(dir, filename)) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt rename to app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt index 9311bb253..cd12a97fe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt @@ -11,9 +11,6 @@ class CompositeResult { val failures: List get() = errors.filterNotNull() - val isEmpty: Boolean - get() = errors.isEmpty() && successCount == 0 - val isAllSuccess: Boolean get() = errors.none { it != null } @@ -39,4 +36,4 @@ class CompositeResult { result.errors.addAll(other.errors) return result } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt similarity index 64% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt rename to app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index 60462178e..96463e4a5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -1,9 +1,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONObject -import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity @@ -11,7 +9,6 @@ import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault -import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull class JsonDeserializer(private val json: JSONObject) { @@ -19,9 +16,7 @@ class JsonDeserializer(private val json: JSONObject) { fun toFavouriteEntity() = FavouriteEntity( mangaId = json.getLong("manga_id"), categoryId = json.getLong("category_id"), - sortKey = json.getIntOrDefault("sort_key", 0), createdAt = json.getLong("created_at"), - deletedAt = 0L, ) fun toMangaEntity() = MangaEntity( @@ -36,14 +31,14 @@ class JsonDeserializer(private val json: JSONObject) { largeCoverUrl = json.getStringOrNull("large_cover_url"), state = json.getStringOrNull("state"), author = json.getStringOrNull("author"), - source = json.getString("source"), + source = json.getString("source") ) fun toTagEntity() = TagEntity( id = json.getLong("id"), title = json.getString("title"), key = json.getString("key"), - source = json.getString("source"), + source = json.getString("source") ) fun toHistoryEntity() = HistoryEntity( @@ -54,7 +49,6 @@ class JsonDeserializer(private val json: JSONObject) { page = json.getInt("page"), scroll = json.getDouble("scroll").toFloat(), percent = json.getFloatOrDefault("percent", -1f), - deletedAt = 0L, ) fun toFavouriteCategoryEntity() = FavouriteCategoryEntity( @@ -64,37 +58,5 @@ class JsonDeserializer(private val json: JSONObject) { title = json.getString("title"), order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, track = json.getBooleanOrDefault("track", true), - isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true), - deletedAt = 0L, ) - - fun toBookmarkEntity() = BookmarkEntity( - mangaId = json.getLong("manga_id"), - pageId = json.getLong("page_id"), - chapterId = json.getLong("chapter_id"), - page = json.getInt("page"), - scroll = json.getInt("scroll"), - imageUrl = json.getString("image_url"), - createdAt = json.getLong("created_at"), - percent = json.getDouble("percent").toFloat(), - ) - - fun toMangaSourceEntity() = MangaSourceEntity( - source = json.getString("source"), - isEnabled = json.getBoolean("enabled"), - sortKey = json.getInt("sort_key"), - ) - - fun toMap(): Map { - val map = mutableMapOf() - val keys = json.keys() - - while (keys.hasNext()) { - val key = keys.next() - val value = json.get(key) - map[key] = value - } - - return map - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt similarity index 69% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt rename to app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt index 208f4ed32..b6a035e87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt @@ -1,9 +1,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONObject -import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity @@ -15,9 +13,8 @@ class JsonSerializer private constructor(private val json: JSONObject) { JSONObject().apply { put("manga_id", e.mangaId) put("category_id", e.categoryId) - put("sort_key", e.sortKey) put("created_at", e.createdAt) - }, + } ) constructor(e: FavouriteCategoryEntity) : this( @@ -28,8 +25,7 @@ class JsonSerializer private constructor(private val json: JSONObject) { put("title", e.title) put("order", e.order) put("track", e.track) - put("show_in_lib", e.isVisibleInLibrary) - }, + } ) constructor(e: HistoryEntity) : this( @@ -41,7 +37,7 @@ class JsonSerializer private constructor(private val json: JSONObject) { put("page", e.page) put("scroll", e.scroll) put("percent", e.percent) - }, + } ) constructor(e: TagEntity) : this( @@ -50,7 +46,7 @@ class JsonSerializer private constructor(private val json: JSONObject) { put("title", e.title) put("key", e.key) put("source", e.source) - }, + } ) constructor(e: MangaEntity) : this( @@ -67,33 +63,8 @@ class JsonSerializer private constructor(private val json: JSONObject) { put("state", e.state) put("author", e.author) put("source", e.source) - }, - ) - - constructor(e: BookmarkEntity) : this( - JSONObject().apply { - put("manga_id", e.mangaId) - put("page_id", e.pageId) - put("chapter_id", e.chapterId) - put("page", e.page) - put("scroll", e.scroll) - put("image_url", e.imageUrl) - put("created_at", e.createdAt) - put("percent", e.percent) - }, - ) - - constructor(e: MangaSourceEntity) : this( - JSONObject().apply { - put("source", e.source) - put("enabled", e.isEnabled) - put("sort_key", e.sortKey) - }, - ) - - constructor(m: Map) : this( - JSONObject(m), + } ) fun toJson(): JSONObject = json -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt new file mode 100644 index 000000000..215d02259 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.core.db + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val databaseModule + get() = module { + single { MangaDatabase(androidContext()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt similarity index 67% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt index e13b0d26a..6257a8456 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt @@ -10,16 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba override fun onCreate(db: SupportSQLiteDatabase) { db.execSQL( - "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)", - arrayOf( - System.currentTimeMillis(), - 1, - resources.getString(R.string.read_later), - SortOrder.NEWEST.name, - 1, - 1, - 0L, - ) + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)", + arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt new file mode 100644 index 000000000..d3dff3cc6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -0,0 +1,88 @@ +package org.koitharu.kotatsu.core.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity +import org.koitharu.kotatsu.bookmarks.data.BookmarksDao +import org.koitharu.kotatsu.core.db.dao.MangaDao +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.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity +import org.koitharu.kotatsu.core.db.migrations.* +import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao +import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity +import org.koitharu.kotatsu.favourites.data.FavouriteEntity +import org.koitharu.kotatsu.favourites.data.FavouritesDao +import org.koitharu.kotatsu.history.data.HistoryDao +import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.suggestions.data.SuggestionDao +import org.koitharu.kotatsu.suggestions.data.SuggestionEntity +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 = 12 + +@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, + ], + version = DATABASE_VERSION, +) +abstract class MangaDatabase : RoomDatabase() { + + abstract val historyDao: HistoryDao + + abstract val tagsDao: TagsDao + + abstract val mangaDao: MangaDao + + abstract val favouritesDao: FavouritesDao + + abstract val preferencesDao: PreferencesDao + + abstract val favouriteCategoriesDao: FavouriteCategoriesDao + + abstract val tracksDao: TracksDao + + abstract val trackLogsDao: TrackLogsDao + + abstract val suggestionDao: SuggestionDao + + abstract val bookmarksDao: BookmarksDao + + abstract val scrobblingDao: ScrobblingDao +} + +val databaseMigrations: Array + get() = arrayOf( + Migration1To2(), + Migration2To3(), + Migration3To4(), + Migration4To5(), + Migration5To6(), + Migration6To7(), + Migration7To8(), + Migration8To9(), + Migration9To10(), + Migration10To11(), + Migration11To12(), + ) + +fun MangaDatabase(context: Context): MangaDatabase = Room + .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db") + .addMigrations(*databaseMigrations) + .addCallback(DatabasePrePopulateCallback(context.resources)) + .build() \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt similarity index 88% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt index fc36085c7..28920a626 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt @@ -1,9 +1,9 @@ package org.koitharu.kotatsu.core.db + const val TABLE_FAVOURITES = "favourites" const val TABLE_MANGA = "manga" const val TABLE_TAGS = "tags" const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" const val TABLE_HISTORY = "history" const val TABLE_MANGA_TAGS = "manga_tags" -const val TABLE_SOURCES = "sources" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt new file mode 100644 index 000000000..ee8255dfc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.core.db.dao + +import androidx.room.* +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity +import org.koitharu.kotatsu.core.db.entity.MangaWithTags +import org.koitharu.kotatsu.core.db.entity.TagEntity + +@Dao +abstract class MangaDao { + + @Transaction + @Query("SELECT * FROM manga WHERE manga_id = :id") + abstract suspend fun find(id: Long): MangaWithTags? + + @Transaction + @Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit") + abstract suspend fun searchByTitle(query: String, limit: Int): List + + @Transaction + @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit") + abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(manga: MangaEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun update(manga: MangaEntity): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long + + @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") + abstract suspend fun clearTagRelation(mangaId: Long) + + @Transaction + open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) { + if (update(manga) <= 0) { + insert(manga) + if (tags != null) { + clearTagRelation(manga.id) + tags.map { + MangaTagsEntity(manga.id, it.id) + }.forEach { + insertTagRelation(it) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt new file mode 100644 index 000000000..9a957b206 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.core.db.dao + +import androidx.room.* +import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity + +@Dao +abstract class PreferencesDao { + + @Query("SELECT * FROM preferences WHERE manga_id = :mangaId") + abstract suspend fun find(mangaId: Long): MangaPrefsEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(pref: MangaPrefsEntity): Long + + @Update + abstract suspend fun update(pref: MangaPrefsEntity): Int + + @Transaction + open suspend fun upsert(pref: MangaPrefsEntity) { + if (update(pref) == 0) { + insert(pref) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt new file mode 100644 index 000000000..8b3498e3c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.core.db.dao + +import androidx.room.* +import org.koitharu.kotatsu.core.db.entity.TagEntity + +@Dao +abstract class TagsDao { + + @Query("SELECT * FROM tags WHERE source = :source") + abstract suspend fun findTags(source: String): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""" + ) + abstract suspend fun findPopularTags(limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE tags.source = :source + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""" + ) + abstract suspend fun findPopularTags(source: String, limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE tags.source = :source AND title LIKE :query + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""" + ) + abstract suspend fun findTags(source: String, query: String, limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE title LIKE :query + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""" + ) + abstract suspend fun findTags(query: String, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(tag: TagEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun update(tag: TagEntity): Int + + @Transaction + open suspend fun upsert(tags: Iterable) { + tags.forEach { tag -> + if (update(tag) <= 0) { + insert(tag) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt similarity index 86% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt index ee0da6165..ade35613b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.core.db.dao import androidx.room.* -import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogWithManga @@ -9,8 +8,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga interface TrackLogsDao { @Transaction - @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0") - fun observeAll(limit: Int): Flow> + @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + suspend fun findAll(offset: Int, limit: Int): List @Query("DELETE FROM track_logs") suspend fun clear() @@ -26,4 +25,4 @@ interface TrackLogsDao { @Query("SELECT COUNT(*) FROM track_logs") suspend fun count(): Int -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt similarity index 64% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt index d4280c850..af938a813 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt @@ -1,31 +1,25 @@ package org.koitharu.kotatsu.core.db.entity -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.util.ext.longHashCode -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.utils.ext.longHashCode // Entity to model fun TagEntity.toMangaTag() = MangaTag( key = this.key, title = this.title.toTitleCase(), - source = MangaSource(this.source), + source = MangaSource.valueOf(this.source), ) fun Collection.toMangaTags() = mapToSet(TagEntity::toMangaTag) -fun Collection.toMangaTagsList() = map(TagEntity::toMangaTag) - fun MangaEntity.toManga(tags: Set) = Manga( id = this.id, title = this.title, altTitle = this.altTitle, - state = this.state?.let { MangaState(it) }, + state = this.state?.let { MangaState.valueOf(it) }, rating = this.rating, isNsfw = this.isNsfw, url = this.url, @@ -33,8 +27,8 @@ fun MangaEntity.toManga(tags: Set) = Manga( coverUrl = this.coverUrl, largeCoverUrl = this.largeCoverUrl, author = this.author, - source = MangaSource(this.source), - tags = tags, + source = MangaSource.valueOf(this.source), + tags = tags ) fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) @@ -60,17 +54,14 @@ fun MangaTag.toEntity() = TagEntity( title = title, key = key, source = source.name, - id = "${key}_${source.name}".longHashCode(), + id = "${key}_${source.name}".longHashCode() ) fun Collection.toEntities() = map(MangaTag::toEntity) // Other +@Suppress("FunctionName") fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { SortOrder.valueOf(name) -}.getOrDefault(fallback) - -fun MangaState(name: String): MangaState? = runCatching { - MangaState.valueOf(name) -}.getOrNull() +}.getOrDefault(fallback) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt similarity index 99% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index e2c474399..36e534c71 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -20,4 +20,4 @@ data class MangaEntity( @ColumnInfo(name = "state") val state: String?, @ColumnInfo(name = "author") val author: String?, @ColumnInfo(name = "source") val source: String, -) +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt new file mode 100644 index 000000000..a09ccd884 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.core.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "preferences", + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class MangaPrefsEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "mode") val mode: Int +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt index bc343f784..e7a59c5d0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt @@ -13,13 +13,13 @@ import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], - onDelete = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ), ForeignKey( entity = TagEntity::class, parentColumns = ["tag_id"], childColumns = ["tag_id"], - onDelete = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ) ] ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt index 5a28a10d3..8c35c376e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt @@ -4,7 +4,7 @@ import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation -data class MangaWithTags( +class MangaWithTags( @Embedded val manga: MangaEntity, @Relation( parentColumn = "manga_id", @@ -12,4 +12,4 @@ data class MangaWithTags( associateBy = Junction(MangaTagsEntity::class) ) val tags: List, -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt similarity index 99% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 6c7907da6..7f147c992 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -12,4 +12,4 @@ data class TagEntity( @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "key") val key: String, @ColumnInfo(name = "source") val source: String, -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt index 2d9cd4fa3..5d80708fe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt @@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration10To11 : Migration(10, 11) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( """ CREATE TABLE IF NOT EXISTS `bookmarks` ( `manga_id` INTEGER NOT NULL, @@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) { FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE ) """.trimIndent() ) - db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt index 6af773d5a..9d13d2420 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt @@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration11To12 : Migration(11, 12) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( """ CREATE TABLE IF NOT EXISTS `scrobblings` ( `scrobbler` INTEGER NOT NULL, @@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) { ) """.trimIndent() ) - db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") - db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") + database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") + database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt index 56cc534fd..8e99a65a3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt @@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) { /** * Adding foreign keys */ - override fun migrate(db: SupportSQLiteDatabase) { + override fun migrate(database: SupportSQLiteDatabase) { /* manga_tags */ - db.execSQL( + database.execSQL( "CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " + "PRIMARY KEY(manga_id, tag_id), " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " + "FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )" ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)") - db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags") - db.execSQL("DROP TABLE manga_tags") - db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags") + database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)") + database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags") + database.execSQL("DROP TABLE manga_tags") + database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags") /* favourites */ - db.execSQL( + database.execSQL( "CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " + "PRIMARY KEY(manga_id, category_id), " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " + "FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )" ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)") - db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites") - db.execSQL("DROP TABLE favourites") - db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites") + database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)") + database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites") + database.execSQL("DROP TABLE favourites") + database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites") /* history */ - db.execSQL( + database.execSQL( "CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " + "PRIMARY KEY(manga_id), " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" ) - db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history") - db.execSQL("DROP TABLE history") - db.execSQL("ALTER TABLE history_tmp RENAME TO history") + database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history") + database.execSQL("DROP TABLE history") + database.execSQL("ALTER TABLE history_tmp RENAME TO history") /* preferences */ - db.execSQL( + database.execSQL( "CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," + " PRIMARY KEY(manga_id), " + "FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" ) - db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences") - db.execSQL("DROP TABLE preferences") - db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences") + database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences") + database.execSQL("DROP TABLE preferences") + database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt index b5a36173d..018f186a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt @@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration2To3 : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0") } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt new file mode 100644 index 000000000..47fca3fce --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration3To4 : Migration(3, 4) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt similarity index 53% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt index b4144da5f..a2445ef01 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt @@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration4To5 : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0") } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt new file mode 100644 index 000000000..4541bf6bb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration5To6 : Migration(5, 6) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt index 5651c5297..08c742853 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt @@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration6To7 : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt new file mode 100644 index 000000000..4a4e781f2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration7To8 : Migration(7, 8) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0") + database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt index 891761001..768acf7d4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt @@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder class Migration8To9 : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt similarity index 53% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt index 6aa1dbbf1..59cba96ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt @@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase class Migration9To10 : Migration(9, 10) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CaughtException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CaughtException.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CaughtException.kt rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/CaughtException.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt new file mode 100644 index 000000000..ef20b4fb0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.core.exceptions + +import okio.IOException + +class CloudFlareProtectedException( + val url: String +) : IOException("Protected by CloudFlare") \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt new file mode 100644 index 000000000..e8c554107 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.exceptions + +import org.koitharu.kotatsu.parsers.util.mapNotNullToSet + +class CompositeException(val errors: Collection) : Exception() { + + override val message: String = errors.mapNotNullToSet { it.message }.joinToString() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt new file mode 100644 index 000000000..691a39640 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.core.exceptions + +class WrongPasswordException : SecurityException() \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt similarity index 59% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index 9407ab6e9..d0165c7ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -1,70 +1,73 @@ package org.koitharu.kotatsu.core.exceptions.resolve +import android.util.ArrayMap import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes -import androidx.collection.ArrayMap import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import okhttp3.Headers +import kotlinx.coroutines.suspendCancellableCoroutine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.browser.BrowserActivity -import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog -import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity +import org.koitharu.kotatsu.utils.TaggedActivityResult +import org.koitharu.kotatsu.utils.isSuccess import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class ExceptionResolver : ActivityResultCallback { +class ExceptionResolver private constructor( + private val activity: FragmentActivity?, + private val fragment: Fragment?, +) : ActivityResultCallback { private val continuations = ArrayMap>(1) - private val activity: FragmentActivity? - private val fragment: Fragment? - private val sourceAuthContract: ActivityResultLauncher - private val cloudflareContract: ActivityResultLauncher> + private lateinit var sourceAuthContract: ActivityResultLauncher - constructor(activity: FragmentActivity) { - this.activity = activity - fragment = null + constructor(activity: FragmentActivity) : this(activity = activity, fragment = null) { sourceAuthContract = activity.registerForActivityResult(SourceAuthActivity.Contract(), this) - cloudflareContract = activity.registerForActivityResult(CloudFlareActivity.Contract(), this) } - constructor(fragment: Fragment) { - this.fragment = fragment - activity = null + constructor(fragment: Fragment) : this(activity = null, fragment = fragment) { sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this) - cloudflareContract = fragment.registerForActivityResult(CloudFlareActivity.Contract(), this) } - override fun onActivityResult(result: TaggedActivityResult) { + override fun onActivityResult(result: TaggedActivityResult?) { + result ?: return continuations.remove(result.tag)?.resume(result.isSuccess) } - fun showDetails(e: Throwable, url: String?) { - ErrorDetailsDialog.show(getFragmentManager(), e, url) - } - suspend fun resolve(e: Throwable): Boolean = when (e) { - is CloudFlareProtectedException -> resolveCF(e.url, e.headers) + is CloudFlareProtectedException -> resolveCF(e.url) is AuthRequiredException -> resolveAuthException(e.source) is NotFoundException -> { openInBrowser(e.url) false } - else -> false } - private suspend fun resolveCF(url: String, headers: Headers): Boolean = suspendCoroutine { cont -> - continuations[CloudFlareActivity.TAG] = cont - cloudflareContract.launch(url to headers) + private suspend fun resolveCF(url: String): Boolean { + val dialog = CloudFlareDialog.newInstance(url) + val fm = getFragmentManager() + return suspendCancellableCoroutine { cont -> + fm.clearFragmentResult(CloudFlareDialog.TAG) + continuations[CloudFlareDialog.TAG] = cont + fm.setFragmentResultListener(CloudFlareDialog.TAG, checkNotNull(fragment ?: activity)) { key, result -> + continuations.remove(key)?.resume(result.getBoolean(CloudFlareDialog.EXTRA_RESULT)) + } + dialog.show(fm, CloudFlareDialog.TAG) + cont.invokeOnCancellation { + continuations.remove(CloudFlareDialog.TAG, cont) + fm.clearFragmentResultListener(CloudFlareDialog.TAG) + dialog.dismiss() + } + } } private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont -> @@ -91,4 +94,4 @@ class ExceptionResolver : ActivityResultCallback { fun canResolve(e: Throwable) = getResolveStringId(e) != 0 } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt rename to app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt index 1dcf7e26f..ff6babc95 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.core.github import android.os.Parcelable -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -11,9 +10,5 @@ data class AppVersion( val url: String, val apkSize: Long, val apkUrl: String, - val description: String, -) : Parcelable { - - @IgnoredOnParcel - val versionId = VersionId(name) -} + val description: String +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt new file mode 100644 index 000000000..58d8d22c6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.github + +import org.koin.dsl.module + +val githubModule + get() = module { + factory { GithubRepository(get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt new file mode 100644 index 000000000..0176823db --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.core.github + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.parseJson + +class GithubRepository(private val okHttp: OkHttpClient) { + + suspend fun getLatestVersion(): AppVersion { + val request = Request.Builder() + .get() + .url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases/latest") + val json = okHttp.newCall(request.build()).await().parseJson() + val asset = json.getJSONArray("assets").getJSONObject(0) + return AppVersion( + id = json.getLong("id"), + url = json.getString("html_url"), + name = json.getString("name").removePrefix("v"), + apkSize = asset.getLong("size"), + apkUrl = asset.getString("browser_download_url"), + description = json.getString("body") + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt similarity index 66% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt rename to app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt index 56c931b24..88304755b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.github import java.util.* -data class VersionId( +class VersionId( val major: Int, val minor: Int, val build: Int, @@ -30,6 +30,30 @@ data class VersionId( return variantNumber.compareTo(other.variantNumber) } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VersionId + + if (major != other.major) return false + if (minor != other.minor) return false + if (build != other.build) return false + if (variantType != other.variantType) return false + if (variantNumber != other.variantNumber) return false + + return true + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + build + result = 31 * result + variantType.hashCode() + result = 31 * result + variantNumber + return result + } + private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) { "a", "alpha" -> 1 "b", "beta" -> 2 @@ -39,9 +63,6 @@ data class VersionId( } } -val VersionId.isStable: Boolean - get() = variantType.isEmpty() - fun VersionId(versionName: String): VersionId { val parts = versionName.substringBeforeLast('-').split('.') val variant = versionName.substringAfterLast('-', "") @@ -52,4 +73,4 @@ fun VersionId(versionName: String): VersionId { variantType = variant.filter(Char::isLetter), variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt new file mode 100644 index 000000000..798ec2fbd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.core.model + +import android.os.Parcelable +import java.util.* +import kotlinx.parcelize.Parcelize +import org.koitharu.kotatsu.parsers.model.SortOrder + +@Parcelize +data class FavouriteCategory( + val id: Long, + val title: String, + val sortKey: Int, + val order: SortOrder, + val createdAt: Date, + val isTrackingEnabled: Boolean, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt new file mode 100644 index 000000000..ed5594c42 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.core.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet + +fun Collection.ids() = mapToSet { it.id } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt rename to app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt index d72ed9d3a..9ac085183 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt @@ -2,14 +2,14 @@ package org.koitharu.kotatsu.core.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import java.time.Instant +import java.util.* @Parcelize data class MangaHistory( - val createdAt: Instant, - val updatedAt: Instant, + val createdAt: Date, + val updatedAt: Date, val chapterId: Long, val page: Int, val scroll: Int, val percent: Float, -) : Parcelable +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt new file mode 100644 index 000000000..9bd4ef5cf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.core.model + +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toTitleCase +import java.util.* + +fun MangaSource.getLocaleTitle(): String? { + val lc = Locale(locale ?: return null) + return lc.getDisplayLanguage(lc).toTitleCase(lc) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/ZoomMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/ZoomMode.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/ZoomMode.kt rename to app/src/main/java/org/koitharu/kotatsu/core/model/ZoomMode.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt new file mode 100644 index 000000000..67bb80044 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt @@ -0,0 +1,95 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import androidx.core.os.ParcelCompat +import org.koitharu.kotatsu.parsers.model.* + +fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) { + out.writeLong(id) + out.writeString(title) + out.writeString(altTitle) + out.writeString(url) + out.writeString(publicUrl) + out.writeFloat(rating) + ParcelCompat.writeBoolean(out, isNsfw) + out.writeString(coverUrl) + out.writeString(largeCoverUrl) + out.writeString(description) + out.writeParcelable(ParcelableMangaTags(tags), flags) + out.writeSerializable(state) + out.writeString(author) + if (withChapters) { + out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags) + } else { + out.writeString(null) + } + out.writeSerializable(source) +} + +fun Parcel.readManga() = Manga( + id = readLong(), + title = requireNotNull(readString()), + altTitle = readString(), + url = requireNotNull(readString()), + publicUrl = requireNotNull(readString()), + rating = readFloat(), + isNsfw = ParcelCompat.readBoolean(this), + coverUrl = requireNotNull(readString()), + largeCoverUrl = readString(), + description = readString(), + tags = requireNotNull(readParcelable(ParcelableMangaTags::class.java.classLoader)).tags, + state = readSerializable() as MangaState?, + author = readString(), + chapters = readParcelable(ParcelableMangaChapters::class.java.classLoader)?.chapters, + source = readSerializable() as MangaSource, +) + +fun MangaPage.writeToParcel(out: Parcel) { + out.writeLong(id) + out.writeString(url) + out.writeString(referer) + out.writeString(preview) + out.writeSerializable(source) +} + +fun Parcel.readMangaPage() = MangaPage( + id = readLong(), + url = requireNotNull(readString()), + referer = requireNotNull(readString()), + preview = readString(), + source = readSerializable() as MangaSource, +) + +fun MangaChapter.writeToParcel(out: Parcel) { + out.writeLong(id) + out.writeString(name) + out.writeInt(number) + out.writeString(url) + out.writeString(scanlator) + out.writeLong(uploadDate) + out.writeString(branch) + out.writeSerializable(source) +} + +fun Parcel.readMangaChapter() = MangaChapter( + id = readLong(), + name = requireNotNull(readString()), + number = readInt(), + url = requireNotNull(readString()), + scanlator = readString(), + uploadDate = readLong(), + branch = readString(), + source = readSerializable() as MangaSource, +) + +fun MangaTag.writeToParcel(out: Parcel) { + out.writeString(title) + out.writeString(key) + out.writeSerializable(source) +} + +fun Parcel.readMangaTag() = MangaTag( + title = requireNotNull(readString()), + key = requireNotNull(readString()), + source = readSerializable() as MangaSource, +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt new file mode 100644 index 000000000..b302ce634 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import org.koitharu.kotatsu.parsers.model.Manga + +// Limits to avoid TransactionTooLargeException +private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size +private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe + +class ParcelableManga( + val manga: Manga, + private val withChapters: Boolean, +) : Parcelable { + + constructor(parcel: Parcel) : this(parcel.readManga(), true) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + val chapters = manga.chapters + if (!withChapters || chapters == null) { + manga.writeToParcel(parcel, flags, withChapters = false) + return + } + if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) { + // fast path + manga.writeToParcel(parcel, flags, withChapters = true) + return + } + val tempParcel = Parcel.obtain() + manga.writeToParcel(tempParcel, flags, withChapters = true) + val size = tempParcel.dataSize() + if (size < MAX_SAFE_SIZE) { + parcel.appendFrom(tempParcel, 0, size) + } else { + manga.writeToParcel(parcel, flags, withChapters = false) + } + tempParcel.recycle() + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelableManga { + return ParcelableManga(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt new file mode 100644 index 000000000..473b45320 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import org.koitharu.kotatsu.parsers.model.MangaChapter + +class ParcelableMangaChapters( + val chapters: List, +) : Parcelable { + + constructor(parcel: Parcel) : this( + List(parcel.readInt()) { parcel.readMangaChapter() } + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(chapters.size) + for (chapter in chapters) { + chapter.writeToParcel(parcel) + } + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelableMangaChapters { + return ParcelableMangaChapters(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt new file mode 100644 index 000000000..3230ec59b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import org.koitharu.kotatsu.parsers.model.MangaPage + +class ParcelableMangaPages( + val pages: List, +) : Parcelable { + + constructor(parcel: Parcel) : this( + List(parcel.readInt()) { parcel.readMangaPage() } + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(pages.size) + for (page in pages) { + page.writeToParcel(parcel) + } + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelableMangaPages { + return ParcelableMangaPages(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt new file mode 100644 index 000000000..bd5490e0a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.core.model.parcelable + +import android.os.Parcel +import android.os.Parcelable +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ext.Set + +class ParcelableMangaTags( + val tags: Set, +) : Parcelable { + + constructor(parcel: Parcel) : this( + Set(parcel.readInt()) { parcel.readMangaTag() } + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeInt(tags.size) + for (tag in tags) { + tag.writeToParcel(parcel) + } + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ParcelableMangaTags { + return ParcelableMangaTags(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt new file mode 100644 index 000000000..fb806bda1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt @@ -0,0 +1,34 @@ +package org.koitharu.kotatsu.core.network + +import android.webkit.CookieManager +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class AndroidCookieJar : CookieJar { + + private val cookieManager = CookieManager.getInstance() + + override fun loadForRequest(url: HttpUrl): List { + val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() + return rawCookie.split(';').mapNotNull { + Cookie.parse(url, it) + } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) { + return + } + val urlString = url.toString() + for (cookie in cookies) { + cookieManager.setCookie(urlString, cookie.toString()) + } + } + + suspend fun clear() = suspendCoroutine { continuation -> + cookieManager.removeAllCookies(continuation::resume) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt index 8e353bfcb..a32a94c83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt @@ -3,30 +3,23 @@ package org.koitharu.kotatsu.core.network import okhttp3.Interceptor import okhttp3.Response import okhttp3.internal.closeQuietly -import org.jsoup.Jsoup import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.parsers.model.MangaSource import java.net.HttpURLConnection.HTTP_FORBIDDEN import java.net.HttpURLConnection.HTTP_UNAVAILABLE +private const val HEADER_SERVER = "Server" +private const val SERVER_CLOUDFLARE = "cloudflare" + class CloudFlareInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { - val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use { - Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString()) - } ?: return response - if (content.getElementById("challenge-error-title") != null) { - val request = response.request + if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) { response.closeQuietly() - throw CloudFlareProtectedException( - url = request.url.toString(), - source = request.tag(MangaSource::class.java), - headers = request.headers, - ) + throw CloudFlareProtectedException(response.request.url.toString()) } } return response } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index 9a31233a0..f377404c8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -7,16 +7,10 @@ object CommonHeaders { const val REFERER = "Referer" const val USER_AGENT = "User-Agent" const val ACCEPT = "Accept" - const val CONTENT_TYPE = "Content-Type" const val CONTENT_DISPOSITION = "Content-Disposition" const val COOKIE = "Cookie" - const val CONTENT_ENCODING = "Content-Encoding" - const val ACCEPT_ENCODING = "Accept-Encoding" const val AUTHORIZATION = "Authorization" - const val CACHE_CONTROL = "Cache-Control" - const val PROXY_AUTHORIZATION = "Proxy-Authorization" - const val RETRY_AFTER = "Retry-After" - val CACHE_CONTROL_NO_STORE: CacheControl + val CACHE_CONTROL_DISABLED: CacheControl get() = CacheControl.Builder().noStore().build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt index 0cc4b6db0..f32717aad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt @@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import java.net.InetAddress import java.net.UnknownHostException @@ -52,9 +52,8 @@ class DoHManager( tryGetByIp("8.8.8.8"), tryGetByIp("2001:4860:4860::8888"), tryGetByIp("2001:4860:4860::8844"), - ), + ) ).build() - DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) .resolvePrivateAddresses(true) @@ -69,9 +68,8 @@ class DoHManager( tryGetByIp("2606:4700:4700::1001"), tryGetByIp("2606:4700:4700::0064"), tryGetByIp("2606:4700:4700::6400"), - ), + ) ).build() - DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient) .url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl()) .resolvePrivateAddresses(true) @@ -81,7 +79,7 @@ class DoHManager( tryGetByIp("94.140.14.141"), tryGetByIp("2a10:50c0::1:ff"), tryGetByIp("2a10:50c0::2:ff"), - ), + ) ).build() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHProvider.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt new file mode 100644 index 000000000..2af4c215e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.core.network + +import okhttp3.CookieJar +import okhttp3.OkHttpClient +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import java.util.concurrent.TimeUnit + +val networkModule + get() = module { + single { AndroidCookieJar() } bind CookieJar::class + single { + val cache = get().createHttpCache() + OkHttpClient.Builder().apply { + connectTimeout(20, TimeUnit.SECONDS) + readTimeout(60, TimeUnit.SECONDS) + writeTimeout(20, TimeUnit.SECONDS) + cookieJar(get()) + dns(DoHManager(cache, get())) + cache(cache) + addInterceptor(UserAgentInterceptor()) + addInterceptor(CloudFlareInterceptor()) + }.build() + } + single { MangaLoaderContextImpl(get(), get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt new file mode 100644 index 000000000..b6491f154 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.core.network + +import android.os.Build +import java.util.* +import okhttp3.Interceptor +import okhttp3.Response +import org.koitharu.kotatsu.BuildConfig + +class UserAgentInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + return chain.proceed( + if (request.header(CommonHeaders.USER_AGENT) == null) { + request.newBuilder() + .addHeader(CommonHeaders.USER_AGENT, userAgent) + .build() + } else request + ) + } + + companion object { + + val userAgent + get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format( + BuildConfig.VERSION_NAME, + Build.VERSION.RELEASE, + Build.MODEL, + Build.BRAND, + Build.DEVICE, + Locale.getDefault().language + ) + + val userAgentChrome + get() = ( + "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/100.0.4896.127 Mobile Safari/537.36" + ).format( + Build.VERSION.RELEASE, + Build.MODEL, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt new file mode 100644 index 000000000..a201295c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt @@ -0,0 +1,105 @@ +package org.koitharu.kotatsu.core.os + +import android.app.ActivityManager +import android.content.Context +import android.content.pm.ShortcutManager +import android.media.ThumbnailUtils +import android.os.Build +import android.util.Size +import androidx.annotation.VisibleForTesting +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.room.InvalidationTracker +import coil.ImageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.core.db.TABLE_HISTORY +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.processLifecycleScope +import org.koitharu.kotatsu.utils.ext.requireBitmap + +class ShortcutsUpdater( + private val context: Context, + private val coil: ImageLoader, + private val historyRepository: HistoryRepository, + private val mangaRepository: MangaDataRepository, +) : InvalidationTracker.Observer(TABLE_HISTORY) { + + private val iconSize by lazy { getIconSize(context) } + private var shortcutsUpdateJob: Job? = null + + override fun onInvalidated(tables: MutableSet) { + val prevJob = shortcutsUpdateJob + shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) { + prevJob?.join() + updateShortcutsImpl() + } + } + + suspend fun requestPinShortcut(manga: Manga): Boolean { + return ShortcutManagerCompat.requestPinShortcut( + context, + buildShortcutInfo(manga).build(), + null + ) + } + + @VisibleForTesting + suspend fun await(): Boolean { + return shortcutsUpdateJob?.join() != null + } + + private suspend fun updateShortcutsImpl() = runCatching { + val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager + val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) + .filter { x -> x.title.isNotEmpty() } + .map { buildShortcutInfo(it).build().toShortcutInfo() } + manager.dynamicShortcuts = shortcuts + }.onFailure { + it.printStackTraceDebug() + } + + private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { + val icon = runCatching { + val bmp = coil.execute( + ImageRequest.Builder(context) + .data(manga.coverUrl) + .size(iconSize.width, iconSize.height) + .build() + ).requireBitmap() + ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0) + }.fold( + onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, + onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) } + ) + mangaRepository.storeManga(manga) + return ShortcutInfoCompat.Builder(context, manga.id.toString()) + .setShortLabel(manga.title) + .setLongLabel(manga.title) + .setIcon(icon) + .setIntent( + ReaderActivity.newIntent(context, manga.id) + .setAction(ReaderActivity.ACTION_MANGA_READ) + ) + } + + private fun getIconSize(context: Context): Size { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + (context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let { + Size(it.iconMaxWidth, it.iconMaxHeight) + } + } else { + (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let { + Size(it, it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt new file mode 100644 index 000000000..ba5412a50 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.core.parser + +import android.net.Uri +import coil.map.Mapper +import coil.request.Options +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.parsers.model.MangaSource + +class FaviconMapper : Mapper { + + override fun map(data: Uri, options: Options): HttpUrl? { + if (data.scheme != "favicon") { + return null + } + val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) + val repo = MangaRepository(mangaSource) as RemoteMangaRepository + return repo.getFaviconUrl().toHttpUrl() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt similarity index 61% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt rename to app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index 5ee976bd9..ffacb6c6e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -5,39 +5,29 @@ import android.content.Context import android.util.Base64 import android.webkit.WebView import androidx.core.os.LocaleListCompat -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import org.koitharu.kotatsu.core.network.MangaHttpClient -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.prefs.SourceSettings -import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource -import java.lang.ref.WeakReference -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton +import org.koitharu.kotatsu.utils.ext.toList +import java.util.* import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -@Singleton -class MangaLoaderContextImpl @Inject constructor( - @MangaHttpClient override val httpClient: OkHttpClient, - override val cookieJar: MutableCookieJar, - @ApplicationContext private val androidContext: Context, +class MangaLoaderContextImpl( + override val httpClient: OkHttpClient, + override val cookieJar: AndroidCookieJar, + private val androidContext: Context, ) : MangaLoaderContext() { - private var webViewCached: WeakReference? = null - @SuppressLint("SetJavaScriptEnabled") override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { - val webView = webViewCached?.get() ?: WebView(androidContext).also { - it.settings.javaScriptEnabled = true - webViewCached = WeakReference(it) - } + val webView = WebView(androidContext) + webView.settings.javaScriptEnabled = true suspendCoroutine { cont -> webView.evaluateJavascript(script) { result -> cont.resume(result?.takeUnless { it == "null" }) @@ -50,7 +40,7 @@ class MangaLoaderContextImpl @Inject constructor( } override fun encodeBase64(data: ByteArray): String { - return Base64.encodeToString(data, Base64.NO_WRAP) + return Base64.encodeToString(data, Base64.NO_PADDING) } override fun decodeBase64(data: String): ByteArray { @@ -60,4 +50,4 @@ class MangaLoaderContextImpl @Inject constructor( override fun getPreferredLocales(): List { return LocaleListCompat.getAdjustedDefault().toList() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt new file mode 100644 index 000000000..90c84d5a8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.core.parser + +import java.lang.ref.WeakReference +import java.util.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.* + +interface MangaRepository { + + val source: MangaSource + + val sortOrders: Set + + suspend fun getList(offset: Int, query: String): List + + suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List + + suspend fun getDetails(manga: Manga): Manga + + suspend fun getPages(chapter: MangaChapter): List + + suspend fun getPageUrl(page: MangaPage): String + + suspend fun getTags(): Set + + companion object : KoinComponent { + + private val cache = EnumMap>(MangaSource::class.java) + + operator fun invoke(source: MangaSource): MangaRepository { + if (source == MangaSource.LOCAL) { + return get() + } + cache[source]?.get()?.let { return it } + return synchronized(cache) { + cache[source]?.get()?.let { return it } + val repository = RemoteMangaRepository(MangaParser(source, get())) + cache[source] = WeakReference(repository) + repository + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt new file mode 100644 index 000000000..999ecb09b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.core.parser + +import org.koitharu.kotatsu.core.prefs.SourceSettings +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.MangaParserAuthProvider +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* + +class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository { + + override val source: MangaSource + get() = parser.source + + override val sortOrders: Set + get() = parser.sortOrders + + var defaultSortOrder: SortOrder? + get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull() + set(value) { + getConfig().defaultSortOrder = value + } + + override suspend fun getList(offset: Int, query: String): List { + return parser.getList(offset, query) + } + + override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List { + return parser.getList(offset, tags, sortOrder) + } + + override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga) + + override suspend fun getPages(chapter: MangaChapter): List = parser.getPages(chapter) + + override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) + + override suspend fun getTags(): Set = parser.getTags() + + fun getFaviconUrl(): String = parser.getFaviconUrl() + + fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider + + fun getConfigKeys(): List> = ArrayList>().also { + parser.onCreateConfig(it) + } + + private fun getConfig() = parser.config as SourceSettings +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt new file mode 100644 index 000000000..0efa45c92 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.core.prefs + +enum class AppSection { + + LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt new file mode 100644 index 000000000..cd9c8a07a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -0,0 +1,327 @@ +package org.koitharu.kotatsu.core.prefs + +import android.content.Context +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.net.Uri +import android.provider.Settings +import androidx.appcompat.app.AppCompatDelegate +import androidx.collection.arraySetOf +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.google.android.material.color.DynamicColors +import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.callbackFlow +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.network.DoHProvider +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.utils.ext.getEnumValue +import org.koitharu.kotatsu.utils.ext.observe +import org.koitharu.kotatsu.utils.ext.putEnumValue +import org.koitharu.kotatsu.utils.ext.toUriOrNull + +class AppSettings(context: Context) { + + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { + remove(MangaSource.LOCAL) + if (!BuildConfig.DEBUG) { + remove(MangaSource.DUMMY) + } + } + + val remoteMangaSources: Set + get() = Collections.unmodifiableSet(remoteSources) + + var listMode: ListMode + get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) + set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } + + var defaultSection: AppSection + get() = prefs.getEnumValue(KEY_APP_SECTION, AppSection.HISTORY) + set(value) = prefs.edit { putEnumValue(KEY_APP_SECTION, value) } + + val theme: Int + get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + + val isDynamicTheme: Boolean + get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false) + + val isAmoledTheme: Boolean + get() = prefs.getBoolean(KEY_THEME_AMOLED, false) + + var gridSize: Int + get() = prefs.getInt(KEY_GRID_SIZE, 100) + set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } + + val readerPageSwitch: Set + get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) + + var isTrafficWarningEnabled: Boolean + get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) + set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } + + var isAllFavouritesVisible: Boolean + get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) + set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } + + val isUpdateCheckingEnabled: Boolean + get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true) + + var lastUpdateCheckTimestamp: Long + get() = prefs.getLong(KEY_APP_UPDATE, 0L) + set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) } + + val isTrackerEnabled: Boolean + get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) + + val isTrackerNotificationsEnabled: Boolean + get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) + + var notificationSound: Uri + get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() + ?: Settings.System.DEFAULT_NOTIFICATION_URI + set(value) = prefs.edit { putString(KEY_NOTIFICATIONS_SOUND, value.toString()) } + + val notificationVibrate: Boolean + get() = prefs.getBoolean(KEY_NOTIFICATIONS_VIBRATE, false) + + val notificationLight: Boolean + get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true) + + val readerAnimation: Boolean + get() = prefs.getBoolean(KEY_READER_ANIMATION, false) + + val defaultReaderMode: ReaderMode + get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD) + + val isReaderModeDetectionEnabled: Boolean + get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true) + + var isHistoryGroupingEnabled: Boolean + get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) + set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) } + + val isReadingIndicatorsEnabled: Boolean + get() = prefs.getBoolean(KEY_READING_INDICATORS, true) + + val isHistoryExcludeNsfw: Boolean + get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) + + var chaptersReverse: Boolean + get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false) + set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) } + + val zoomMode: ZoomMode + get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER) + + val trackSources: Set + get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: arraySetOf(TRACK_FAVOURITES, TRACK_HISTORY) + + var appPassword: String? + get() = prefs.getString(KEY_APP_PASSWORD, null) + set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } + + var isBiometricProtectionEnabled: Boolean + get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) + set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } + + var sourcesOrder: List + get() = prefs.getString(KEY_SOURCES_ORDER, null) + ?.split('|') + .orEmpty() + set(value) = prefs.edit { + putString(KEY_SOURCES_ORDER, value.joinToString("|")) + } + + var hiddenSources: Set + get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null) ?: emptySet() + set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) } + + val isSourcesSelected: Boolean + get() = KEY_SOURCES_HIDDEN in prefs + + val newSources: Set + get() { + val known = sourcesOrder.toSet() + val hidden = hiddenSources + return remoteMangaSources + .filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x -> + x.name in known || x.name in hidden + } + } + + fun markKnownSources(sources: Collection) { + sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct() + } + + val isPagesNumbersEnabled: Boolean + get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) + + val screenshotsPolicy: ScreenshotsPolicy + get() = runCatching { + val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT) + if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key) + }.getOrDefault(ScreenshotsPolicy.ALLOW) + + var mangaStorageDir: File? + get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { + File(it) + }?.takeIf { it.exists() } + set(value) = prefs.edit { + if (value == null) { + remove(KEY_LOCAL_STORAGE) + } else { + putString(KEY_LOCAL_STORAGE, value.path) + } + } + + val isDownloadsSlowdownEnabled: Boolean + get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false) + + val downloadsParallelism: Int + get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2) + + val isSuggestionsEnabled: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS, false) + + val isSuggestionsExcludeNsfw: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + + var isSearchSingleSource: Boolean + get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false) + set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) } + + val dnsOverHttps: DoHProvider + get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) + + fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { + return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { + NETWORK_ALWAYS -> true + NETWORK_NEVER -> false + else -> cm.isActiveNetworkMetered + } + } + + fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat = + when (format) { + "" -> DateFormat.getDateInstance(DateFormat.SHORT) + else -> SimpleDateFormat(format, Locale.getDefault()) + } + + fun getSuggestionsTagsBlacklistRegex(): Regex? { + val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') + if (string.isNullOrEmpty()) { + return null + } + val tags = string.split(',') + val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag -> + Regex.escape(tag.trim()) + } + return Regex(regex, RegexOption.IGNORE_CASE) + } + + fun getMangaSources(includeHidden: Boolean): List { + val list = remoteSources.toMutableList() + val order = sourcesOrder + list.sortBy { x -> + val e = order.indexOf(x.name) + if (e == -1) order.size + x.ordinal else e + } + if (!includeHidden) { + val hidden = hiddenSources + list.removeAll { x -> x.name in hidden } + } + return list + } + + fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + prefs.registerOnSharedPreferenceChangeListener(listener) + } + + fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + + fun observe() = prefs.observe() + + companion object { + + const val PAGE_SWITCH_TAPS = "taps" + const val PAGE_SWITCH_VOLUME_KEYS = "volume" + + const val TRACK_HISTORY = "history" + const val TRACK_FAVOURITES = "favourites" + + const val KEY_LIST_MODE = "list_mode_2" + const val KEY_APP_SECTION = "app_section_2" + const val KEY_THEME = "theme" + const val KEY_DYNAMIC_THEME = "dynamic_theme" + const val KEY_THEME_AMOLED = "amoled_theme" + const val KEY_DATE_FORMAT = "date_format" + const val KEY_SOURCES_ORDER = "sources_order_2" + const val KEY_SOURCES_HIDDEN = "sources_hidden" + const val KEY_TRAFFIC_WARNING = "traffic_warning" + const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" + const val KEY_COOKIES_CLEAR = "cookies_clear" + const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" + const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" + const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" + const val KEY_GRID_SIZE = "grid_size" + const val KEY_REMOTE_SOURCES = "remote_sources" + const val KEY_LOCAL_STORAGE = "local_storage" + const val KEY_READER_SWITCHERS = "reader_switchers" + const val KEY_TRACKER_ENABLED = "tracker_enabled" + const val KEY_TRACK_SOURCES = "track_sources" + const val KEY_TRACK_CATEGORIES = "track_categories" + const val KEY_TRACK_WARNING = "track_warning" + const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" + const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" + const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" + const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" + const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" + const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info" + const val KEY_READER_ANIMATION = "reader_animation" + const val KEY_READER_MODE = "reader_mode" + const val KEY_READER_MODE_DETECT = "reader_mode_detect" + const val KEY_APP_PASSWORD = "app_password" + const val KEY_PROTECT_APP = "protect_app" + const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" + const val KEY_APP_VERSION = "app_version" + const val KEY_ZOOM_MODE = "zoom_mode" + const val KEY_BACKUP = "backup" + const val KEY_RESTORE = "restore" + const val KEY_HISTORY_GROUPING = "history_grouping" + const val KEY_READING_INDICATORS = "reading_indicators" + const val KEY_REVERSE_CHAPTERS = "reverse_chapters" + const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" + const val KEY_PAGES_NUMBERS = "pages_numbers" + const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" + const val KEY_PAGES_PRELOAD = "pages_preload" + const val KEY_SUGGESTIONS = "suggestions" + const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" + const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" + const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source" + const val KEY_SHIKIMORI = "shikimori" + const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" + const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" + const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" + const val KEY_DOH = "doh" + + // About + const val KEY_APP_UPDATE = "app_update" + const val KEY_APP_UPDATE_AUTO = "app_update_auto" + const val KEY_APP_TRANSLATION = "about_app_translation" + + private const val NETWORK_NEVER = 0 + private const val NETWORK_ALWAYS = 1 + private const val NETWORK_NON_METERED = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt new file mode 100644 index 000000000..88c62514c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.core.prefs + +import androidx.lifecycle.liveData +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.flow.flow + +fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { + var lastValue: T = valueProducer() + emit(lastValue) + observe().collect { + if (it == key) { + val value = valueProducer() + if (value != lastValue) { + emit(value) + } + lastValue = value + } + } +} + +fun AppSettings.observeAsLiveData( + context: CoroutineContext, + key: String, + valueProducer: AppSettings.() -> T +) = liveData(context) { + emit(valueProducer()) + observe().collect { + if (it == key) { + val value = valueProducer() + if (value != latestValue) { + emit(value) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt new file mode 100644 index 000000000..082266b54 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.core.prefs + +import android.content.Context +import androidx.core.content.edit + +private const val CATEGORY_ID = "cat_id" + +class AppWidgetConfig(context: Context, val widgetId: Int) { + + private val prefs = context.getSharedPreferences("appwidget_$widgetId", Context.MODE_PRIVATE) + + var categoryId: Long + get() = prefs.getLong(CATEGORY_ID, 0L) + set(value) = prefs.edit { putLong(CATEGORY_ID, value) } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ListMode.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ListMode.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt index 792ca13b6..9ec51d479 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt @@ -3,11 +3,11 @@ package org.koitharu.kotatsu.core.prefs enum class ReaderMode(val id: Int) { STANDARD(1), - REVERSED(3), - WEBTOON(2); + WEBTOON(2), + REVERSED(3); companion object { - fun valueOf(id: Int) = entries.firstOrNull { it.id == id } + fun valueOf(id: Int) = values().firstOrNull { it.id == id } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt index af19f5ae1..ea14c7342 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt @@ -2,16 +2,15 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context import androidx.core.content.edit -import org.koitharu.kotatsu.core.util.ext.getEnumValue -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.ext.getEnumValue +import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty +import org.koitharu.kotatsu.utils.ext.putEnumValue private const val KEY_SORT_ORDER = "sort_order" -private const val KEY_SLOWDOWN = "slowdown" class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { @@ -21,23 +20,10 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } - val isSlowdownEnabled: Boolean - get() = prefs.getBoolean(KEY_SLOWDOWN, false) - @Suppress("UNCHECKED_CAST") override fun get(key: ConfigKey): T { return when (key) { - is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } - is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) } as T } - - operator fun set(key: ConfigKey, value: T) = prefs.edit { - when (key) { - is ConfigKey.Domain -> putString(key.key, value as String?) - is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) - is ConfigKey.UserAgent -> putString(key.key, value as String?) - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt new file mode 100644 index 000000000..03bafa077 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt @@ -0,0 +1,107 @@ +package org.koitharu.kotatsu.core.ui + +import android.content.res.Resources +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.daysDiff +import org.koitharu.kotatsu.utils.ext.format +import java.util.* + +sealed class DateTimeAgo : ListModel { + + abstract fun format(resources: Resources): String + + object JustNow : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getString(R.string.just_now) + } + } + + class MinutesAgo(val minutes: Int) : DateTimeAgo() { + + override fun format(resources: Resources): String { + return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as MinutesAgo + return minutes == other.minutes + } + + override fun hashCode(): Int = minutes + } + + class HoursAgo(val hours: Int) : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getQuantityString(R.plurals.hours_ago, hours, hours) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as HoursAgo + return hours == other.hours + } + + override fun hashCode(): Int = hours + } + + object Today : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getString(R.string.today) + } + } + + object Yesterday : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getString(R.string.yesterday) + } + } + + class DaysAgo(val days: Int) : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getQuantityString(R.plurals.days_ago, days, days) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DaysAgo + return days == other.days + } + + override fun hashCode(): Int = days + } + + class Absolute(private val date: Date) : DateTimeAgo() { + + private val day = date.daysDiff(0) + + override fun format(resources: Resources): String { + return date.format("d MMMM") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Absolute + + if (day != other.day) return false + + return true + } + + override fun hashCode(): Int { + return day + } + } + + object LongAgo : DateTimeAgo() { + override fun format(resources: Resources): String { + return resources.getString(R.string.long_ago) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt similarity index 79% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt rename to app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt index da1f262c8..92b9fd9ef 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.ui.model +package org.koitharu.kotatsu.core.ui import androidx.annotation.StringRes import org.koitharu.kotatsu.R @@ -12,5 +12,4 @@ val SortOrder.titleRes: Int SortOrder.RATING -> R.string.by_rating SortOrder.NEWEST -> R.string.newest SortOrder.ALPHABETICAL -> R.string.by_name - SortOrder.ALPHABETICAL_DESC -> R.string.by_name_reverse - } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt new file mode 100644 index 000000000..c9168abaf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.core.ui + +import android.text.Html +import coil.ComponentRegistry +import coil.ImageLoader +import coil.disk.DiskCache +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import org.koitharu.kotatsu.core.parser.FaviconMapper +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.CbzFetcher +import org.koitharu.kotatsu.utils.image.CoilImageGetter + +val uiModule + get() = module { + single { + val httpClientFactory = { + get().newBuilder() + .cache(null) + .build() + } + val diskCacheFactory = { + val context = androidContext() + val rootDir = context.externalCacheDir ?: context.cacheDir + DiskCache.Builder() + .directory(rootDir.resolve(CacheDir.THUMBS.dir)) + .build() + } + ImageLoader.Builder(androidContext()) + .okHttpClient(httpClientFactory) + .interceptorDispatcher(Dispatchers.Default) + .fetcherDispatcher(Dispatchers.IO) + .decoderDispatcher(Dispatchers.Default) + .transformationDispatcher(Dispatchers.Default) + .diskCache(diskCacheFactory) + .components( + ComponentRegistry.Builder() + .add(CbzFetcher.Factory()) + .add(FaviconMapper()) + .build() + ).build() + } + factory { CoilImageGetter(androidContext(), get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt rename to app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt index 448341678..d34e753ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.zip import androidx.annotation.WorkerThread import androidx.collection.ArraySet import okio.Closeable -import org.koitharu.kotatsu.core.util.ext.children import java.io.File import java.io.FileInputStream import java.util.zip.Deflater @@ -53,13 +52,10 @@ class ZipOutput( return if (entryNames.add(entry.name)) { val zipEntry = ZipEntry(entry.name) output.putNextEntry(zipEntry) - try { - other.getInputStream(entry).use { input -> - input.copyTo(output) - } - } finally { - output.closeEntry() + other.getInputStream(entry).use { input -> + input.copyTo(output) } + output.closeEntry() true } else { false @@ -91,7 +87,7 @@ class ZipOutput( } putNextEntry(entry) closeEntry() - fileToZip.children().forEach { childFile -> + fileToZip.listFiles()?.forEach { childFile -> appendFile(childFile, "$name/${childFile.name}") } } else { @@ -119,4 +115,4 @@ class ZipOutput( closeEntry() return true } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt new file mode 100644 index 000000000..5091ee0e0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.details + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.details.ui.DetailsViewModel + +val detailsModule + get() = module { + + viewModel { intent -> + DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt new file mode 100644 index 000000000..d0b5a23c3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.details.domain + +class BranchComparator : Comparator { + + override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt new file mode 100644 index 000000000..9f1ad93e9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -0,0 +1,286 @@ +package org.koitharu.kotatsu.details.ui + +import android.app.ActivityOptions +import android.os.Bundle +import android.view.* +import android.widget.AdapterView +import android.widget.Spinner +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.SearchView +import androidx.core.graphics.Insets +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.ListSelectionController +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.FragmentChaptersBinding +import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter +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.download.ui.service.DownloadService +import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import kotlin.math.roundToInt + +class ChaptersFragment : + BaseFragment(), + OnListItemClickListener, + AdapterView.OnItemSelectedListener, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + ListSelectionController.Callback { + + private val viewModel by sharedViewModel() + + private var chaptersAdapter: ChaptersAdapter? = null + private var selectionController: ListSelectionController? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentChaptersBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + chaptersAdapter = ChaptersAdapter(this) + selectionController = ListSelectionController( + activity = requireActivity(), + decoration = ChaptersSelectionDecoration(view.context), + registryOwner = this, + callback = this, + ) + with(binding.recyclerViewChapters) { + checkNotNull(selectionController).attachToRecyclerView(this) + setHasFixedSize(true) + adapter = chaptersAdapter + } + binding.spinnerBranches?.let(::initSpinner) + viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) + viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) + viewModel.isChaptersReversed.observe(viewLifecycleOwner) { + activity?.invalidateOptionsMenu() + } + viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { + binding.textViewHolder.isVisible = it + activity?.invalidateOptionsMenu() + } + addMenuProvider(ChaptersMenuProvider()) + } + + override fun onDestroyView() { + chaptersAdapter = null + selectionController = null + binding.spinnerBranches?.adapter = null + super.onDestroyView() + } + + override fun onItemClick(item: ChapterListItem, view: View) { + if (selectionController?.onItemClick(item.chapter.id) == true) { + return + } + if (item.hasFlag(ChapterListItem.FLAG_MISSING)) { + (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id) + return + } + val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height) + startActivity( + ReaderActivity.newIntent( + context = view.context, + manga = viewModel.manga.value ?: return, + state = ReaderState(item.chapter.id, 0, 0), + ), + options.toBundle() + ) + } + + override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { + return selectionController?.onItemLongClick(item.chapter.id) ?: false + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_save -> { + DownloadService.start( + context ?: return false, + viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, + selectionController?.snapshot(), + ) + mode.finish() + true + } + R.id.action_delete -> { + val ids = selectionController?.peekCheckedIds() + val manga = viewModel.manga.value + when { + ids.isNullOrEmpty() || manga == null -> Unit + ids.size == manga.chapters?.size -> viewModel.deleteLocal() + else -> { + LocalChaptersRemoveService.start(requireContext(), manga, ids) + Snackbar.make( + binding.recyclerViewChapters, + R.string.chapters_will_removed_background, + Snackbar.LENGTH_LONG + ).show() + } + } + mode.finish() + true + } + R.id.action_select_range -> { + val controller = selectionController ?: return false + val items = chaptersAdapter?.items ?: return false + val ids = HashSet(controller.peekCheckedIds()) + val buffer = HashSet() + var isAdding = false + for (x in items) { + if (x.chapter.id in ids) { + isAdding = true + if (buffer.isNotEmpty()) { + ids.addAll(buffer) + buffer.clear() + } + } else if (isAdding) { + buffer.add(x.chapter.id) + } + } + controller.addAll(ids) + true + } + R.id.action_select_all -> { + val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false + selectionController?.addAll(ids) + true + } + else -> false + } + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val spinner = binding.spinnerBranches ?: return + viewModel.setSelectedBranch(spinner.selectedItem as String?) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_chapters, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val selectedIds = selectionController?.peekCheckedIds() ?: return false + val allItems = chaptersAdapter?.items.orEmpty() + val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds } + menu.findItem(R.id.action_save).isVisible = items.none { (_, x) -> + x.chapter.source == MangaSource.LOCAL + } + menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) -> + x.chapter.source == MangaSource.LOCAL + } + menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size + mode.title = items.size.toString() + var hasGap = false + for (i in 0 until items.size - 1) { + if (items[i].index + 1 != items[i + 1].index) { + hasGap = true + break + } + } + menu.findItem(R.id.action_select_range).isVisible = hasGap + return true + } + + override fun onSelectionChanged(count: Int) { + binding.recyclerViewChapters.invalidateItemDecorations() + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + (item?.actionView as? SearchView)?.setQuery("", false) + viewModel.performChapterSearch(null) + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean = false + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.performChapterSearch(newText) + return true + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.recyclerViewChapters.updatePadding( + bottom = insets.bottom + (binding.spinnerBranches?.height ?: 0), + ) + } + + private fun initSpinner(spinner: Spinner) { + val branchesAdapter = BranchesAdapter() + spinner.adapter = branchesAdapter + spinner.onItemSelectedListener = this + viewModel.branches.observe(viewLifecycleOwner) { + branchesAdapter.setItems(it) + spinner.isVisible = it.size > 1 + } + viewModel.selectedBranchIndex.observe(viewLifecycleOwner) { + if (it != -1 && it != spinner.selectedItemPosition) { + spinner.setSelection(it) + } + } + } + + private fun onChaptersChanged(list: List) { + val adapter = chaptersAdapter ?: return + if (adapter.itemCount == 0) { + val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1 + if (position > 0) { + val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() + adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position, offset)) + } else { + adapter.items = list + } + } else { + adapter.items = list + } + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + binding.progressBar.isVisible = isLoading + } + + private inner class ChaptersMenuProvider : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_chapters, menu) + val searchMenuItem = menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this@ChaptersFragment) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this@ChaptersFragment) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true + menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_reversed -> { + viewModel.setChaptersReversed(!menuItem.isChecked) + true + } + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt new file mode 100644 index 000000000..8e517b73d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -0,0 +1,373 @@ +package org.koitharu.kotatsu.details.ui + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.Spinner +import android.widget.Toast +import androidx.appcompat.view.ActionMode +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.launch +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.browser.BrowserActivity +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.os.ShortcutsUpdater +import org.koitharu.kotatsu.databinding.ActivityDetailsBinding +import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter +import org.koitharu.kotatsu.download.ui.service.DownloadService +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.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet +import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.isReportable +import org.koitharu.kotatsu.utils.ext.report + +class DetailsActivity : + BaseActivity(), + TabLayoutMediator.TabConfigurationStrategy, + AdapterView.OnItemSelectedListener { + + private val viewModel by viewModel { + parametersOf(MangaIntent(intent)) + } + + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return + viewModel.onDownloadComplete(downloadedManga) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDetailsBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowTitleEnabled(false) + } + val pager = binding.pager + if (pager != null) { + pager.adapter = MangaDetailsAdapter(this) + TabLayoutMediator(checkNotNull(binding.tabs), pager, this).attach() + } + gcFragments() + binding.spinnerBranches?.let(::initSpinner) + + viewModel.manga.observe(this, ::onMangaUpdated) + viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) + viewModel.onMangaRemoved.observe(this, ::onMangaRemoved) + viewModel.onError.observe(this, ::onError) + viewModel.onShowToast.observe(this) { + binding.snackbar.show(messageText = getString(it)) + } + + registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE)) + } + + override fun onDestroy() { + unregisterReceiver(downloadReceiver) + super.onDestroy() + } + + private fun onMangaUpdated(manga: Manga) { + title = manga.title + invalidateOptionsMenu() + } + + private fun onMangaRemoved(manga: Manga) { + Toast.makeText( + this, getString(R.string._s_deleted_from_local_storage, manga.title), + Toast.LENGTH_SHORT + ).show() + finishAfterTransition() + } + + private fun onError(e: Throwable) { + when { + ExceptionResolver.canResolve(e) -> { + resolveError(e) + } + viewModel.manga.value == null -> { + Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + finishAfterTransition() + } + e.isReportable() -> { + binding.snackbar.show( + messageText = e.getDisplayMessage(resources), + actionId = R.string.report, + duration = if (viewModel.manga.value?.chapters == null) { + Snackbar.LENGTH_INDEFINITE + } else { + Snackbar.LENGTH_LONG + }, + onActionClick = { + e.report("DetailsActivity::onError") + dismiss() + } + ) + } + else -> { + binding.snackbar.show(e.getDisplayMessage(resources)) + } + } + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.snackbar.updatePadding( + bottom = insets.bottom + ) + binding.toolbar.updateLayoutParams { + topMargin = insets.top + } + binding.root.updatePadding( + left = insets.left, + right = insets.right + ) + } + + private fun onNewChaptersChanged(newChapters: Int) { + val tab = binding.tabs?.getTabAt(1) ?: return + if (newChapters == 0) { + tab.removeBadge() + } else { + val badge = tab.orCreateBadge + badge.number = newChapters + badge.isVisible = true + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.opt_details, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val manga = viewModel.manga.value + menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL + menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL + menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL + menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this) + menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable + return super.onPrepareOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.action_delete -> { + val title = viewModel.manga.value?.title.orEmpty() + MaterialAlertDialogBuilder(this) + .setTitle(R.string.delete_manga) + .setMessage(getString(R.string.text_delete_local_manga, title)) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteLocal() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + R.id.action_save -> { + viewModel.manga.value?.let { + val chaptersCount = it.chapters?.size ?: 0 + val branches = viewModel.branches.value.orEmpty() + if (chaptersCount > 5 || branches.size > 1) { + showSaveConfirmation(it, chaptersCount, branches) + } else { + DownloadService.start(this, it) + } + } + true + } + R.id.action_browser -> { + viewModel.manga.value?.let { + startActivity(BrowserActivity.newIntent(this, it.publicUrl, it.title)) + } + true + } + R.id.action_related -> { + viewModel.manga.value?.let { + startActivity(MultiSearchActivity.newIntent(this, it.title)) + } + true + } + R.id.action_shiki_track -> { + viewModel.manga.value?.let { + ScrobblingSelectorBottomSheet.show(supportFragmentManager, it) + } + true + } + R.id.action_shortcut -> { + viewModel.manga.value?.let { + lifecycleScope.launch { + if (!get().requestPinShortcut(it)) { + binding.snackbar.show(getString(R.string.operation_not_supported)) + } + } + } + true + } + else -> super.onOptionsItemSelected(item) + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + tab.text = when (position) { + 0 -> getString(R.string.details) + 1 -> getString(R.string.chapters) + else -> null + } + } + + override fun onSupportActionModeStarted(mode: ActionMode) { + super.onSupportActionModeStarted(mode) + binding.pager?.isUserInputEnabled = false + } + + override fun onSupportActionModeFinished(mode: ActionMode) { + super.onSupportActionModeFinished(mode) + binding.pager?.isUserInputEnabled = true + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val spinner = binding.spinnerBranches ?: return + viewModel.setSelectedBranch(spinner.selectedItem as String?) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + fun showChapterMissingDialog(chapterId: Long) { + val remoteManga = viewModel.getRemoteManga() + if (remoteManga == null) { + binding.snackbar.show(getString(R.string.chapter_is_missing)) + return + } + MaterialAlertDialogBuilder(this).apply { + setMessage(R.string.chapter_is_missing_text) + setTitle(R.string.chapter_is_missing) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.read) { _, _ -> + startActivity( + ReaderActivity.newIntent( + context = this@DetailsActivity, + manga = remoteManga, + state = ReaderState(chapterId, 0, 0) + ) + ) + } + setNeutralButton(R.string.download) { _, _ -> + DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId)) + } + setCancelable(true) + }.show() + } + + private fun initSpinner(spinner: Spinner) { + val branchesAdapter = BranchesAdapter() + spinner.adapter = branchesAdapter + spinner.onItemSelectedListener = this + viewModel.branches.observe(this) { + branchesAdapter.setItems(it) + spinner.isVisible = it.size > 1 + } + viewModel.selectedBranchIndex.observe(this) { + if (it != -1 && it != spinner.selectedItemPosition) { + spinner.setSelection(it) + } + } + } + + private fun resolveError(e: Throwable) { + lifecycleScope.launch { + if (exceptionResolver.resolve(e)) { + viewModel.reload() + } else if (viewModel.manga.value == null) { + Toast.makeText(this@DetailsActivity, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + finishAfterTransition() + } + } + } + + private fun gcFragments() { + val mustHaveId = binding.pager == null + val fm = supportFragmentManager + val fragmentsToRemove = fm.fragments.filter { f -> + (f.id == 0) == mustHaveId + } + if (fragmentsToRemove.isEmpty()) { + return + } + fm.commit { + setReorderingAllowed(true) + for (f in fragmentsToRemove) { + remove(f) + } + } + } + + private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List) { + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(R.string.save_manga) + .setNegativeButton(android.R.string.cancel, null) + if (branches.size > 1) { + val items = Array(branches.size) { i -> branches[i].orEmpty() } + val currentBranch = viewModel.selectedBranchIndex.value ?: -1 + val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch } + dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked -> + checkedIndices[i] = checked + }.setPositiveButton(R.string.save) { _, _ -> + val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] } + val chaptersIds = manga.chapters?.mapNotNullToSet { c -> + if (c.branch in selectedBranches) c.id else null + } + DownloadService.start(this, manga, chaptersIds) + } + } else { + dialogBuilder.setMessage( + getString( + R.string.large_manga_save_confirm, + resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) + ) + ).setPositiveButton(R.string.save) { _, _ -> + DownloadService.start(this, manga) + } + } + dialogBuilder.show() + } + + companion object { + + fun newIntent(context: Context, manga: Manga): Intent { + return Intent(context, DetailsActivity::class.java) + .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + } + + fun newIntent(context: Context, mangaId: Long): Intent { + return Intent(context, DetailsActivity::class.java) + .putExtra(MangaIntent.KEY_ID, mangaId) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt new file mode 100644 index 000000000..6b2b1afac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -0,0 +1,389 @@ +package org.koitharu.kotatsu.details.ui + +import android.app.ActivityOptions +import android.os.Bundle +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.view.* +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.core.text.parseAsHtml +import androidx.core.view.MenuProvider +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import coil.util.CoilUtils +import com.google.android.material.chip.Chip +import kotlinx.coroutines.launch +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter +import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.databinding.FragmentDetailsBinding +import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.image.ui.ImageActivity +import org.koitharu.kotatsu.parsers.model.Manga +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.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.search.ui.SearchActivity +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.* + +class DetailsFragment : + BaseFragment(), + View.OnClickListener, + View.OnLongClickListener, + ChipsView.OnChipClickListener, + OnListItemClickListener { + + private val viewModel by sharedViewModel() + private val coil by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup?, + ) = FragmentDetailsBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.textViewAuthor.setOnClickListener(this) + binding.buttonFavorite.setOnClickListener(this) + binding.buttonRead.setOnClickListener(this) + binding.buttonRead.setOnLongClickListener(this) + binding.imageViewCover.setOnClickListener(this) + binding.scrobblingLayout.root.setOnClickListener(this) + binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() + binding.chipsTags.onChipClickListener = this + viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) + viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) + viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) + viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged) + viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) + viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) + viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) + addMenuProvider(DetailsMenuProvider()) + } + + override fun onItemClick(item: Bookmark, view: View) { + val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height) + startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle()) + } + + override fun onItemLongClick(item: Bookmark, view: View): Boolean { + val menu = PopupMenu(view.context, view) + menu.inflate(R.menu.popup_bookmark) + menu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_remove -> viewModel.removeBookmark(item) + } + true + } + menu.show() + return true + } + + private fun onMangaUpdated(manga: Manga) { + with(binding) { + // Main + loadCover(manga) + textViewTitle.text = manga.title + textViewSubtitle.textAndVisible = manga.altTitle + textViewAuthor.textAndVisible = manga.author + when (manga.state) { + MangaState.FINISHED -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_finished) + drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_state_finished) + } + } + MangaState.ONGOING -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_ongoing) + drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing) + } + } + else -> textViewState.isVisible = false + } + + // Info containers + val chapters = manga.chapters + if (chapters.isNullOrEmpty()) { + infoLayout.textViewChapters.isVisible = false + } else { + infoLayout.textViewChapters.isVisible = true + infoLayout.textViewChapters.text = resources.getQuantityString( + R.plurals.chapters, + chapters.size, + chapters.size, + ) + } + if (manga.hasRating) { + infoLayout.textViewRating.text = String.format("%.1f", manga.rating * 5) + infoLayout.ratingContainer.isVisible = true + } else { + infoLayout.ratingContainer.isVisible = false + } + if (manga.source == MangaSource.LOCAL) { + infoLayout.textViewSource.isVisible = false + val file = manga.url.toUri().toFileOrNull() + if (file != null) { + viewLifecycleScope.launch { + val size = file.computeSize() + infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size) + infoLayout.textViewSize.isVisible = true + } + } else { + infoLayout.textViewSize.isVisible = false + } + } else { + infoLayout.textViewSource.text = manga.source.title + infoLayout.textViewSource.isVisible = true + infoLayout.textViewSize.isVisible = false + } + + infoLayout.textViewNsfw.isVisible = manga.isNsfw + + // Buttons + buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() + + // Chips + bindTags(manga) + } + } + + private fun onDescriptionChanged(description: CharSequence?) { + if (description.isNullOrBlank()) { + binding.textViewDescription.setText(R.string.no_description) + } else { + binding.textViewDescription.text = description + } + } + + private fun onHistoryChanged(history: MangaHistory?) { + with(binding.buttonRead) { + if (history == null) { + setText(R.string.read) + setIconResource(R.drawable.ic_read) + } else { + setText(R.string._continue) + setIconResource(R.drawable.ic_play) + } + } + binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true) + } + + private fun onFavouriteChanged(isFavourite: Boolean) { + val iconRes = if (isFavourite) { + R.drawable.ic_heart + } else { + R.drawable.ic_heart_outline + } + binding.buttonFavorite.setIconResource(iconRes) + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + if (isLoading) { + binding.progressBar.show() + } else { + binding.progressBar.hide() + } + } + + private fun onBookmarksChanged(bookmarks: List) { + var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter + binding.groupBookmarks.isGone = bookmarks.isEmpty() + if (adapter != null) { + adapter.items = bookmarks + } else { + adapter = BookmarksAdapter(coil, viewLifecycleOwner, this) + adapter.items = bookmarks + binding.recyclerViewBookmarks.adapter = adapter + val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing) + binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing)) + } + } + + private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) { + with(binding.scrobblingLayout) { + root.isVisible = scrobbling != null + if (scrobbling == null) { + CoilUtils.dispose(imageViewCover) + return + } + imageViewCover.newImageRequest(scrobbling.coverUrl)?.run { + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_placeholder) + lifecycle(viewLifecycleOwner) + enqueueWith(coil) + } + textViewTitle.text = scrobbling.title + textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0) + ratingBar.rating = scrobbling.rating * ratingBar.numStars + textViewStatus.text = scrobbling.status?.let { + resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal) + } + } + } + + override fun onClick(v: View) { + val manga = viewModel.manga.value ?: return + when (v.id) { + R.id.button_favorite -> { + FavouriteCategoriesBottomSheet.show(childFragmentManager, manga) + } + R.id.scrobbling_layout -> { + ScrobblingInfoBottomSheet.show(childFragmentManager) + } + R.id.button_read -> { + val chapterId = viewModel.readingHistory.value?.chapterId + if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { + (activity as? DetailsActivity)?.showChapterMissingDialog(chapterId) + } else { + startActivity( + ReaderActivity.newIntent( + context = context ?: return, + manga = manga, + branch = viewModel.selectedBranchValue, + ) + ) + } + } + R.id.textView_author -> { + startActivity( + SearchActivity.newIntent( + context = v.context, + source = manga.source, + query = manga.author ?: return, + ) + ) + } + R.id.imageView_cover -> { + val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height) + startActivity( + ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }), + options.toBundle() + ) + } + } + } + + override fun onLongClick(v: View): Boolean { + when (v.id) { + R.id.button_read -> { + if (viewModel.readingHistory.value == null) { + return false + } + val menu = PopupMenu(v.context, v) + menu.inflate(R.menu.popup_read) + menu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_read -> { + val branch = viewModel.selectedBranchValue + startActivity( + ReaderActivity.newIntent( + context = context ?: return@setOnMenuItemClickListener false, + manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false, + state = viewModel.chapters.value?.firstOrNull { c -> + c.chapter.branch == branch + }?.let { c -> + ReaderState(c.chapter.id, 0, 0) + } + ) + ) + true + } + else -> false + } + } + menu.show() + return true + } + else -> return false + } + } + + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag ?: return + startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.root.updatePadding( + bottom = insets.bottom, + ) + } + + private fun bindTags(manga: Manga) { + binding.chipsTags.setChips( + manga.tags.map { tag -> + ChipsView.ChipModel( + title = tag.title, + icon = 0, + data = tag, + ) + } + ) + } + + private fun loadCover(manga: Manga) { + val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } + val lastResult = CoilUtils.result(binding.imageViewCover) + if (lastResult?.request?.data == imageUrl) { + return + } + val request = ImageRequest.Builder(context ?: return) + .target(binding.imageViewCover) + .data(imageUrl) + .crossfade(true) + .referer(manga.publicUrl) + .lifecycle(viewLifecycleOwner) + lastResult?.drawable?.let { + request.fallback(it) + } ?: request.fallback(R.drawable.ic_placeholder) + request.enqueueWith(coil) + } + + private inner class DetailsMenuProvider : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_details_info, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_share -> { + viewModel.manga.value?.let { + val context = requireContext() + if (it.source == MangaSource.LOCAL) { + ShareHelper(context).shareCbz(listOf(it.url.toUri().toFile())) + } else { + ShareHelper(context).shareMangaLink(it) + } + } + true + } + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt new file mode 100644 index 000000000..d77ef9aba --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -0,0 +1,245 @@ +package org.koitharu.kotatsu.details.ui + +import android.text.Html +import androidx.core.text.parseAsHtml +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.details.domain.BranchComparator +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.io.IOException + +class DetailsViewModel( + intent: MangaIntent, + private val historyRepository: HistoryRepository, + favouritesRepository: FavouritesRepository, + private val localMangaRepository: LocalMangaRepository, + trackingRepository: TrackingRepository, + mangaDataRepository: MangaDataRepository, + private val bookmarksRepository: BookmarksRepository, + private val settings: AppSettings, + private val scrobbler: Scrobbler, + private val imageGetter: Html.ImageGetter, +) : BaseViewModel() { + + private val delegate = MangaDetailsDelegate( + intent = intent, + settings = settings, + mangaDataRepository = mangaDataRepository, + historyRepository = historyRepository, + localMangaRepository = localMangaRepository, + ) + + private var loadingJob: Job + + val onShowToast = SingleLiveEvent() + + private val history = historyRepository.observeOne(delegate.mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + + private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + + private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) + + private val chaptersQuery = MutableStateFlow("") + + private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + + val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext) + val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext) + val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext) + val readingHistory = history.asLiveData(viewModelScope.coroutineContext) + val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext) + + val bookmarks = delegate.manga.flatMapLatest { + if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList()) + + val description = delegate.manga + .distinctUntilChangedBy { it?.description.orEmpty() } + .transformLatest { + val description = it?.description + if (description.isNullOrEmpty()) { + emit(null) + } else { + emit(description.parseAsHtml()) + emit(description.parseAsHtml(imageGetter = imageGetter)) + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) + + val onMangaRemoved = SingleLiveEvent() + val isScrobblingAvailable: Boolean + get() = scrobbler.isAvailable + + val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId) + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) + + val branches: LiveData> = delegate.manga.map { + val chapters = it?.chapters ?: return@map emptyList() + chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + val selectedBranchIndex = combine( + branches.asFlow(), + delegate.selectedBranch + ) { branches, selected -> + branches.indexOf(selected) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + val isChaptersEmpty: LiveData = combine( + delegate.manga, + isLoading.asFlow(), + ) { m, loading -> + m != null && m.chapters.isNullOrEmpty() && !loading + }.asLiveDataDistinct(viewModelScope.coroutineContext, false) + + val chapters = combine( + combine( + delegate.manga, + delegate.relatedManga, + history, + delegate.selectedBranch, + newChapters, + ) { manga, related, history, branch, news -> + delegate.mapChapters(manga, related, history, news, branch) + }, + chaptersReversed, + chaptersQuery, + ) { list, reversed, query -> + (if (reversed) list.asReversed() else list).filterSearch(query) + }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + + val selectedBranchValue: String? + get() = delegate.selectedBranch.value + + init { + loadingJob = doLoad() + } + + fun reload() { + loadingJob.cancel() + loadingJob = doLoad() + } + + fun deleteLocal() { + val m = delegate.manga.value + if (m == null) { + onShowToast.call(R.string.file_not_found) + return + } + launchLoadingJob(Dispatchers.Default) { + val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m) + checkNotNull(manga) { "Cannot find saved manga for ${m.title}" } + val original = localMangaRepository.getRemoteManga(manga) + localMangaRepository.delete(manga) || throw IOException("Unable to delete file") + runCatching { + historyRepository.deleteOrSwap(manga, original) + } + onMangaRemoved.postCall(manga) + } + } + + fun removeBookmark(bookmark: Bookmark) { + launchJob { + bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId) + onShowToast.call(R.string.bookmark_removed) + } + } + + fun setChaptersReversed(newValue: Boolean) { + settings.chaptersReverse = newValue + } + + fun setSelectedBranch(branch: String?) { + delegate.selectedBranch.value = branch + } + + fun getRemoteManga(): Manga? { + return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL } + } + + fun performChapterSearch(query: String?) { + chaptersQuery.value = query?.trim().orEmpty() + } + + fun onDownloadComplete(downloadedManga: Manga) { + val currentManga = delegate.manga.value ?: return + if (currentManga.id != downloadedManga.id) { + return + } + if (currentManga.source == MangaSource.LOCAL) { + reload() + } else { + viewModelScope.launch(Dispatchers.Default) { + runCatching { + localMangaRepository.getDetails(downloadedManga) + }.onSuccess { + delegate.relatedManga.value = it + }.onFailure { + it.printStackTraceDebug() + } + } + } + } + + fun updateScrobbling(rating: Float, status: ScrobblingStatus?) { + launchJob(Dispatchers.Default) { + scrobbler.updateScrobblingInfo( + mangaId = delegate.mangaId, + rating = rating, + status = status, + comment = null, + ) + } + } + + fun unregisterScrobbling() { + launchJob(Dispatchers.Default) { + scrobbler.unregisterScrobbling( + mangaId = delegate.mangaId + ) + } + } + + private fun doLoad() = launchLoadingJob(Dispatchers.Default) { + delegate.doLoad() + } + + private fun List.filterSearch(query: String): List { + if (query.isEmpty() || this.isEmpty()) { + return this + } + return filter { + it.chapter.name.contains(query, ignoreCase = true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt new file mode 100644 index 000000000..03cf1d76c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.details.ui + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter + +class MangaDetailsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun getItemCount() = 2 + + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> DetailsFragment() + 1 -> ChaptersFragment() + else -> throw IndexOutOfBoundsException("No fragment for position $position") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt new file mode 100644 index 000000000..3a4eca7ca --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt @@ -0,0 +1,184 @@ +package org.koitharu.kotatsu.details.ui + +import androidx.core.os.LocaleListCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.details.ui.model.toListItem +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.exception.NotFoundException +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.util.mapToSet +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.utils.ext.iterator +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +class MangaDetailsDelegate( + private val intent: MangaIntent, + private val settings: AppSettings, + private val mangaDataRepository: MangaDataRepository, + private val historyRepository: HistoryRepository, + private val localMangaRepository: LocalMangaRepository, +) { + + private val mangaData = MutableStateFlow(intent.manga) + + val selectedBranch = MutableStateFlow(null) + + // Remote manga for saved and saved for remote + val relatedManga = MutableStateFlow(null) + val manga: StateFlow + get() = mangaData + val mangaId = intent.manga?.id ?: intent.mangaId + + suspend fun doLoad() { + var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "") + mangaData.value = manga + manga = MangaRepository(manga.source).getDetails(manga) + // find default branch + val hist = historyRepository.getOne(manga) + selectedBranch.value = if (hist != null) { + val currentChapter = manga.chapters?.find { it.id == hist.chapterId } + if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters) + } else { + predictBranch(manga.chapters) + } + mangaData.value = manga + relatedManga.value = runCatching { + if (manga.source == MangaSource.LOCAL) { + val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null + MangaRepository(m.source).getDetails(m) + } else { + localMangaRepository.findSavedManga(manga) + } + }.onFailure { error -> + error.printStackTraceDebug() + }.getOrNull() + } + + fun mapChapters( + manga: Manga?, + related: Manga?, + history: MangaHistory?, + newCount: Int, + branch: String?, + ): List { + val chapters = manga?.chapters ?: return emptyList() + val relatedChapters = related?.chapters + return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) { + mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch) + } else { + mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch) + } + } + + private fun mapChapters( + chapters: List, + downloadedChapters: List?, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val result = ArrayList(chapters.size) + val dateFormat = settings.getDateFormat() + val currentIndex = chapters.indexOfFirst { it.id == currentId } + val firstNewIndex = chapters.size - newCount + val downloadedIds = downloadedChapters?.mapToSet { it.id } + for (i in chapters.indices) { + val chapter = chapters[i] + if (chapter.branch != branch) { + continue + } + result += chapter.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = downloadedIds?.contains(chapter.id) == true, + dateFormat = dateFormat, + ) + } + return result + } + + private fun mapChaptersWithSource( + chapters: List, + sourceChapters: List, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id } + val result = ArrayList(sourceChapters.size) + val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } + val firstNewIndex = sourceChapters.size - newCount + val dateFormat = settings.getDateFormat() + for (i in sourceChapters.indices) { + val chapter = sourceChapters[i] + val localChapter = chaptersMap.remove(chapter.id) + if (chapter.branch != branch) { + continue + } + result += localChapter?.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) ?: chapter.toListItem( + isCurrent = i == currentIndex, + isUnread = i > currentIndex, + isNew = i >= firstNewIndex, + isMissing = true, + isDownloaded = false, + dateFormat = dateFormat, + ) + } + if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source + result.ensureCapacity(result.size + chaptersMap.size) + chaptersMap.values.mapNotNullTo(result) { + if (it.branch == branch) { + it.toListItem( + isCurrent = false, + isUnread = true, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) + } else { + null + } + } + result.sortBy { it.chapter.number } + } + return result + } + + private fun predictBranch(chapters: List?): String? { + if (chapters.isNullOrEmpty()) { + return null + } + val groups = chapters.groupBy { it.branch } + for (locale in LocaleListCompat.getAdjustedDefault()) { + var language = locale.getDisplayLanguage(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + language = locale.getDisplayName(locale).toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + } + return groups.maxByOrNull { it.value.size }?.key + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt new file mode 100644 index 000000000..2ca602df9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.details.ui.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.replaceWith + +class BranchesAdapter : BaseAdapter() { + + private val dataSet = ArrayList() + + override fun getCount(): Int { + return dataSet.size + } + + override fun getItem(position: Int): Any? { + return dataSet[position] + } + + override fun getItemId(position: Int): Long { + return dataSet[position].hashCode().toLong() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(parent.context) + .inflate(R.layout.item_branch, parent, false) + (view as TextView).text = dataSet[position] + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(parent.context) + .inflate(R.layout.item_branch_dropdown, parent, false) + (view as TextView).text = dataSet[position] + return view + } + + fun setItems(items: Collection) { + dataSet.replaceWith(items) + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt rename to app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 2c11ee18f..f65951d47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -1,22 +1,24 @@ package org.koitharu.kotatsu.details.ui.adapter -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.drawableStart -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import com.google.android.material.R as materialR +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD +import org.koitharu.kotatsu.utils.ext.getThemeColor +import org.koitharu.kotatsu.utils.ext.textAndVisible fun chapterListItemAD( clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } ) { val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener) @@ -29,28 +31,26 @@ fun chapterListItemAD( binding.textViewNumber.text = item.chapter.number.toString() binding.textViewDescription.textAndVisible = item.description() } - when { - item.isCurrent -> { - binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_primary) - binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnPrimary)) - } - - item.isUnread -> { + when (item.status) { + FLAG_UNREAD -> { binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) - binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnTertiary)) + binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse)) + } + FLAG_CURRENT -> { + binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent) + binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) } - else -> { binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary)) } } - binding.imageViewBookmarked.isVisible = item.isBookmarked - binding.imageViewDownloaded.isVisible = item.isDownloaded - binding.textViewTitle.drawableStart = if (item.isNew) { - ContextCompat.getDrawable(context, R.drawable.ic_new) - } else { - null - } + val isMissing = item.hasFlag(FLAG_MISSING) + binding.textViewTitle.alpha = if (isMissing) 0.3f else 1f + binding.textViewDescription.alpha = if (isMissing) 0.3f else 1f + binding.textViewNumber.alpha = if (isMissing) 0.3f else 1f + + binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED) + binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt new file mode 100644 index 000000000..033b9ed92 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.details.ui.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import kotlin.jvm.internal.Intrinsics + +class ChaptersAdapter( + onItemClickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + setHasStableIds(true) + delegatesManager.addDelegate(chapterListItemAD(onItemClickListener)) + } + + override fun getItemId(position: Int): Long { + return items[position].chapter.id + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean { + return oldItem.chapter.id == newItem.chapter.id + } + + override fun areContentsTheSame( + oldItem: ChapterListItem, + newItem: ChapterListItem + ): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + + override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? { + if (oldItem.flags != newItem.flags && oldItem.chapter == newItem.chapter) { + return newItem.flags + } + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt index 505de1c4b..0ff2fbc94 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt @@ -6,10 +6,9 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.view.View -import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration +import org.koitharu.kotatsu.utils.ext.getThemeColor import com.google.android.material.R as materialR class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { @@ -18,10 +17,7 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material) init { - paint.color = ColorUtils.setAlphaComponent( - context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY), - 98, - ) + paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY) paint.style = Paint.Style.FILL } @@ -34,4 +30,4 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor ) { canvas.drawRoundRect(bounds, radius, radius, paint) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt new file mode 100644 index 000000000..2d5b90840 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.details.ui.model + +import org.koitharu.kotatsu.parsers.model.MangaChapter + +class ChapterListItem( + val chapter: MangaChapter, + val flags: Int, + val uploadDate: String?, +) { + + val status: Int + get() = flags and MASK_STATUS + + fun hasFlag(flag: Int): Boolean { + return (flags and flag) == flag + } + + fun description(): CharSequence? { + val scanlator = chapter.scanlator?.takeUnless { it.isBlank() } + return when { + uploadDate != null && scanlator != null -> "$uploadDate • $scanlator" + scanlator != null -> scanlator + else -> uploadDate + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChapterListItem + + if (chapter != other.chapter) return false + if (flags != other.flags) return false + if (uploadDate != other.uploadDate) return false + + return true + } + + override fun hashCode(): Int { + var result = chapter.hashCode() + result = 31 * result + flags + result = 31 * result + (uploadDate?.hashCode() ?: 0) + return result + } + + companion object { + + const val FLAG_UNREAD = 2 + const val FLAG_CURRENT = 4 + const val FLAG_NEW = 8 + const val FLAG_MISSING = 16 + const val FLAG_DOWNLOADED = 32 + const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt similarity index 80% rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt rename to app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt index 95c4cae15..22d272346 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt @@ -1,28 +1,30 @@ package org.koitharu.kotatsu.details.ui.model -import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED +import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD import org.koitharu.kotatsu.parsers.model.MangaChapter +import java.text.DateFormat fun MangaChapter.toListItem( isCurrent: Boolean, isUnread: Boolean, isNew: Boolean, + isMissing: Boolean, isDownloaded: Boolean, - isBookmarked: Boolean, + dateFormat: DateFormat, ): ChapterListItem { var flags = 0 if (isCurrent) flags = flags or FLAG_CURRENT if (isUnread) flags = flags or FLAG_UNREAD if (isNew) flags = flags or FLAG_NEW - if (isBookmarked) flags = flags or FLAG_BOOKMARKED + if (isMissing) flags = flags or FLAG_MISSING if (isDownloaded) flags = flags or FLAG_DOWNLOADED return ChapterListItem( chapter = this, flags = flags, - uploadDateMs = uploadDate, + uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt new file mode 100644 index 000000000..8347b0a7e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt @@ -0,0 +1,150 @@ +package org.koitharu.kotatsu.details.ui.scrobbling + +import android.app.ActivityOptions +import android.content.Intent +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.RatingBar +import android.widget.Toast +import androidx.appcompat.widget.PopupMenu +import androidx.core.net.toUri +import androidx.fragment.app.FragmentManager +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +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.domain.model.ScrobblingInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class ScrobblingInfoBottomSheet : + BaseBottomSheet(), + AdapterView.OnItemSelectedListener, + RatingBar.OnRatingBarChangeListener, + View.OnClickListener, + PopupMenu.OnMenuItemClickListener { + + private val viewModel by sharedViewModel() + private val coil by inject(mode = LazyThreadSafetyMode.NONE) + private var menu: PopupMenu? = null + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding { + return SheetScrobblingBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) + viewModel.onError.observe(viewLifecycleOwner) { + Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show() + } + + binding.spinnerStatus.onItemSelectedListener = this + binding.ratingBar.onRatingBarChangeListener = this + binding.buttonMenu.setOnClickListener(this) + binding.imageViewCover.setOnClickListener(this) + binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() + + menu = PopupMenu(view.context, binding.buttonMenu).apply { + inflate(R.menu.opt_scrobbling) + setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet) + } + } + + override fun onDestroyView() { + super.onDestroyView() + menu = null + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.updateScrobbling( + rating = binding.ratingBar.rating / binding.ratingBar.numStars, + status = enumValues().getOrNull(position), + ) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { + if (fromUser) { + viewModel.updateScrobbling( + rating = rating / ratingBar.numStars, + status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition), + ) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_menu -> menu?.show() + R.id.imageView_cover -> { + val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return + val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height) + startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle()) + } + } + } + + private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) { + if (scrobbling == null) { + dismissAllowingStateLoss() + return + } + binding.textViewTitle.text = scrobbling.title + binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars + binding.textViewDescription.text = scrobbling.description + binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) + ImageRequest.Builder(context ?: return) + .target(binding.imageViewCover) + .data(scrobbling.coverUrl) + .crossfade(true) + .lifecycle(viewLifecycleOwner) + .placeholder(R.drawable.ic_placeholder) + .fallback(R.drawable.ic_placeholder) + .error(R.drawable.ic_placeholder) + .scale(Scale.FILL) + .enqueueWith(coil) + } + + companion object { + + private const val TAG = "ScrobblingInfoBottomSheet" + + fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG) + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_browser -> { + val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity( + Intent.createChooser(intent, getString(R.string.open_in_browser)) + ) + } + R.id.action_unregister -> { + viewModel.unregisterScrobbling() + dismiss() + } + R.id.action_edit -> { + val manga = viewModel.manga.value ?: return false + ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga) + dismiss() + } + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt new file mode 100644 index 000000000..d079eb51f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -0,0 +1,231 @@ +package org.koitharu.kotatsu.download.domain + +import android.content.Context +import android.net.ConnectivityManager +import android.webkit.MimeTypeMap +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Semaphore +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.IOException +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.CbzMangaOutput +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.referer +import org.koitharu.kotatsu.utils.ext.waitForNetwork +import org.koitharu.kotatsu.utils.progress.ProgressJob +import java.io.File + +private const val MAX_DOWNLOAD_ATTEMPTS = 3 +private const val DOWNLOAD_ERROR_DELAY = 500L +private const val SLOWDOWN_DELAY = 200L + +class DownloadManager( + private val coroutineScope: CoroutineScope, + private val context: Context, + private val imageLoader: ImageLoader, + private val okHttp: OkHttpClient, + private val cache: PagesCache, + private val localMangaRepository: LocalMangaRepository, + private val settings: AppSettings, +) { + + private val connectivityManager = context.getSystemService( + Context.CONNECTIVITY_SERVICE + ) as ConnectivityManager + private val coverWidth = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_width + ) + private val coverHeight = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_height + ) + private val semaphore = Semaphore(settings.downloadsParallelism) + + fun downloadManga( + manga: Manga, + chaptersIds: LongArray?, + startId: Int, + ): ProgressJob { + val stateFlow = MutableStateFlow( + DownloadState.Queued(startId = startId, manga = manga, cover = null) + ) + val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId) + return ProgressJob(job, stateFlow) + } + + private fun downloadMangaImpl( + manga: Manga, + chaptersIds: LongArray?, + outState: MutableStateFlow, + startId: Int, + ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { + @Suppress("NAME_SHADOWING") var manga = manga + val chaptersIdsSet = chaptersIds?.toMutableSet() + val cover = loadCover(manga) + outState.value = DownloadState.Queued(startId, manga, cover) + localMangaRepository.lockManga(manga.id) + semaphore.acquire() + coroutineContext[WakeLockNode]?.acquire() + outState.value = DownloadState.Preparing(startId, manga, null) + val destination = localMangaRepository.getOutputDir() + checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } + val tempFileName = "${manga.id}_$startId.tmp" + var output: CbzMangaOutput? = null + try { + if (manga.source == MangaSource.LOCAL) { + manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance") + } + val repo = MangaRepository(manga.source) + outState.value = DownloadState.Preparing(startId, manga, cover) + val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga + output = CbzMangaOutput.get(destination, data) + val coverUrl = data.largeCoverUrl ?: data.coverUrl + downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file -> + output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) + } + val chapters = checkNotNull( + if (chaptersIdsSet == null) { + data.chapters + } else { + data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) } + } + ) { "Chapters list must not be null" } + check(chapters.isNotEmpty()) { "Chapters list must not be empty" } + check(chaptersIdsSet.isNullOrEmpty()) { + "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga" + } + for ((chapterIndex, chapter) in chapters.withIndex()) { + val pages = repo.getPages(chapter) + for ((pageIndex, page) in pages.withIndex()) { + var retryCounter = 0 + failsafe@ while (true) { + try { + val url = repo.getPageUrl(page) + val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName) + output.addPage( + chapter = chapter, + file = file, + pageNumber = pageIndex, + ext = MimeTypeMap.getFileExtensionFromUrl(url), + ) + break@failsafe + } catch (e: IOException) { + if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) { + outState.value = DownloadState.WaitingForNetwork(startId, data, cover) + delay(DOWNLOAD_ERROR_DELAY) + connectivityManager.waitForNetwork() + retryCounter++ + } else { + throw e + } + } + } + + outState.value = DownloadState.Progress( + startId, data, cover, + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageIndex, + ) + + if (settings.isDownloadsSlowdownEnabled) { + delay(SLOWDOWN_DELAY) + } + } + } + outState.value = DownloadState.PostProcessing(startId, data, cover) + output.mergeWithExisting() + output.finalize() + val localManga = localMangaRepository.getFromFile(output.file) + outState.value = DownloadState.Done(startId, data, cover, localManga) + } catch (e: CancellationException) { + outState.value = DownloadState.Cancelled(startId, manga, cover) + throw e + } catch (e: Throwable) { + e.printStackTraceDebug() + outState.value = DownloadState.Error(startId, manga, cover, e) + } finally { + withContext(NonCancellable) { + output?.cleanup() + File(destination, tempFileName).deleteAwait() + } + coroutineContext[WakeLockNode]?.release() + semaphore.release() + localMangaRepository.unlockManga(manga.id) + } + } + + private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File { + val request = Request.Builder() + .url(url) + .header(CommonHeaders.REFERER, referer) + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .get() + .build() + val call = okHttp.newCall(request) + val file = File(destination, tempFileName) + val response = call.clone().await() + runInterruptible(Dispatchers.IO) { + file.outputStream().use { out -> + checkNotNull(response.body).byteStream().copyTo(out) + } + } + return file + } + + private fun errorStateHandler(outState: MutableStateFlow) = + CoroutineExceptionHandler { _, throwable -> + val prevValue = outState.value + outState.value = DownloadState.Error( + startId = prevValue.startId, + manga = prevValue.manga, + cover = prevValue.cover, + error = throwable, + ) + } + + private suspend fun loadCover(manga: Manga) = runCatching { + imageLoader.execute( + ImageRequest.Builder(context) + .data(manga.coverUrl) + .referer(manga.publicUrl) + .size(coverWidth, coverHeight) + .scale(Scale.FILL) + .build() + ).drawable + }.getOrNull() + + class Factory( + private val context: Context, + private val imageLoader: ImageLoader, + private val okHttp: OkHttpClient, + private val cache: PagesCache, + private val localMangaRepository: LocalMangaRepository, + private val settings: AppSettings, + ) { + + fun create(coroutineScope: CoroutineScope) = DownloadManager( + coroutineScope = coroutineScope, + context = context, + imageLoader = imageLoader, + okHttp = okHttp, + cache = cache, + localMangaRepository = localMangaRepository, + settings = settings, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt new file mode 100644 index 000000000..a0a78ac7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -0,0 +1,251 @@ +package org.koitharu.kotatsu.download.domain + +import android.graphics.drawable.Drawable +import org.koitharu.kotatsu.parsers.model.Manga + +sealed interface DownloadState { + + val startId: Int + val manga: Manga + val cover: Drawable? + + class Queued( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Queued + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Preparing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Preparing + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Progress( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val totalChapters: Int, + val currentChapter: Int, + val totalPages: Int, + val currentPage: Int, + ) : DownloadState { + + val max: Int = totalChapters * totalPages + + val progress: Int = totalPages * currentChapter + currentPage + 1 + + val percent: Float = progress.toFloat() / max + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Progress + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (totalChapters != other.totalChapters) return false + if (currentChapter != other.currentChapter) return false + if (totalPages != other.totalPages) return false + if (currentPage != other.currentPage) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + totalChapters + result = 31 * result + currentChapter + result = 31 * result + totalPages + result = 31 * result + currentPage + return result + } + } + + class WaitingForNetwork( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WaitingForNetwork + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class Done( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val localManga: Manga, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Done + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (localManga != other.localManga) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + localManga.hashCode() + return result + } + } + + class Error( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val error: Throwable, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Error + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + if (error != other.error) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + result = 31 * result + error.hashCode() + return result + } + } + + class Cancelled( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Cancelled + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } + + class PostProcessing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : DownloadState { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PostProcessing + + if (startId != other.startId) return false + if (manga != other.manga) return false + if (cover != other.cover) return false + + return true + } + + override fun hashCode(): Int { + var result = startId + result = 31 * result + manga.hashCode() + result = 31 * result + (cover?.hashCode() ?: 0) + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt new file mode 100644 index 000000000..8bbfc2f2d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.download.domain + +import android.os.PowerManager +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +class WakeLockNode( + private val wakeLock: PowerManager.WakeLock, + private val timeout: Long, +) : AbstractCoroutineContextElement(Key) { + + init { + wakeLock.setReferenceCounted(true) + } + + fun acquire() { + wakeLock.acquire(timeout) + } + + fun release() { + wakeLock.release() + } + + companion object Key : CoroutineContext.Key +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt new file mode 100644 index 000000000..476f6efb6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -0,0 +1,109 @@ +package org.koitharu.kotatsu.download.ui + +import androidx.core.view.isVisible +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemDownloadBinding +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.parsers.util.format +import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.progress.ProgressJob + +fun downloadItemAD( + scope: CoroutineScope, + coil: ImageLoader, +) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>( + { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } +) { + + var job: Job? = null + val percentPattern = context.resources.getString(R.string.percent_string_pattern) + + bind { + job?.cancel() + job = item.progressAsFlow().onFirst { state -> + binding.imageViewCover.newImageRequest(state.manga.coverUrl)?.run { + referer(state.manga.publicUrl) + placeholder(state.cover) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_placeholder) + allowRgb565(true) + enqueueWith(coil) + } + }.onEach { state -> + binding.textViewTitle.text = state.manga.title + when (state) { + is DownloadState.Cancelled -> { + binding.textViewStatus.setText(R.string.cancelling_) + binding.progressBar.isIndeterminate = true + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadState.Done -> { + binding.textViewStatus.setText(R.string.download_complete) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadState.Error -> { + binding.textViewStatus.setText(R.string.error_occurred) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) + binding.textViewDetails.isVisible = true + } + is DownloadState.PostProcessing -> { + binding.textViewStatus.setText(R.string.processing_) + binding.progressBar.isIndeterminate = true + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadState.Preparing -> { + binding.textViewStatus.setText(R.string.preparing_) + binding.progressBar.isIndeterminate = true + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadState.Progress -> { + binding.textViewStatus.setText(R.string.manga_downloading_) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = true + binding.progressBar.max = state.max + binding.progressBar.setProgressCompat(state.progress, true) + binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1)) + binding.textViewPercent.isVisible = true + binding.textViewDetails.isVisible = false + } + is DownloadState.Queued -> { + binding.textViewStatus.setText(R.string.queued) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadState.WaitingForNetwork -> { + binding.textViewStatus.setText(R.string.waiting_for_network) + binding.progressBar.isIndeterminate = false + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + } + }.launchIn(scope) + } + + onViewRecycled { + job?.cancel() + job = null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt new file mode 100644 index 000000000..a0c6c63dd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -0,0 +1,57 @@ +package org.koitharu.kotatsu.download.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.utils.bindServiceWithLifecycle + +class DownloadsActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val adapter = DownloadsAdapter(lifecycleScope, get()) + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.adapter = adapter + bindServiceWithLifecycle( + owner = this, + service = Intent(this, DownloadService::class.java), + flags = 0, + ).service.flatMapLatest { binder -> + (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null) + }.onEach { + adapter.items = it?.toList().orEmpty() + binding.textViewHolder.isVisible = it.isNullOrEmpty() + }.launchIn(lifecycleScope) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.recyclerView.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom + ) + binding.toolbar.updatePadding( + left = insets.left, + right = insets.right + ) + } + + companion object { + + fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt new file mode 100644 index 000000000..0c3629d1b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.download.ui + +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlinx.coroutines.CoroutineScope +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.utils.progress.ProgressJob + +class DownloadsAdapter( + scope: CoroutineScope, + coil: ImageLoader, +) : AsyncListDifferDelegationAdapter>(DiffCallback()) { + + init { + delegatesManager.addDelegate(downloadItemAD(scope, coil)) + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return items[position].progressValue.startId.toLong() + } + + private class DiffCallback : DiffUtil.ItemCallback>() { + + override fun areItemsTheSame( + oldItem: ProgressJob, + newItem: ProgressJob, + ): Boolean { + return oldItem.progressValue.startId == newItem.progressValue.startId + } + + override fun areContentsTheSame( + oldItem: ProgressJob, + newItem: ProgressJob, + ): Boolean { + return oldItem.progressValue == newItem.progressValue + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt new file mode 100644 index 000000000..a8f0744bd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -0,0 +1,171 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import android.text.format.DateUtils +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import com.google.android.material.R as materialR +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.format +import org.koitharu.kotatsu.utils.PendingIntentCompat +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class DownloadNotification(private val context: Context, startId: Int) { + + private val builder = NotificationCompat.Builder(context, CHANNEL_ID) + private val cancelAction = NotificationCompat.Action( + materialR.drawable.material_ic_clear_black_24dp, + context.getString(android.R.string.cancel), + PendingIntent.getBroadcast( + context, + startId, + DownloadService.getCancelIntent(startId), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + ) + private val listIntent = PendingIntent.getActivity( + context, + REQUEST_LIST, + DownloadsActivity.newIntent(context), + PendingIntentCompat.FLAG_IMMUTABLE, + ) + + init { + builder.setOnlyAlertOnce(true) + builder.setDefaults(0) + builder.color = ContextCompat.getColor(context, R.color.blue_primary) + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + builder.setSilent(true) + } + + fun create(state: DownloadState, timeLeft: Long): Notification { + builder.setContentTitle(state.manga.title) + builder.setContentText(context.getString(R.string.manga_downloading_)) + builder.setProgress(1, 0, true) + builder.setSmallIcon(android.R.drawable.stat_sys_download) + builder.setContentIntent(listIntent) + builder.setStyle(null) + builder.setLargeIcon(state.cover?.toBitmap()) + builder.clearActions() + builder.setVisibility( + if (state.manga.isNsfw) { + NotificationCompat.VISIBILITY_PRIVATE + } else { + NotificationCompat.VISIBILITY_PUBLIC + } + ) + when (state) { + is DownloadState.Cancelled -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.cancelling_)) + builder.setContentIntent(null) + builder.setStyle(null) + builder.setOngoing(true) + } + is DownloadState.Done -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.download_complete)) + builder.setContentIntent(createMangaIntent(context, state.localManga)) + builder.setAutoCancel(true) + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setCategory(null) + builder.setStyle(null) + builder.setOngoing(false) + } + is DownloadState.Error -> { + val message = state.error.getDisplayMessage(context.resources) + builder.setProgress(0, 0, false) + builder.setSmallIcon(android.R.drawable.stat_notify_error) + builder.setSubText(context.getString(R.string.error)) + builder.setContentText(message) + builder.setAutoCancel(true) + builder.setOngoing(false) + builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + } + is DownloadState.PostProcessing -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.processing_)) + builder.setStyle(null) + builder.setOngoing(true) + } + is DownloadState.Queued -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.queued)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + } + is DownloadState.Preparing -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.preparing_)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + } + is DownloadState.Progress -> { + builder.setProgress(state.max, state.progress, false) + if (timeLeft > 0L) { + val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) + builder.setContentText(eta) + } else { + val percent = (state.percent * 100).format() + builder.setContentText(context.getString(R.string.percent_string_pattern, percent)) + } + builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + } + is DownloadState.WaitingForNetwork -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.waiting_for_network)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + } + } + return builder.build() + } + + private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( + context, + manga.hashCode(), + DetailsActivity.newIntent(context, manga), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + + companion object { + + private const val CHANNEL_ID = "download" + private const val REQUEST_LIST = 6 + + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = NotificationManagerCompat.from(context) + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.downloads), + NotificationManager.IMPORTANCE_LOW + ) + channel.enableVibration(false) + channel.enableLights(false) + channel.setSound(null, null) + manager.createNotificationChannel(channel) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt new file mode 100644 index 000000000..91c742e73 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -0,0 +1,248 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Binder +import android.os.IBinder +import android.os.PowerManager +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.koin.android.ext.android.get +import org.koin.core.context.GlobalContext +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseService +import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.download.domain.DownloadState +import org.koitharu.kotatsu.download.domain.WakeLockNode +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.connectivityManager +import org.koitharu.kotatsu.utils.ext.throttle +import org.koitharu.kotatsu.utils.progress.ProgressJob +import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator +import java.util.concurrent.TimeUnit + +class DownloadService : BaseService() { + + private lateinit var downloadManager: DownloadManager + private lateinit var notificationSwitcher: ForegroundNotificationSwitcher + + private val jobs = LinkedHashMap>() + private val jobCount = MutableStateFlow(0) + private val controlReceiver = ControlReceiver() + private var binder: DownloadBinder? = null + + override fun onCreate() { + super.onCreate() + isRunning = true + notificationSwitcher = ForegroundNotificationSwitcher(this) + val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") + downloadManager = get().create( + coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), + ) + DownloadNotification.createChannel(this) + registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val manga = intent?.getParcelableExtra(EXTRA_MANGA)?.manga + val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS) + return if (manga != null) { + jobs[startId] = downloadManga(startId, manga, chapters) + jobCount.value = jobs.size + START_REDELIVER_INTENT + } else { + stopSelf(startId) + START_NOT_STICKY + } + } + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return binder ?: DownloadBinder(this).also { binder = it } + } + + override fun onUnbind(intent: Intent?): Boolean { + binder = null + return super.onUnbind(intent) + } + + override fun onDestroy() { + unregisterReceiver(controlReceiver) + binder = null + isRunning = false + super.onDestroy() + } + + private fun downloadManga( + startId: Int, + manga: Manga, + chaptersIds: LongArray?, + ): ProgressJob { + val job = downloadManager.downloadManga(manga, chaptersIds, startId) + listenJob(job) + return job + } + + private fun listenJob(job: ProgressJob) { + lifecycleScope.launch { + val startId = job.progressValue.startId + val notification = DownloadNotification(this@DownloadService, startId) + try { + val timeLeftEstimator = TimeLeftEstimator() + notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L)) + job.progressAsFlow() + .onEach { state -> + if (state is DownloadState.Progress) { + timeLeftEstimator.tick(value = state.progress, total = state.max) + } else { + timeLeftEstimator.emptyTick() + } + } + .throttle { state -> if (state is DownloadState.Progress) 400L else 0L } + .whileActive() + .collect { state -> + val timeLeft = timeLeftEstimator.getEstimatedTimeLeft() + notificationSwitcher.notify(startId, notification.create(state, timeLeft)) + } + job.join() + } finally { + (job.progressValue as? DownloadState.Done)?.let { + sendBroadcast( + Intent(ACTION_DOWNLOAD_COMPLETE) + .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)) + ) + } + notificationSwitcher.detach( + startId, + if (job.isCancelled) { + null + } else { + notification.create(job.progressValue, -1L) + } + ) + stopSelf(startId) + } + } + } + + private fun Flow.whileActive(): Flow = transformWhile { state -> + emit(state) + !state.isTerminal + } + + private val DownloadState.isTerminal: Boolean + get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled + + inner class ControlReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + when (intent?.action) { + ACTION_DOWNLOAD_CANCEL -> { + val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) + jobs.remove(cancelId)?.cancel() + jobCount.value = jobs.size + } + } + } + } + + class DownloadBinder(private val service: DownloadService) : Binder() { + + val downloads: Flow>> + get() = service.jobCount.mapLatest { service.jobs.values } + } + + companion object { + + var isRunning: Boolean = false + private set + + const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" + + private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" + + private const val EXTRA_MANGA = "manga" + private const val EXTRA_CHAPTERS_IDS = "chapters_ids" + private const val EXTRA_CANCEL_ID = "cancel_id" + + fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) { + if (chaptersIds?.isEmpty() == true) { + return + } + confirmDataTransfer(context) { + val intent = Intent(context, DownloadService::class.java) + intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) + if (chaptersIds != null) { + intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) + } + ContextCompat.startForegroundService(context, intent) + Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show() + } + } + + fun start(context: Context, manga: Collection) { + if (manga.isEmpty()) { + return + } + confirmDataTransfer(context) { + for (item in manga) { + val intent = Intent(context, DownloadService::class.java) + intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false)) + ContextCompat.startForegroundService(context, intent) + } + } + } + + fun confirmAndStart(context: Context, items: Set) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.save_manga) + .setMessage(R.string.batch_manga_save_confirm) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.save) { _, _ -> + start(context, items) + }.show() + } + + fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) + .putExtra(EXTRA_CANCEL_ID, startId) + + fun getDownloadedManga(intent: Intent?): Manga? { + if (intent?.action == ACTION_DOWNLOAD_COMPLETE) { + return intent.getParcelableExtra(EXTRA_MANGA)?.manga + } + return null + } + + private fun confirmDataTransfer(context: Context, callback: () -> Unit) { + val settings = GlobalContext.get().get() + if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { + CheckBoxAlertDialog.Builder(context) + .setTitle(R.string.warning) + .setMessage(R.string.network_consumption_warning) + .setCheckBoxText(R.string.dont_ask_again) + .setCheckBoxChecked(false) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string._continue) { _, doNotAsk -> + settings.isTrafficWarningEnabled = !doNotAsk + callback() + }.create() + .show() + } else { + callback() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt new file mode 100644 index 000000000..679405295 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt @@ -0,0 +1,62 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.app.Notification +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.SparseArray +import androidx.core.app.ServiceCompat +import androidx.core.util.isEmpty +import androidx.core.util.size + +private const val DEFAULT_DELAY = 500L + +class ForegroundNotificationSwitcher( + private val service: Service, +) { + + private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notifications = SparseArray() + private val handler = Handler(Looper.getMainLooper()) + + @Synchronized + fun notify(startId: Int, notification: Notification) { + if (notifications.isEmpty()) { + service.startForeground(startId, notification) + } else { + notificationManager.notify(startId, notification) + } + notifications[startId] = notification + } + + @Synchronized + fun detach(startId: Int, notification: Notification?) { + notifications.remove(startId) + if (notifications.isEmpty()) { + ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH) + } + val nextIndex = notifications.size - 1 + if (nextIndex >= 0) { + val nextStartId = notifications.keyAt(nextIndex) + val nextNotification = notifications.valueAt(nextIndex) + service.startForeground(nextStartId, nextNotification) + } + handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY) + } + + private inner class NotifyRunnable( + private val startId: Int, + private val notification: Notification?, + ) : Runnable { + + override fun run() { + if (notification != null) { + notificationManager.notify(startId, notification) + } else { + notificationManager.cancel(startId) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt new file mode 100644 index 000000000..f3bc159a8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.favourites + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditViewModel +import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel +import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel + +val favouritesModule + get() = module { + + single { FavouritesRepository(get(), get()) } + + viewModel { categoryId -> + FavouritesListViewModel(categoryId.get(), get(), get(), get(), get()) + } + viewModel { FavouritesCategoriesViewModel(get(), get()) } + viewModel { manga -> + MangaCategoriesViewModel(manga.get(), get()) + } + viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt new file mode 100644 index 000000000..c6a65c78e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.favourites.data + +import java.util.* +import org.koitharu.kotatsu.core.db.entity.SortOrder +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.parsers.model.SortOrder + +fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( + id = id, + title = title, + sortKey = sortKey, + order = SortOrder(order, SortOrder.NEWEST), + createdAt = Date(createdAt), + isTrackingEnabled = track, +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt index 9cac803fc..148dfd820 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt @@ -1,37 +1,37 @@ package org.koitharu.kotatsu.favourites.data -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Upsert +import androidx.room.* import kotlinx.coroutines.flow.Flow @Dao abstract class FavouriteCategoriesDao { - @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0") + @Query("SELECT * FROM favourite_categories WHERE category_id = :id") abstract suspend fun find(id: Int): FavouriteCategoryEntity - @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key") + @Query("SELECT * FROM favourite_categories ORDER BY sort_key") abstract suspend fun findAll(): List - @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key") + @Query("SELECT * FROM favourite_categories ORDER BY sort_key") abstract fun observeAll(): Flow> - @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key") - abstract fun observeAllForLibrary(): Flow> - - @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0") + @Query("SELECT * FROM favourite_categories WHERE category_id = :id") abstract fun observe(id: Long): Flow @Insert(onConflict = OnConflictStrategy.ABORT) abstract suspend fun insert(category: FavouriteCategoryEntity): Long - suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis()) + @Update + abstract suspend fun update(category: FavouriteCategoryEntity): Int + + @Query("DELETE FROM favourite_categories WHERE category_id = :id") + abstract suspend fun delete(id: Long) - @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id") - abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean) + @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") + abstract suspend fun updateTitle(id: Long, title: String) + + @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id") + abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean) @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") abstract suspend fun updateOrder(id: Long, order: String) @@ -39,25 +39,20 @@ abstract class FavouriteCategoriesDao { @Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id") abstract suspend fun updateTracking(id: Long, isEnabled: Boolean) - @Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id") - abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean) - @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") abstract suspend fun updateSortKey(id: Long, sortKey: Int) - @Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") - abstract suspend fun gc(maxDeletionTime: Long) - - @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0") + @Query("SELECT MAX(sort_key) FROM favourite_categories") protected abstract suspend fun getMaxSortKey(): Int? suspend fun getNextSortKey(): Int { return (getMaxSortKey() ?: 0) + 1 } - @Upsert - abstract suspend fun upsert(entity: FavouriteCategoryEntity) - - @Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id") - protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long) -} + @Transaction + open suspend fun upsert(entity: FavouriteCategoryEntity) { + if (update(entity) == 0) { + insert(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt new file mode 100644 index 000000000..4f4f594e9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.favourites.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES + +@Entity(tableName = TABLE_FAVOURITE_CATEGORIES) +data class FavouriteCategoryEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "category_id") val categoryId: Int, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "sort_key") val sortKey: Int, + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "order") val order: String, + @ColumnInfo(name = "track") val track: Boolean, +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt index 87f8afa55..860465814 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt @@ -27,7 +27,5 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity data class FavouriteEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long, - @ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "created_at") val createdAt: Long, - @ColumnInfo(name = "deleted_at") val deletedAt: Long, -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt new file mode 100644 index 000000000..89fcc92fb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.favourites.data + +import androidx.room.* +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.flow.Flow +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.parsers.model.SortOrder + +@Dao +abstract class FavouritesDao { + + @Transaction + @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC") + abstract suspend fun findAll(): List + + fun observeAll(order: SortOrder): Flow> { + val orderBy = getOrderBy(order) + val query = SimpleSQLiteQuery( + "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy", + ) + return observeAllRaw(query) + } + + @Transaction + @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + abstract suspend fun findAll(offset: Int, limit: Int): List + + @Transaction + @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC") + abstract suspend fun findAll(categoryId: Long): List + + fun observeAll(categoryId: Long, order: SortOrder): Flow> { + val orderBy = getOrderBy(order) + val query = SimpleSQLiteQuery( + "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy", + arrayOf(categoryId), + ) + return observeAllRaw(query) + } + + @Transaction + @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List + + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)") + abstract suspend fun findAllManga(categoryId: Int): List + + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)") + abstract suspend fun findAllManga(): List + + @Transaction + @Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id") + abstract suspend fun find(id: Long): FavouriteManga? + + @Transaction + @Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id") + abstract fun observe(id: Long): Flow + + @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id") + abstract fun observeIds(id: Long): Flow> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(favourite: FavouriteEntity) + + @Update + abstract suspend fun update(favourite: FavouriteEntity): Int + + @Query("DELETE FROM favourites WHERE manga_id = :mangaId") + abstract suspend fun delete(mangaId: Long) + + @Query("DELETE FROM favourites WHERE manga_id = :mangaId AND category_id = :categoryId") + abstract suspend fun delete(categoryId: Long, mangaId: Long) + + @Transaction + open suspend fun upsert(entity: FavouriteEntity) { + if (update(entity) == 0) { + insert(entity) + } + } + + @Transaction + @RawQuery(observedEntities = [FavouriteEntity::class]) + protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow> + + private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) { + SortOrder.RATING -> "rating DESC" + SortOrder.NEWEST, + SortOrder.UPDATED -> "created_at DESC" + SortOrder.ALPHABETICAL -> "title ASC" + else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt new file mode 100644 index 000000000..13a4c92c4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -0,0 +1,166 @@ +package org.koitharu.kotatsu.favourites.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity +import org.koitharu.kotatsu.favourites.data.FavouriteEntity +import org.koitharu.kotatsu.favourites.data.toFavouriteCategory +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels +import org.koitharu.kotatsu.utils.ext.mapItems + +class FavouritesRepository( + private val db: MangaDatabase, + private val channels: TrackerNotificationChannels, +) { + + suspend fun getAllManga(): List { + val entities = db.favouritesDao.findAll() + return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + } + + fun observeAll(order: SortOrder): Flow> { + return db.favouritesDao.observeAll(order) + .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + } + + suspend fun getManga(categoryId: Long): List { + val entities = db.favouritesDao.findAll(categoryId) + return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + } + + fun observeAll(categoryId: Long, order: SortOrder): Flow> { + return db.favouritesDao.observeAll(categoryId, order) + .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + } + + fun observeAll(categoryId: Long): Flow> { + return observeOrder(categoryId) + .flatMapLatest { order -> observeAll(categoryId, order) } + } + + fun observeCategories(): Flow> { + return db.favouriteCategoriesDao.observeAll().mapItems { + it.toFavouriteCategory() + }.distinctUntilChanged() + } + + fun observeCategory(id: Long): Flow { + return db.favouriteCategoriesDao.observe(id) + .map { it?.toFavouriteCategory() } + } + + fun observeCategories(mangaId: Long): Flow> { + return db.favouritesDao.observe(mangaId).map { entity -> + entity?.categories?.map { it.toFavouriteCategory() }.orEmpty() + }.distinctUntilChanged() + } + + fun observeCategoriesIds(mangaId: Long): Flow> { + return db.favouritesDao.observeIds(mangaId).map { it.toSet() } + } + + suspend fun getCategory(id: Long): FavouriteCategory { + return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory() + } + + suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory { + val entity = FavouriteCategoryEntity( + title = title, + createdAt = System.currentTimeMillis(), + sortKey = db.favouriteCategoriesDao.getNextSortKey(), + categoryId = 0, + order = sortOrder.name, + track = isTrackerEnabled, + ) + val id = db.favouriteCategoriesDao.insert(entity) + val category = entity.toFavouriteCategory(id) + channels.createChannel(category) + return category + } + + suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) { + db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled) + } + + suspend fun addCategory(title: String): FavouriteCategory { + val entity = FavouriteCategoryEntity( + title = title, + createdAt = System.currentTimeMillis(), + sortKey = db.favouriteCategoriesDao.getNextSortKey(), + categoryId = 0, + order = SortOrder.NEWEST.name, + track = true, + ) + val id = db.favouriteCategoriesDao.insert(entity) + val category = entity.toFavouriteCategory(id) + channels.createChannel(category) + return category + } + + suspend fun renameCategory(id: Long, title: String) { + db.favouriteCategoriesDao.updateTitle(id, title) + channels.renameChannel(id, title) + } + + suspend fun removeCategory(id: Long) { + db.favouriteCategoriesDao.delete(id) + channels.deleteChannel(id) + } + + suspend fun setCategoryOrder(id: Long, order: SortOrder) { + db.favouriteCategoriesDao.updateOrder(id, order.name) + } + + suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) { + db.favouriteCategoriesDao.updateTracking(id, isEnabled) + } + + suspend fun reorderCategories(orderedIds: List) { + val dao = db.favouriteCategoriesDao + db.withTransaction { + for ((i, id) in orderedIds.withIndex()) { + dao.updateSortKey(id, i) + } + } + } + + suspend fun addToCategory(categoryId: Long, mangas: Collection) { + db.withTransaction { + for (manga in mangas) { + val tags = manga.tags.toEntities() + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga.toEntity(), tags) + val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis()) + db.favouritesDao.insert(entity) + } + } + } + + suspend fun removeFromFavourites(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.delete(id) + } + } + } + + suspend fun removeFromCategory(categoryId: Long, ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.delete(categoryId, id) + } + } + } + + private fun observeOrder(categoryId: Long): Flow { + return db.favouriteCategoriesDao.observe(categoryId) + .filterNotNull() + .map { x -> SortOrder(x.order, SortOrder.NEWEST) } + .distinctUntilChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt new file mode 100644 index 000000000..bd12423c0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -0,0 +1,193 @@ +package org.koitharu.kotatsu.favourites.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.PopupMenu +import androidx.core.graphics.Insets +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.util.ActionModeListener +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding +import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate +import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity +import org.koitharu.kotatsu.main.ui.AppBarOwner +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight +import org.koitharu.kotatsu.utils.ext.resolveDp + +class FavouritesContainerFragment : + BaseFragment(), + FavouritesTabLongClickListener, + CategoriesEditDelegate.CategoriesEditCallback, + ActionModeListener, + View.OnClickListener { + + private val viewModel by viewModel() + private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { + CategoriesEditDelegate(requireContext(), this) + } + private var pagerAdapter: FavouritesPagerAdapter? = null + private var stubBinding: ItemEmptyStateBinding? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentFavouritesBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = FavouritesPagerAdapter(this, this) + viewModel.visibleCategories.value?.let(::onCategoriesChanged) + binding.pager.adapter = adapter + pagerAdapter = adapter + TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() + actionModeDelegate.addListener(this, viewLifecycleOwner) + addMenuProvider(FavouritesContainerMenuProvider(view.context)) + + viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged) + viewModel.onError.observe(viewLifecycleOwner, ::onError) + } + + override fun onDestroyView() { + pagerAdapter = null + stubBinding = null + super.onDestroyView() + } + + override fun onActionModeStarted(mode: ActionMode) { + binding.pager.isUserInputEnabled = false + binding.tabs.setTabsEnabled(false) + } + + override fun onActionModeFinished(mode: ActionMode) { + binding.pager.isUserInputEnabled = true + binding.tabs.setTabsEnabled(true) + } + + override fun onWindowInsetsChanged(insets: Insets) { + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top + binding.root.updatePadding( + top = headerHeight - insets.top + ) + binding.pager.updatePadding( + // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active) + top = -headerHeight + resources.resolveDp(8) + ) + binding.tabs.apply { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + } + + private fun onCategoriesChanged(categories: List) { + pagerAdapter?.replaceData(categories) + if (categories.isEmpty()) { + binding.pager.isVisible = false + binding.tabs.isVisible = false + showStub() + } else { + binding.pager.isVisible = true + binding.tabs.isVisible = true + (stubBinding?.root ?: binding.stubEmptyState).isVisible = false + } + } + + private fun onError(e: Throwable) { + Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + + override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean { + when (item) { + is CategoryListModel.All -> showAllCategoriesMenu(tabView) + is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category) + } + return true + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_retry -> startActivity(FavouritesCategoryEditActivity.newIntent(v.context)) + } + } + + override fun onDeleteCategory(category: FavouriteCategory) { + viewModel.deleteCategory(category.id) + } + + private fun TabLayout.setTabsEnabled(enabled: Boolean) { + val tabStrip = getChildAt(0) as? ViewGroup ?: return + for (tab in tabStrip.children) { + tab.isEnabled = enabled + } + } + + private fun showCategoryMenu(tabView: View, category: FavouriteCategory) { + val menu = PopupMenu(tabView.context, tabView) + menu.inflate(R.menu.popup_category) + menu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_remove -> editDelegate.deleteCategory(category) + R.id.action_edit -> startActivity( + FavouritesCategoryEditActivity.newIntent( + tabView.context, + category.id + ) + ) + else -> return@setOnMenuItemClickListener false + } + true + } + menu.show() + } + + private fun showAllCategoriesMenu(tabView: View) { + val menu = PopupMenu(tabView.context, tabView) + menu.inflate(R.menu.popup_category_all) + menu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) + R.id.action_hide -> viewModel.setAllCategoriesVisible(false) + } + true + } + menu.show() + } + + private fun showStub() { + val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate()) + stub.root.isVisible = true + stub.icon.setImageResource(R.drawable.ic_empty_favourites) + stub.textPrimary.setText(R.string.text_empty_holder_primary) + stub.textSecondary.setText(R.string.empty_favourite_categories) + stub.buttonRetry.setText(R.string.add) + stub.buttonRetry.isVisible = true + stub.buttonRetry.setOnClickListener(this) + stubBinding = stub + } + + companion object { + + fun newInstance() = FavouritesContainerFragment() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt similarity index 59% rename from app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt index 86211b090..1b07f535d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.explore.ui +package org.koitharu.kotatsu.favourites.ui import android.content.Context import android.view.Menu @@ -6,24 +6,23 @@ 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.favourites.ui.categories.CategoriesActivity -class ExploreMenuProvider( +class FavouritesContainerMenuProvider( private val context: Context, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_explore, menu) + menuInflater.inflate(R.menu.opt_favourites, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { - R.id.action_manage -> { - context.startActivity(SettingsActivity.newSourcesSettingsIntent(context)) + R.id.action_categories -> { + context.startActivity(CategoriesActivity.newIntent(context)) true } - else -> false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt new file mode 100644 index 000000000..329e06751 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt @@ -0,0 +1,76 @@ +package org.koitharu.kotatsu.favourites.ui + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment + +class FavouritesPagerAdapter( + fragment: Fragment, + private val longClickListener: FavouritesTabLongClickListener +) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle), + TabLayoutMediator.TabConfigurationStrategy, + View.OnLongClickListener { + + private val differ = AsyncListDiffer(this, DiffCallback()) + + override fun getItemCount() = differ.currentList.size + + override fun createFragment(position: Int): Fragment { + val item = differ.currentList[position] + return FavouritesListFragment.newInstance(item.id) + } + + override fun getItemId(position: Int): Long { + return differ.currentList[position].id + } + + override fun containsItem(itemId: Long): Boolean { + return differ.currentList.any { it.id == itemId } + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + val item = differ.currentList[position] + tab.text = when (item) { + is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites) + is CategoryListModel.CategoryItem -> item.category.title + } + tab.view.tag = item.id + tab.view.setOnLongClickListener(this) + } + + fun replaceData(data: List) { + differ.submitList(data) + } + + override fun onLongClick(v: View): Boolean { + val itemId = v.tag as? Long ?: return false + val item = differ.currentList.find { x -> x.id == itemId } ?: return false + return longClickListener.onTabLongClick(v, item) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: CategoryListModel, + newItem: CategoryListModel + ): Boolean = when { + oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true + oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> { + oldItem.category.id == newItem.category.id + } + else -> false + } + + override fun areContentsTheSame( + oldItem: CategoryListModel, + newItem: CategoryListModel + ): Boolean = oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt new file mode 100644 index 000000000..13fca87c9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.favourites.ui + +import android.view.View +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel + +fun interface FavouritesTabLongClickListener { + + fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt new file mode 100644 index 000000000..380722b84 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.favourites.ui.categories + +interface AllCategoriesToggleListener { + + fun onAllCategoriesToggle(isVisible: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt new file mode 100644 index 000000000..80fd2a137 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt @@ -0,0 +1,157 @@ +package org.koitharu.kotatsu.favourites.ui.categories + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight + +class CategoriesActivity : + BaseActivity(), + OnListItemClickListener, + View.OnClickListener, + CategoriesEditDelegate.CategoriesEditCallback, + AllCategoriesToggleListener { + + private val viewModel by viewModel() + + private lateinit var adapter: CategoriesAdapter + private lateinit var reorderHelper: ItemTouchHelper + private lateinit var editDelegate: CategoriesEditDelegate + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + adapter = CategoriesAdapter(this, this) + editDelegate = CategoriesEditDelegate(this, this) + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.adapter = adapter + binding.fabAdd.setOnClickListener(this) + reorderHelper = ItemTouchHelper(ReorderHelperCallback()) + reorderHelper.attachToRecyclerView(binding.recyclerView) + + viewModel.allCategories.observe(this, ::onCategoriesChanged) + viewModel.onError.observe(this, ::onError) + } + + override fun onClick(v: View) { + when (v.id) { + R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this)) + } + } + + override fun onItemClick(item: FavouriteCategory, view: View) { + val menu = PopupMenu(view.context, view) + menu.inflate(R.menu.popup_category) + menu.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_remove -> editDelegate.deleteCategory(item) + R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id)) + } + true + } + menu.show() + } + + override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { + val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false + reorderHelper.startDrag(viewHolder) + return true + } + + override fun onAllCategoriesToggle(isVisible: Boolean) { + viewModel.setAllCategoriesVisible(isVisible) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.fabAdd.updateLayoutParams { + rightMargin = topMargin + insets.right + leftMargin = topMargin + insets.left + bottomMargin = topMargin + insets.bottom + } + binding.recyclerView.updatePadding( + left = insets.left, + right = insets.right, + bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(), + ) + } + + private fun onCategoriesChanged(categories: List) { + adapter.items = categories + binding.textViewHolder.isVisible = categories.isEmpty() + } + + private fun onError(e: Throwable) { + Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) + .show() + } + + override fun onDeleteCategory(category: FavouriteCategory) { + viewModel.deleteCategory(category.id) + } + + private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 + ) { + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = viewHolder.itemViewType == target.itemViewType + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = current.itemViewType == target.itemViewType + + override fun onMoved( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + fromPos: Int, + target: RecyclerView.ViewHolder, + toPos: Int, + x: Int, + y: Int, + ) { + super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) + viewModel.reorderCategories(fromPos, toPos) + } + + override fun isLongPressDragEnabled(): Boolean = false + } + + companion object { + + val SORT_ORDERS = arrayOf( + SortOrder.ALPHABETICAL, + SortOrder.NEWEST, + SortOrder.RATING, + ) + + fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt new file mode 100644 index 000000000..7a5620158 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.favourites.ui.categories + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD +import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD + +class CategoriesAdapter( + onItemClickListener: OnListItemClickListener, + allCategoriesToggleListener: AllCategoriesToggleListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(categoryAD(onItemClickListener)) + .addDelegate(allCategoriesAD(allCategoriesToggleListener)) + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return items[position].id + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: CategoryListModel, + newItem: CategoryListModel, + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: CategoryListModel, + newItem: CategoryListModel, + ): Boolean = oldItem == newItem + + override fun getChangePayload( + oldItem: CategoryListModel, + newItem: CategoryListModel, + ): Any? = when { + oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit + oldItem is CategoryListModel.CategoryItem && + newItem is CategoryListModel.CategoryItem && + oldItem.category.title != newItem.category.title -> null + else -> Unit + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt new file mode 100644 index 000000000..cece6607f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.favourites.ui.categories + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.FavouriteCategory +import com.google.android.material.R as materialR + +class CategoriesEditDelegate( + private val context: Context, + private val callback: CategoriesEditCallback +) { + + fun deleteCategory(category: FavouriteCategory) { + MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setMessage(context.getString(R.string.category_delete_confirm, category.title)) + .setTitle(R.string.remove_category) + .setIcon(R.drawable.ic_delete) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.remove) { _, _ -> + callback.onDeleteCategory(category) + }.create() + .show() + } + + interface CategoriesEditCallback { + + fun onDeleteCategory(category: FavouriteCategory) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt new file mode 100644 index 000000000..1e24d033f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.favourites.ui.categories + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import java.util.* + +class FavouritesCategoriesViewModel( + private val repository: FavouritesRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + private var reorderJob: Job? = null + + val allCategories = combine( + repository.observeCategories(), + observeAllCategoriesVisible(), + ) { list, showAll -> + mapCategories(list, showAll, true) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + val visibleCategories = combine( + repository.observeCategories(), + observeAllCategoriesVisible(), + ) { list, showAll -> + mapCategories(list, showAll, showAll && list.isNotEmpty()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + fun deleteCategory(id: Long) { + launchJob { + repository.removeCategory(id) + } + } + + fun setAllCategoriesVisible(isVisible: Boolean) { + settings.isAllFavouritesVisible = isVisible + } + + fun reorderCategories(oldPos: Int, newPos: Int) { + val prevJob = reorderJob + reorderJob = launchJob(Dispatchers.Default) { + prevJob?.join() + val items = allCategories.value ?: error("This should not happen") + val ids = items.mapTo(ArrayList(items.size)) { it.id } + Collections.swap(ids, oldPos, newPos) + ids.remove(0L) + repository.reorderCategories(ids) + } + } + + private fun mapCategories( + categories: List, + isAllCategoriesVisible: Boolean, + withAllCategoriesItem: Boolean, + ): List { + val result = ArrayList(categories.size + 1) + if (withAllCategoriesItem) { + result.add(CategoryListModel.All(isAllCategoriesVisible)) + } + categories.mapTo(result) { + CategoryListModel.CategoryItem(it) + } + return result + } + + private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) { + isAllFavouritesVisible + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt new file mode 100644 index 000000000..113198bfa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.favourites.ui.categories.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding +import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener + +fun allCategoriesAD( + allCategoriesToggleListener: AllCategoriesToggleListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) } +) { + + binding.imageViewToggle.setOnClickListener { + allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible) + } + + bind { + binding.imageViewToggle.isChecked = item.isVisible + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt new file mode 100644 index 000000000..e64e36e5a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.favourites.ui.categories.adapter + +import android.view.MotionEvent +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.databinding.ItemCategoryBinding + +fun categoryAD( + clickListener: OnListItemClickListener +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) } +) { + + binding.imageViewMore.setOnClickListener { + clickListener.onItemClick(item.category, it) + } + @Suppress("ClickableViewAccessibility") + binding.imageViewHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + clickListener.onItemLongClick(item.category, itemView) + } else { + false + } + } + + bind { + binding.textViewTitle.text = item.category.title + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt new file mode 100644 index 000000000..899b73e1c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.favourites.ui.categories.adapter + +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.list.ui.model.ListModel + +sealed interface CategoryListModel : ListModel { + + val id: Long + + class All( + val isVisible: Boolean, + ) : CategoryListModel { + + override val id: Long = 0L + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as All + + if (isVisible != other.isVisible) return false + + return true + } + + override fun hashCode(): Int { + return isVisible.hashCode() + } + } + + class CategoryItem( + val category: FavouriteCategory, + ) : CategoryListModel { + + override val id: Long + get() = category.id + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CategoryItem + + if (category.id != other.category.id) return false + if (category.title != other.category.title) return false + if (category.order != other.category.order) return false + if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false + + return true + } + + override fun hashCode(): Int { + var result = category.id.hashCode() + result = 31 * result + category.title.hashCode() + result = 31 * result + category.order.hashCode() + result = 31 * result + category.isTrackingEnabled.hashCode() + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt new file mode 100644 index 000000000..960a92169 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt @@ -0,0 +1,167 @@ +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.text.TextWatcher +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Filter +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener, + View.OnClickListener, TextWatcher { + + private val viewModel by viewModel { + parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID)) + } + private var selectedSortOrder: SortOrder? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityCategoryEditBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material) + } + initSortSpinner() + binding.buttonDone.setOnClickListener(this) + binding.editName.addTextChangedListener(this) + afterTextChanged(binding.editName.text) + + viewModel.onSaved.observe(this) { finishAfterTransition() } + viewModel.category.observe(this, ::onCategoryChanged) + viewModel.isLoading.observe(this, ::onLoadingStateChanged) + viewModel.onError.observe(this, ::onError) + viewModel.isTrackerEnabled.observe(this) { + binding.switchTracker.isVisible = it + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val order = savedInstanceState.getSerializable(KEY_SORT_ORDER) + if (order != null && order is SortOrder) { + selectedSortOrder = order + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.save( + title = binding.editName.text?.toString()?.trim().orEmpty(), + sortOrder = getSelectedSortOrder(), + isTrackerEnabled = binding.switchTracker.isChecked, + ) + } + } + + 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?) { + binding.buttonDone.isEnabled = !s.isNullOrBlank() + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.scrollView.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) + binding.toolbar.updateLayoutParams { + topMargin = insets.top + } + } + + override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position) + } + + private fun onCategoryChanged(category: FavouriteCategory?) { + setTitle(if (category == null) R.string.create_category else R.string.edit_category) + if (selectedSortOrder != null) { + return + } + binding.editName.setText(category?.title) + selectedSortOrder = category?.order + val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes) + binding.editSort.setText(sortText, false) + binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true + } + + private fun onError(e: Throwable) { + binding.textViewError.text = e.getDisplayMessage(resources) + binding.textViewError.isVisible = true + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + binding.editSort.isEnabled = !isLoading + binding.editName.isEnabled = !isLoading + binding.switchTracker.isEnabled = !isLoading + if (isLoading) { + binding.textViewError.isVisible = false + } + } + + private fun initSortSpinner() { + val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } + val adapter = SortAdapter(this, entries) + binding.editSort.setAdapter(adapter) + binding.editSort.onItemClickListener = this + } + + private fun getSelectedSortOrder(): SortOrder { + selectedSortOrder?.let { return it } + val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) } + val index = entries.indexOf(binding.editSort.text.toString()) + return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST + } + + private class SortAdapter( + context: Context, + entries: List, + ) : ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries) { + + override fun getFilter(): Filter = EmptyFilter + + private object EmptyFilter : Filter() { + override fun performFiltering(constraint: CharSequence?) = FilterResults() + override fun publishResults(constraint: CharSequence?, results: FilterResults?) = Unit + } + } + + companion object { + + private const val EXTRA_ID = "id" + private const val KEY_SORT_ORDER = "sort" + private const val NO_ID = -1L + + fun newIntent(context: Context, id: Long = NO_ID): Intent { + return Intent(context, FavouritesCategoryEditActivity::class.java) + .putExtra(EXTRA_ID, id) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt new file mode 100644 index 000000000..9f67964bd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt @@ -0,0 +1,54 @@ +package org.koitharu.kotatsu.favourites.ui.categories.edit + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.utils.SingleLiveEvent + +private const val NO_ID = -1L + +class FavouritesCategoryEditViewModel( + private val categoryId: Long, + private val repository: FavouritesRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val onSaved = SingleLiveEvent() + val category = MutableLiveData() + + val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) { + emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) + } + + init { + launchLoadingJob { + category.value = if (categoryId != NO_ID) { + repository.getCategory(categoryId) + } else { + null + } + } + } + + fun save( + title: String, + sortOrder: SortOrder, + isTrackerEnabled: Boolean, + ) { + launchLoadingJob { + check(title.isNotEmpty()) + if (categoryId == NO_ID) { + repository.createCategory(title, sortOrder, isTrackerEnabled) + } else { + repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled) + } + onSaved.call(Unit) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt new file mode 100644 index 000000000..c2ca49d6b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt @@ -0,0 +1,103 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.FragmentManager +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity +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 org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.withArgs + +class FavouriteCategoriesBottomSheet : + BaseBottomSheet(), + OnListItemClickListener, + CategoriesEditDelegate.CategoriesEditCallback, + View.OnClickListener, + Toolbar.OnMenuItemClickListener { + + private val viewModel by viewModel { + parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga }) + } + + private var adapter: MangaCategoriesAdapter? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup?, + ) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + adapter = MangaCategoriesAdapter(this) + binding.recyclerViewCategories.adapter = adapter + binding.buttonDone.setOnClickListener(this) + binding.toolbar.setOnMenuItemClickListener(this) + + viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) + viewModel.onError.observe(viewLifecycleOwner, ::onError) + } + + override fun onDestroyView() { + adapter = null + super.onDestroyView() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> dismiss() + } + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext())) + else -> return false + } + return true + } + + override fun onItemClick(item: MangaCategoryItem, view: View) { + viewModel.setChecked(item.id, !item.isChecked) + } + + override fun onDeleteCategory(category: FavouriteCategory) = Unit + + private fun onContentChanged(categories: List) { + adapter?.items = categories + } + + private fun onError(e: Throwable) { + Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show() + } + + companion object { + + private const val TAG = "FavouriteCategoriesDialog" + private const val KEY_MANGA_LIST = "manga_list" + + fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) + + fun show(fm: FragmentManager, manga: Collection) = FavouriteCategoriesBottomSheet().withArgs(1) { + putParcelableArrayList( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) } + ) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt new file mode 100644 index 000000000..b9f906549 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.ids +import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct + +class MangaCategoriesViewModel( + private val manga: List, + private val favouritesRepository: FavouritesRepository +) : BaseViewModel() { + + val content = combine( + favouritesRepository.observeCategories(), + observeCategoriesIds(), + ) { all, checked -> + all.map { + MangaCategoryItem( + id = it.id, + name = it.title, + isChecked = it.id in checked + ) + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + fun setChecked(categoryId: Long, isChecked: Boolean) { + launchJob(Dispatchers.Default) { + if (isChecked) { + favouritesRepository.addToCategory(categoryId, manga) + } else { + favouritesRepository.removeFromCategory(categoryId, manga.ids()) + } + } + } + + private fun observeCategoriesIds() = if (manga.size == 1) { + // Fast path + favouritesRepository.observeCategoriesIds(manga[0].id) + } else { + combine( + manga.map { favouritesRepository.observeCategoriesIds(it.id) } + ) { array -> + val result = HashSet() + var isFirst = true + for (ids in array) { + if (isFirst) { + result.addAll(ids) + isFirst = false + } else { + result.retainAll(ids.toSet()) + } + } + result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt new file mode 100644 index 000000000..df6e54ca0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem + +class MangaCategoriesAdapter( + clickListener: OnListItemClickListener +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(mangaCategoryAD(clickListener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: MangaCategoryItem, + newItem: MangaCategoryItem + ): Boolean = oldItem.id == newItem.id + + override fun areContentsTheSame( + oldItem: MangaCategoryItem, + newItem: MangaCategoryItem + ): Boolean = oldItem == newItem + + override fun getChangePayload( + oldItem: MangaCategoryItem, + newItem: MangaCategoryItem + ): Any? { + if (oldItem.isChecked != newItem.isChecked) { + return newItem.isChecked + } + return super.getChangePayload(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt new file mode 100644 index 000000000..c9ce1e8b2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding +import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem + +fun mangaCategoryAD( + clickListener: OnListItemClickListener +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) } +) { + + itemView.setOnClickListener { + clickListener.onItemClick(item, itemView) + } + + bind { + with(binding.root) { + text = item.name + isChecked = item.isChecked + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt new file mode 100644 index 000000000..95447a2af --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select.model + +data class MangaCategoryItem( + val id: Long, + val name: String, + val isChecked: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt new file mode 100644 index 000000000..4d55137a2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -0,0 +1,70 @@ +package org.koitharu.kotatsu.favourites.ui.list + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.view.ActionMode +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.withArgs + +class FavouritesListFragment : MangaListFragment() { + + override val viewModel by viewModel { + parametersOf(categoryId) + } + + private val categoryId: Long + get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID + + override val isSwipeRefreshEnabled = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } + + if (categoryId != NO_ID) { + addMenuProvider(FavouritesListMenuProvider(viewModel)) + } + } + + override fun onScrolledToEnd() = Unit + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_favourites, menu) + return super.onCreateActionMode(mode, menu) + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { + it.source == MangaSource.LOCAL + } + return super.onPrepareActionMode(mode, menu) + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_remove -> { + viewModel.removeFromFavourites(selectedItemsIds) + mode.finish() + true + } + else -> super.onActionItemClicked(mode, item) + } + } + + companion object { + + const val NO_ID = 0L + private const val ARG_CATEGORY_ID = "category_id" + + fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { + putLong(ARG_CATEGORY_ID, categoryId) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt new file mode 100644 index 000000000..2a1b08876 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.favourites.ui.list + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import androidx.core.view.iterator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity + +class FavouritesListMenuProvider( + private val viewModel: FavouritesListViewModel, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_favourites_list, menu) + menu.findItem(R.id.action_order)?.subMenu?.let { submenu -> + for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { + val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes) + menuItem.isCheckable = true + } + submenu.setGroupCheckable(R.id.group_order, true, true) + } + } + + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.action_order)?.subMenu?.let { submenu -> + val selectedOrder = viewModel.sortOrder.value + for (item in submenu) { + val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) + item.isChecked = order == selectedOrder + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when { + menuItem.itemId == R.id.action_order -> false + menuItem.groupId == R.id.group_order -> { + val order = CategoriesActivity.SORT_ORDERS.getOrNull(menuItem.order) ?: return false + viewModel.setSortOrder(order) + true + } + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index e4e305060..44f89c767 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -1,64 +1,51 @@ package org.koitharu.kotatsu.favourites.ui.list -import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi -import javax.inject.Inject +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -@HiltViewModel -class FavouritesListViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +class FavouritesListViewModel( + private val categoryId: Long, private val repository: FavouritesRepository, - private val listExtraProvider: ListExtraProvider, - settings: AppSettings, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { + private val trackingRepository: TrackingRepository, + private val historyRepository: HistoryRepository, + private val settings: AppSettings, +) : MangaListViewModel(settings), ListExtraProvider { - val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID - - override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode) - - val sortOrder: StateFlow = if (categoryId == NO_ID) { - MutableStateFlow(null) + var sortOrder: LiveData = if (categoryId == NO_ID) { + MutableLiveData(null) } else { repository.observeCategory(categoryId) .map { it?.order } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) } override val content = combine( if (categoryId == NO_ID) { - repository.observeAll(ListSortOrder.NEWEST) + repository.observeAll(SortOrder.NEWEST) } else { repository.observeAll(categoryId) }, - listMode, + createListModeFlow() ) { list, mode -> when { list.isEmpty() -> listOf( @@ -71,14 +58,13 @@ class FavouritesListViewModel @Inject constructor( R.string.favourites_category_empty }, actionStringRes = 0, - ), + ) ) - - else -> list.toUi(mode, listExtraProvider) + else -> list.toUi(mode, this) } }.catch { emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) override fun onRefresh() = Unit @@ -88,17 +74,16 @@ class FavouritesListViewModel @Inject constructor( if (ids.isEmpty()) { return } - launchJob(Dispatchers.Default) { - val handle = if (categoryId == NO_ID) { + launchJob { + if (categoryId == NO_ID) { repository.removeFromFavourites(ids) } else { repository.removeFromCategory(categoryId, ids) } - onActionDone.call(ReversibleAction(R.string.removed_from_favourites, handle)) } } - fun setSortOrder(order: ListSortOrder) { + fun setSortOrder(order: SortOrder) { if (categoryId == NO_ID) { return } @@ -106,4 +91,16 @@ class FavouritesListViewModel @Inject constructor( repository.setCategoryOrder(categoryId, order) } } -} + + override suspend fun getCounter(mangaId: Long): Int { + return trackingRepository.getNewChaptersCount(mangaId) + } + + override suspend fun getProgress(mangaId: Long): Float { + return if (settings.isReadingIndicatorsEnabled) { + historyRepository.getProgress(mangaId) + } else { + PROGRESS_NONE + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt new file mode 100644 index 000000000..029d024f0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.history + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.ui.HistoryListViewModel + +val historyModule + get() = module { + + single { HistoryRepository(get(), get(), get(), getAll()) } + + viewModel { HistoryListViewModel(get(), get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt similarity index 66% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt rename to app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt index 664fbbc0d..0e5624559 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt @@ -1,13 +1,13 @@ package org.koitharu.kotatsu.history.data import org.koitharu.kotatsu.core.model.MangaHistory -import java.time.Instant +import java.util.* fun HistoryEntity.toMangaHistory() = MangaHistory( - createdAt = Instant.ofEpochMilli(createdAt), - updatedAt = Instant.ofEpochMilli(updatedAt), + createdAt = Date(createdAt), + updatedAt = Date(updatedAt), chapterId = chapterId, page = page, scroll = scroll.toInt(), percent = percent, -) +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt new file mode 100644 index 000000000..fc1e1748a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -0,0 +1,95 @@ +package org.koitharu.kotatsu.history.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.TagEntity + +@Dao +abstract class HistoryDao { + + /** + * @hide + */ + @Transaction + @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") + abstract suspend fun findAll(offset: Int, limit: Int): List + + @Transaction + @Query("SELECT * FROM history WHERE manga_id IN (:ids)") + abstract suspend fun findAll(ids: Collection): List + + @Transaction + @Query("SELECT * FROM history ORDER BY updated_at DESC") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)") + abstract suspend fun findAllManga(): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + INNER JOIN history ON history.manga_id = manga_tags.manga_id + GROUP BY manga_tags.tag_id + ORDER BY COUNT(manga_tags.manga_id) DESC + LIMIT :limit""" + ) + abstract suspend fun findPopularTags(limit: Int): List + + @Query("SELECT * FROM history WHERE manga_id = :id") + abstract suspend fun find(id: Long): HistoryEntity? + + @Query("SELECT * FROM history WHERE manga_id = :id") + abstract fun observe(id: Long): Flow + + @Query("SELECT COUNT(*) FROM history") + abstract fun observeCount(): Flow + + @Query("SELECT percent FROM history WHERE manga_id = :id") + abstract suspend fun findProgress(id: Long): Float? + + @Query("DELETE FROM history") + abstract suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(entity: HistoryEntity): Long + + @Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt WHERE manga_id = :mangaId") + abstract suspend fun update( + mangaId: Long, + page: Int, + chapterId: Long, + scroll: Float, + percent: Float, + updatedAt: Long, + ): Int + + @Query("DELETE FROM history WHERE manga_id = :mangaId") + abstract suspend fun delete(mangaId: Long) + + suspend fun update(entity: HistoryEntity) = update( + mangaId = entity.mangaId, + page = entity.page, + chapterId = entity.chapterId, + scroll = entity.scroll, + percent = entity.percent, + updatedAt = entity.updatedAt + ) + + @Transaction + open suspend fun upsert(entity: HistoryEntity): Boolean { + return if (update(entity) == 0) { + insert(entity) + true + } else false + } + + @Transaction + open suspend fun upsert(entities: Iterable) { + for (e in entities) { + if (update(e) == 0) { + insert(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt similarity index 94% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt index c499e8c37..38e2daa7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -27,5 +27,4 @@ data class HistoryEntity( @ColumnInfo(name = "page") val page: Int, @ColumnInfo(name = "scroll") val scroll: Float, @ColumnInfo(name = "percent") val percent: Float, - @ColumnInfo(name = "deleted_at") val deletedAt: Long, -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryWithManga.kt rename to app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt new file mode 100644 index 000000000..4b7bb1c99 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -0,0 +1,144 @@ +package org.koitharu.kotatsu.history.domain + +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.base.domain.ReversibleHandle +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.* +import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.history.data.toMangaHistory +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.tryScrobble +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.ext.mapItems + +const val PROGRESS_NONE = -1f + +class HistoryRepository( + private val db: MangaDatabase, + private val trackingRepository: TrackingRepository, + private val settings: AppSettings, + private val scrobblers: List, +) { + + suspend fun getList(offset: Int, limit: Int = 20): List { + val entities = db.historyDao.findAll(offset, limit) + return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + } + + suspend fun getLastOrNull(): Manga? { + val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null + return entity.manga.toManga(entity.tags.toMangaTags()) + } + + fun observeAll(): Flow> { + return db.historyDao.observeAll().mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + + fun observeAllWithHistory(): Flow> { + return db.historyDao.observeAll().mapItems { + MangaWithHistory( + it.manga.toManga(it.tags.toMangaTags()), + it.history.toMangaHistory(), + ) + } + } + + fun observeOne(id: Long): Flow { + return db.historyDao.observe(id).map { + it?.toMangaHistory() + } + } + + fun observeHasItems(): Flow { + return db.historyDao.observeCount() + .map { it > 0 } + .distinctUntilChanged() + } + + suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) { + if (manga.isNsfw && settings.isHistoryExcludeNsfw) { + return + } + val tags = manga.tags.toEntities() + db.withTransaction { + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga.toEntity(), tags) + db.historyDao.upsert( + HistoryEntity( + mangaId = manga.id, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + chapterId = chapterId, + page = page, + scroll = scroll.toFloat(), // we migrate to int, but decide to not update database + percent = percent, + ) + ) + trackingRepository.syncWithHistory(manga, chapterId) + val chapter = manga.chapters?.find { x -> x.id == chapterId } + if (chapter != null) { + scrobblers.forEach { it.tryScrobble(manga.id, chapter) } + } + } + } + + suspend fun getOne(manga: Manga): MangaHistory? { + return db.historyDao.find(manga.id)?.toMangaHistory() + } + + suspend fun getProgress(mangaId: Long): Float { + return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE + } + + suspend fun clear() { + db.historyDao.clear() + } + + suspend fun delete(manga: Manga) { + db.historyDao.delete(manga.id) + } + + suspend fun delete(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.historyDao.delete(id) + } + } + } + + suspend fun deleteReversible(ids: Collection): ReversibleHandle { + val entities = db.withTransaction { + val entities = db.historyDao.findAll(ids.toList()).filterNotNull() + for (id in ids) { + db.historyDao.delete(id) + } + entities + } + return ReversibleHandle { + db.historyDao.upsert(entities) + } + } + + /** + * Try to replace one manga with another one + * Useful for replacing saved manga on deleting it with remove source + */ + suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { + if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) { + db.historyDao.delete(manga.id) + } + } + + suspend fun getPopularTags(limit: Int): List { + return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt rename to app/src/main/java/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt index 5a7e26897..611dd1ded 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/model/MangaWithHistory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/MangaWithHistory.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.history.domain.model +package org.koitharu.kotatsu.history.domain import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.parsers.model.Manga @@ -6,4 +6,4 @@ import org.koitharu.kotatsu.parsers.model.Manga data class MangaWithHistory( val manga: Manga, val history: MangaHistory -) +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt new file mode 100644 index 000000000..b68f247aa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -0,0 +1,66 @@ +package org.koitharu.kotatsu.history.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.view.ActionMode +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.ReversibleHandle +import org.koitharu.kotatsu.base.domain.reverseAsync +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.utils.ext.addMenuProvider + +class HistoryListFragment : MangaListFragment() { + + override val viewModel by viewModel() + override val isSwipeRefreshEnabled = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + addMenuProvider(HistoryListMenuProvider(view.context, viewModel)) + viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { + activity?.invalidateOptionsMenu() + } + viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved) + } + + override fun onScrolledToEnd() = Unit + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_history, menu) + return super.onCreateActionMode(mode, menu) + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { + it.source == MangaSource.LOCAL + } + return super.onPrepareActionMode(mode, menu) + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_remove -> { + viewModel.removeFromHistory(selectedItemsIds) + mode.finish() + true + } + else -> super.onActionItemClicked(mode, item) + } + } + + private fun onItemsRemoved(reversibleHandle: ReversibleHandle) { + Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG) + .setAction(R.string.undo) { reversibleHandle.reverseAsync() } + .show() + } + + companion object { + + fun newInstance() = HistoryListFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt new file mode 100644 index 000000000..fe0daa58c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.history.ui + +import android.content.Context +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.R +import com.google.android.material.R as materialR + +class HistoryListMenuProvider( + private val context: Context, + private val viewModel: HistoryListViewModel, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_history, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_clear_history -> { + MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.clear_history) + .setMessage(R.string.text_clear_history_prompt) + .setIcon(R.drawable.ic_delete) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearHistory() + }.show() + true + } + R.id.action_history_grouping -> { + viewModel.setGrouping(!menuItem.isChecked) + true + } + else -> false + } + + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.action_history_grouping).isChecked = viewModel.isGroupingEnabled.value == true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt new file mode 100644 index 000000000..7cb6d266e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -0,0 +1,131 @@ +package org.koitharu.kotatsu.history.ui + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.ReversibleHandle +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.domain.MangaWithHistory +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.daysDiff +import org.koitharu.kotatsu.utils.ext.onFirst +import java.util.* +import java.util.concurrent.TimeUnit + +class HistoryListViewModel( + private val repository: HistoryRepository, + private val settings: AppSettings, + private val trackingRepository: TrackingRepository, +) : MangaListViewModel(settings) { + + val isGroupingEnabled = MutableLiveData() + val onItemsRemoved = SingleLiveEvent() + + private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled } + .onEach { isGroupingEnabled.postValue(it) } + + override val content = combine( + repository.observeAllWithHistory(), + historyGrouping, + createListModeFlow() + ) { list, grouped, mode -> + when { + list.isEmpty() -> listOf( + EmptyState( + icon = R.drawable.ic_empty_history, + textPrimary = R.string.text_history_holder_primary, + textSecondary = R.string.text_history_holder_secondary, + actionStringRes = 0, + ) + ) + else -> mapList(list, grouped, mode) + } + }.onStart { + loadingCounter.increment() + }.onFirst { + loadingCounter.decrement() + }.catch { + it.toErrorState(canRetry = false) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + override fun onRefresh() = Unit + + override fun onRetry() = Unit + + fun clearHistory() { + launchLoadingJob { + repository.clear() + } + } + + fun removeFromHistory(ids: Set) { + if (ids.isEmpty()) { + return + } + launchJob(Dispatchers.Default) { + val handle = repository.deleteReversible(ids) + onItemsRemoved.postCall(handle) + } + } + + fun setGrouping(isGroupingEnabled: Boolean) { + settings.isHistoryGroupingEnabled = isGroupingEnabled + } + + private suspend fun mapList( + list: List, + grouped: Boolean, + mode: ListMode + ): List { + val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1) + val showPercent = settings.isReadingIndicatorsEnabled + var prevDate: DateTimeAgo? = null + if (!grouped) { + result += ListHeader(null, R.string.history, null) + } + for ((manga, history) in list) { + if (grouped) { + val date = timeAgo(history.updatedAt) + if (prevDate != date) { + result += date + } + prevDate = date + } + val counter = trackingRepository.getNewChaptersCount(manga.id) + val percent = if (showPercent) history.percent else PROGRESS_NONE + result += when (mode) { + ListMode.LIST -> manga.toListModel(counter, percent) + ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent) + ListMode.GRID -> manga.toGridModel(counter, percent) + } + } + return result + } + + private fun timeAgo(date: Date): DateTimeAgo { + val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L) + val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt() + val diffDays = -date.daysDiff(System.currentTimeMillis()) + return when { + diffMinutes < 3 -> DateTimeAgo.JustNow + diffDays < 1 -> DateTimeAgo.Today + diffDays == 1 -> DateTimeAgo.Yesterday + diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays) + else -> DateTimeAgo.LongAgo + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt rename to app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt index 618b8d788..9fd4542ec 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt @@ -1,19 +1,14 @@ package org.koitharu.kotatsu.history.ui.util import android.content.Context -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.* import android.graphics.drawable.Drawable 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.util.ext.scale -import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import kotlin.math.roundToInt class ReadingProgressDrawable( context: Context, @@ -110,7 +105,7 @@ class ReadingProgressDrawable( if (hasText) { if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) { tempRect.set(bounds) - tempRect.scale(0.6) + tempRect *= 0.6 checkDrawable.bounds = tempRect checkDrawable.draw(canvas) } else { @@ -144,4 +139,13 @@ class ReadingProgressDrawable( paint.getTextBounds(text, 0, text.length, tempRect) return testTextSize * width / tempRect.width() } -} + + private operator fun Rect.timesAssign(factor: Double) { + val newWidth = (width() * factor).roundToInt() + val newHeight = (height() * factor).roundToInt() + inset( + (width() - newWidth) / 2, + (height() - newHeight) / 2, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt similarity index 85% rename from app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt rename to app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt index 744201215..2bb906c99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt @@ -11,8 +11,7 @@ import android.view.animation.AccelerateDecelerateInterpolator import androidx.annotation.AttrRes import androidx.annotation.StyleRes import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE class ReadingProgressView @JvmOverloads constructor( context: Context, @@ -21,7 +20,7 @@ class ReadingProgressView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { private var percentAnimator: ValueAnimator? = null - private val animationDuration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) + private val animationDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong() @StyleRes private val drawableStyle: Int @@ -56,17 +55,17 @@ class ReadingProgressView @JvmOverloads constructor( getProgressDrawable().progress = p } - override fun onAnimationStart(animation: Animator) = Unit + override fun onAnimationStart(animation: Animator?) = Unit - override fun onAnimationEnd(animation: Animator) { + override fun onAnimationEnd(animation: Animator?) { if (percentAnimator === animation) { percentAnimator = null } } - override fun onAnimationCancel(animation: Animator) = Unit + override fun onAnimationCancel(animation: Animator?) = Unit - override fun onAnimationRepeat(animation: Animator) = Unit + override fun onAnimationRepeat(animation: Animator?) = Unit fun setPercent(value: Float, animate: Boolean) { val currentDrawable = peekProgressDrawable() @@ -77,7 +76,7 @@ class ReadingProgressView @JvmOverloads constructor( percentAnimator?.cancel() percentAnimator = ValueAnimator.ofFloat( currentDrawable.progress.coerceAtLeast(0f), - value, + value ).apply { duration = animationDuration interpolator = AccelerateDecelerateInterpolator() @@ -112,4 +111,4 @@ class ReadingProgressView @JvmOverloads constructor( outline.setOval(0, 0, view.width, view.height) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt new file mode 100644 index 000000000..28f58887b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.image.ui + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.target.ViewTarget +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityImageBinding +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.indicator + +class ImageActivity : BaseActivity() { + + private val coil: ImageLoader by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityImageBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowTitleEnabled(false) + } + loadImage(intent.data) + } + + override fun onWindowInsetsChanged(insets: Insets) { + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + } + + private fun loadImage(url: Uri?) { + ImageRequest.Builder(this) + .data(url) + .memoryCachePolicy(CachePolicy.DISABLED) + .lifecycle(this) + .target(SsivTarget(binding.ssiv)) + .indicator(binding.progressBar) + .enqueueWith(coil) + } + + private class SsivTarget( + override val view: SubsamplingScaleImageView, + ) : ViewTarget { + + override fun onError(error: Drawable?) = setDrawable(error) + + override fun onSuccess(result: Drawable) = setDrawable(result) + + override fun equals(other: Any?): Boolean { + return (this === other) || (other is SsivTarget && view == other.view) + } + + override fun hashCode() = view.hashCode() + + override fun toString() = "SsivTarget(view=$view)" + + private fun setDrawable(drawable: Drawable?) { + if (drawable != null) { + view.setImage(ImageSource.bitmap(drawable.toBitmap())) + } else { + view.recycle() + } + } + } + + companion object { + + fun newIntent(context: Context, url: String): Intent { + return Intent(context, ImageActivity::class.java) + .setData(Uri.parse(url)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt b/app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt new file mode 100644 index 000000000..7c547e0ae --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.list.domain + +interface ListExtraProvider { + + suspend fun getCounter(mangaId: Long): Int + + suspend fun getProgress(mangaId: Long): Float +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt new file mode 100644 index 000000000..a7ae219e8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.list.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.databinding.DialogListModeBinding +import org.koitharu.kotatsu.utils.ext.setValueRounded +import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter + +class ListModeSelectDialog : AlertDialogFragment(), + CheckableButtonGroup.OnCheckedChangeListener, Slider.OnSliderTouchListener { + + private val settings by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = DialogListModeBinding.inflate(inflater, container, false) + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setTitle(R.string.list_mode) + .setPositiveButton(R.string.done, null) + .setCancelable(true) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val mode = settings.listMode + binding.buttonList.isChecked = mode == ListMode.LIST + binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST + binding.buttonGrid.isChecked = mode == ListMode.GRID + binding.textViewGridTitle.isVisible = mode == ListMode.GRID + binding.sliderGrid.isVisible = mode == ListMode.GRID + + binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context)) + binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) + binding.sliderGrid.addOnSliderTouchListener(this) + + binding.checkableGroup.onCheckedChangeListener = this + } + + override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { + val mode = when (checkedId) { + R.id.button_list -> ListMode.LIST + R.id.button_list_detailed -> ListMode.DETAILED_LIST + R.id.button_grid -> ListMode.GRID + else -> return + } + binding.textViewGridTitle.isVisible = mode == ListMode.GRID + binding.sliderGrid.isVisible = mode == ListMode.GRID + settings.listMode = mode + } + + override fun onStartTrackingTouch(slider: Slider) = Unit + + override fun onStopTrackingTouch(slider: Slider) { + settings.gridSize = slider.value.toInt() + } + + companion object { + + private const val TAG = "ListModeSelectDialog" + + fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 41240eb32..cf4465a8e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -1,83 +1,61 @@ package org.koitharu.kotatsu.list.ui import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams +import android.view.* import androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet import androidx.core.graphics.Insets import androidx.core.view.isNotEmpty -import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint +import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch +import org.koin.android.ext.android.get import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager +import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager +import org.koitharu.kotatsu.base.ui.list.ListSelectionController +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.list.FitHeightGridLayoutManager -import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.measureHeight -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet -import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter +import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaItemModel -import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver +import org.koitharu.kotatsu.main.ui.AppBarOwner 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.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.search.ui.MangaListActivity -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.* -@AndroidEntryPoint abstract class MangaListFragment : BaseFragment(), PaginationScrollListener.Callback, MangaListListener, SwipeRefreshLayout.OnRefreshListener, - ListSelectionController.Callback2, - FastScroller.FastScrollListener { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings + ListSelectionController.Callback { private var listAdapter: MangaListAdapter? = null private var paginationListener: PaginationScrollListener? = null private var selectionController: ListSelectionController? = null - private var spanResolver: MangaListSpanResolver? = null + private val spanResolver = MangaListSpanResolver() private val spanSizeLookup = SpanSizeLookup() + private val listCommitCallback = Runnable { + spanSizeLookup.invalidateCache() + } open val isSwipeRefreshEnabled = true protected abstract val viewModel: MangaListViewModel @@ -88,18 +66,21 @@ abstract class MangaListFragment : protected val selectedItems: Set get() = collectSelectedItems() - override fun onCreateViewBinding( + override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup?, + container: ViewGroup? ) = FragmentListBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - listAdapter = onCreateAdapter() - spanResolver = MangaListSpanResolver(binding.root.resources) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listAdapter = MangaListAdapter( + coil = get(), + lifecycleOwner = viewLifecycleOwner, + listener = this, + ) selectionController = ListSelectionController( activity = requireActivity(), - decoration = MangaSelectionDecoration(binding.root.context), + decoration = MangaSelectionDecoration(view.context), registryOwner = this, callback = this, ) @@ -108,39 +89,34 @@ abstract class MangaListFragment : setHasFixedSize(true) adapter = listAdapter checkNotNull(selectionController).attachToRecyclerView(binding.recyclerView) - addItemDecoration(TypedListSpacingDecoration(context, false)) addOnScrollListener(paginationListener!!) - fastScroller.setFastScrollListener(this@MangaListFragment) } with(binding.swipeRefreshLayout) { + setProgressBackgroundColorSchemeColor(context.getThemeColor(com.google.android.material.R.attr.colorPrimary)) + setColorSchemeColors(context.getThemeColor(com.google.android.material.R.attr.colorOnPrimary)) setOnRefreshListener(this@MangaListFragment) isEnabled = isSwipeRefreshEnabled } - addMenuProvider(MangaListMenuProvider(this)) + addMenuProvider(MangaListMenuProvider(childFragmentManager)) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged) - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.recyclerView)) + viewModel.onError.observe(viewLifecycleOwner, ::onError) } override fun onDestroyView() { listAdapter = null paginationListener = null selectionController = null - spanResolver = null spanSizeLookup.invalidateCache() super.onDestroyView() } 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)) - } + startActivity(DetailsActivity.newIntent(context ?: return, item)) } } @@ -148,31 +124,25 @@ abstract class MangaListFragment : return selectionController?.onItemLongClick(item.id) ?: false } - override fun onReadClick(manga: Manga, view: View) { - if (selectionController?.onItemClick(manga.id) != true) { - val intent = IntentBuilder(view.context).manga(manga).build() - startActivity(intent) - } - } - - override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { - if (selectionController?.onItemClick(manga.id) != true) { - val intent = MangaListActivity.newIntent(context ?: return, setOf(tag)) - startActivity(intent) - } - } - @CallSuper override fun onRefresh() { - requireViewBinding().swipeRefreshLayout.isRefreshing = true + binding.swipeRefreshLayout.isRefreshing = true viewModel.onRefresh() } - private suspend fun onListChanged(list: List) { - listAdapter?.emit(list) - spanSizeLookup.invalidateCache() - viewBinding?.recyclerView?.let { - paginationListener?.postInvalidate(it) + private fun onListChanged(list: List) { + listAdapter?.setItems(list, listCommitCallback) + } + + private fun onError(e: Throwable) { + if (e is CloudFlareProtectedException) { + CloudFlareDialog.newInstance(e.url).show(childFragmentManager, CloudFlareDialog.TAG) + } else { + Snackbar.make( + binding.recyclerView, + e.getDisplayMessage(resources), + Snackbar.LENGTH_SHORT + ).show() } } @@ -190,87 +160,99 @@ abstract class MangaListFragment : @CallSuper protected open fun onLoadingStateChanged(isLoading: Boolean) { - requireViewBinding().swipeRefreshLayout.isEnabled = requireViewBinding().swipeRefreshLayout.isRefreshing || + binding.swipeRefreshLayout.isEnabled = binding.swipeRefreshLayout.isRefreshing || isSwipeRefreshEnabled && !isLoading if (!isLoading) { - requireViewBinding().swipeRefreshLayout.isRefreshing = false + binding.swipeRefreshLayout.isRefreshing = false } } - protected open fun onCreateAdapter(): MangaListAdapter { - return MangaListAdapter( - coil = coil, - lifecycleOwner = viewLifecycleOwner, - listener = this, - sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = false), - ) - } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top + binding.root.updatePadding( + left = insets.left, + right = insets.right, ) - rv.fastScroller.updateLayoutParams { - bottomMargin = insets.bottom - } if (activity is MainActivity) { - val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top - requireViewBinding().swipeRefreshLayout.setProgressViewOffset( + binding.recyclerView.updatePadding( + top = headerHeight, + bottom = insets.bottom, + ) + binding.swipeRefreshLayout.setProgressViewOffset( true, headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(10), ) + } else { + binding.recyclerView.updatePadding( + bottom = insets.bottom, + ) } } - override fun onFilterClick(view: View?) = Unit + override fun onFilterClick() = Unit override fun onEmptyActionClick() = Unit - override fun onListHeaderClick(item: ListHeader, view: View) = Unit - override fun onRetryClick(error: Throwable) { resolveException(error) } - override fun onUpdateFilter(tags: Set) { - viewModel.onUpdateFilter(tags) + override fun onTagRemoveClick(tag: MangaTag) { + viewModel.onRemoveFilterTag(tag) } private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() - spanResolver?.setGridSize(scale, requireViewBinding().recyclerView) + spanResolver.setGridSize(scale, binding.recyclerView) } private fun onListModeChanged(mode: ListMode) { spanSizeLookup.invalidateCache() - with(requireViewBinding().recyclerView) { + with(binding.recyclerView) { + clearItemDecorations() removeOnLayoutChangeListener(spanResolver) when (mode) { ListMode.LIST -> { layoutManager = FitHeightLinearLayoutManager(context) + val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) + val decoration = TypedSpacingItemDecoration( + MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0, + fallbackSpacing = spacing + ) + addItemDecoration(decoration) } - ListMode.DETAILED_LIST -> { layoutManager = FitHeightLinearLayoutManager(context) + val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) + updatePadding(left = spacing, right = spacing) + addItemDecoration(SpacingItemDecoration(spacing)) } - ListMode.GRID -> { - layoutManager = FitHeightGridLayoutManager(context, checkNotNull(spanResolver).spanCount).also { + layoutManager = FitHeightGridLayoutManager(context, spanResolver.spanCount).also { it.spanSizeLookup = spanSizeLookup } + val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing) + addItemDecoration(SpacingItemDecoration(spacing)) + updatePadding(left = spacing, right = spacing) addOnLayoutChangeListener(spanResolver) } } + selectionController?.attachToRecyclerView(binding.recyclerView) } } - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { return menu.isNotEmpty() } - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { + @CallSuper + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.title = selectionController?.count?.toString() + return true + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_select_all -> { val ids = listAdapter?.items?.mapNotNull { @@ -279,40 +261,27 @@ abstract class MangaListFragment : selectionController?.addAll(ids) true } - R.id.action_share -> { ShareHelper(requireContext()).shareMangaLinks(selectedItems) mode.finish() true } - R.id.action_favourite -> { - FavoriteSheet.show(childFragmentManager, selectedItems) + FavouriteCategoriesBottomSheet.show(childFragmentManager, selectedItems) mode.finish() true } - R.id.action_save -> { - viewModel.download(selectedItems) + DownloadService.confirmAndStart(requireContext(), selectedItems) mode.finish() true } - else -> false } } - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - viewBinding?.recyclerView?.invalidateItemDecorations() - } - - override fun onFastScrollStart(fastScroller: FastScroller) { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - requireViewBinding().swipeRefreshLayout.isEnabled = false - } - - override fun onFastScrollStop(fastScroller: FastScroller) { - requireViewBinding().swipeRefreshLayout.isEnabled = isSwipeRefreshEnabled + override fun onSelectionChanged(count: Int) { + binding.recyclerView.invalidateItemDecorations() } private fun collectSelectedItems(): Set { @@ -335,9 +304,10 @@ abstract class MangaListFragment : } override fun getSpanSize(position: Int): Int { - val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 + val total = + (binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 return when (listAdapter?.getItemViewType(position)) { - ListItemType.MANGA_GRID.ordinal -> 1 + ITEM_TYPE_MANGA_GRID -> 1 else -> total } } @@ -347,4 +317,4 @@ abstract class MangaListFragment : invalidateSpanIndexCache() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt new file mode 100644 index 000000000..5950cd546 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.list.ui + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import androidx.fragment.app.FragmentManager +import org.koitharu.kotatsu.R + +class MangaListMenuProvider( + private val fragmentManager: FragmentManager, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_list, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_list_mode -> { + ListModeSelectDialog.show(fragmentManager) + true + } + else -> false + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt index c3c534e8f..1e58b2065 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSpanResolver.kt @@ -1,22 +1,18 @@ package org.koitharu.kotatsu.list.ui -import android.content.res.Resources import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.R import kotlin.math.abs import kotlin.math.roundToInt -import org.koitharu.kotatsu.R -class MangaListSpanResolver( - resources: Resources, -) : View.OnLayoutChangeListener { +class MangaListSpanResolver : View.OnLayoutChangeListener { var spanCount = 3 private set - private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width) - private val spacing = resources.getDimension(R.dimen.grid_spacing) + private var gridWidth = -1f private var cellWidth = -1f override fun onLayoutChange( @@ -28,12 +24,15 @@ class MangaListSpanResolver( oldLeft: Int, oldTop: Int, oldRight: Int, - oldBottom: Int, + oldBottom: Int ) { if (cellWidth <= 0f) { return } val rv = v as? RecyclerView ?: return + if (gridWidth < 0f) { + gridWidth = rv.resources.getDimension(R.dimen.preferred_grid_width) + } val width = abs(right - left) if (width == 0) { return @@ -42,13 +41,17 @@ class MangaListSpanResolver( (rv.layoutManager as? GridLayoutManager)?.spanCount = spanCount } - fun setGridSize(scaleFactor: Float, rv: RecyclerView) { - cellWidth = (gridWidth * scaleFactor) + spacing - val lm = rv.layoutManager as? GridLayoutManager ?: return - val innerWidth = lm.width - lm.paddingEnd - lm.paddingStart - if (innerWidth > 0) { - resolveGridSpanCount(innerWidth) - lm.spanCount = spanCount + fun setGridSize(scaleFactor: Float, rv: RecyclerView?) { + if (gridWidth < 0f) { + gridWidth = (rv ?: return).resources.getDimension(R.dimen.preferred_grid_width) + } + cellWidth = gridWidth * scaleFactor + if (rv != null) { + val width = rv.width + if (width != 0) { + resolveGridSpanCount(width) + (rv.layoutManager as? GridLayoutManager)?.spanCount = spanCount + } } } @@ -56,4 +59,4 @@ class MangaListSpanResolver( val estimatedCount = (width / cellWidth).roundToInt() spanCount = estimatedCount.coerceAtLeast(2) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt new file mode 100644 index 000000000..6adc8c0d2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.list.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.onEach +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.MangaTag + +abstract class MangaListViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + abstract val content: LiveData> + val listMode = MutableLiveData() + val gridScale = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_GRID_SIZE, + valueProducer = { gridSize / 100f }, + ) + + open fun onRemoveFilterTag(tag: MangaTag) = Unit + + protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } + .onEach { + if (listMode.value != it) { + listMode.postValue(it) + } + } + + abstract fun onRefresh() + + abstract fun onRetry() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt similarity index 84% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt index 5518e15a3..8422d00ec 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -12,22 +12,21 @@ import androidx.core.graphics.ColorUtils import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration -import org.koitharu.kotatsu.core.util.ext.getItem -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration import org.koitharu.kotatsu.list.ui.model.MangaItemModel +import org.koitharu.kotatsu.utils.ext.getItem +import org.koitharu.kotatsu.utils.ext.getThemeColor import com.google.android.material.R as materialR open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { protected val paint = Paint(Paint.ANTI_ALIAS_FLAG) protected val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) - protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) - protected val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) + protected val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) protected val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) protected val fillColor = ColorUtils.setAlphaComponent( ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), - 0x74, + 0x74 ) protected val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) @@ -66,11 +65,11 @@ open class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDec setBounds( (bounds.left + iconOffset).toInt(), (bounds.top + iconOffset).toInt(), - (bounds.left + iconOffset + iconSize).toInt(), - (bounds.top + iconOffset + iconSize).toInt(), + (bounds.left + iconOffset + intrinsicWidth).toInt(), + (bounds.top + iconOffset + intrinsicHeight).toInt(), ) draw(canvas) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt new file mode 100644 index 000000000..e25a70657 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt @@ -0,0 +1,42 @@ +@file:SuppressLint("UnsafeOptInUsageError") +package org.koitharu.kotatsu.list.ui.adapter + +import android.annotation.SuppressLint +import android.view.View +import androidx.core.view.doOnNextLayout +import com.google.android.material.badge.BadgeDrawable +import com.google.android.material.badge.BadgeUtils +import org.koitharu.kotatsu.R + +fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { + return if (counter > 0) { + val badgeDrawable = badge ?: initBadge(this) + badgeDrawable.number = counter + badgeDrawable.isVisible = true + badgeDrawable.align() + badgeDrawable + } else { + badge?.isVisible = false + badge + } +} + +fun View.clearBadge(badge: BadgeDrawable?) { + BadgeUtils.detachBadgeDrawable(badge, this) +} + +private fun initBadge(anchor: View): BadgeDrawable { + val badge = BadgeDrawable.create(anchor.context) + val resources = anchor.resources + badge.maxCharacterCount = resources.getInteger(R.integer.manga_badge_max_character_count) + anchor.doOnNextLayout { + BadgeUtils.attachBadgeDrawable(badge, it) + badge.align() + } + return badge +} + +private fun BadgeDrawable.align() { + horizontalOffset = intrinsicWidth + verticalOffset = intrinsicHeight +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt new file mode 100644 index 000000000..c13fd3cfa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.MangaTag + +fun currentFilterAD( + listener: MangaListListener, +) = adapterDelegate(R.layout.item_current_filter) { + + val chipGroup = itemView as ChipsView + + chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { _, data -> + listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener) + } + + bind { + chipGroup.setChips(item.chips) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt new file mode 100644 index 000000000..8385090ab --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding +import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.setTextAndVisible + +fun emptyStateListAD( + listener: MangaListListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) } +) { + + binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } + + bind { + binding.icon.setImageResource(item.icon) + binding.textPrimary.setText(item.textPrimary) + binding.textSecondary.setTextAndVisible(item.textSecondary) + binding.buttonRetry.setTextAndVisible(item.actionStringRes) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt similarity index 73% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt index aa1a018f7..52b3db95a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt @@ -1,25 +1,23 @@ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.databinding.ItemErrorFooterBinding import org.koitharu.kotatsu.list.ui.model.ErrorFooter import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.getDisplayMessage fun errorFooterAD( - listener: MangaListListener?, + listener: MangaListListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) } ) { - if (listener != null) { - binding.root.setOnClickListener { - listener.onRetryClick(item.exception) - } + binding.root.setOnClickListener { + listener.onRetryClick(item.exception) } bind { binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources) binding.imageViewIcon.setImageResource(item.icon) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt similarity index 87% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt index 03de52eb8..084c0c28f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt @@ -2,15 +2,15 @@ package org.koitharu.kotatsu.list.ui.adapter import androidx.core.view.isVisible import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.list.ui.model.ErrorState import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.getDisplayMessage fun errorStateListAD( - listener: ListStateHolderListener, + listener: MangaListListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemErrorStateBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemErrorStateBinding.inflate(inflater, parent, false) } ) { binding.buttonRetry.setOnClickListener { @@ -27,4 +27,4 @@ fun errorStateListAD( setText(item.buttonText) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt new file mode 100644 index 000000000..ec44f2ab9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import android.widget.TextView +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.databinding.ItemHeaderWithFilterBinding +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun listHeaderAD() = adapterDelegate( + layout = R.layout.item_header, + on = { item, _, _ -> item is ListHeader && item.sortOrder == null }, +) { + + bind { + val textView = (itemView as TextView) + if (item.text != null) { + textView.text = item.text + } else { + textView.setText(item.textRes) + } + } +} + +fun listHeaderWithFilterAD( + listener: MangaListListener, +) = adapterDelegateViewBinding( + viewBinding = { inflater, parent -> ItemHeaderWithFilterBinding.inflate(inflater, parent, false) }, + on = { item, _, _ -> item is ListHeader && item.sortOrder != null }, +) { + + binding.textViewFilter.setOnClickListener { + listener.onFilterClick() + } + + bind { + if (item.text != null) { + binding.textViewTitle.text = item.text + } else { + binding.textViewTitle.setText(item.textRes) + } + binding.textViewFilter.setText(requireNotNull(item.sortOrder).titleRes) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt new file mode 100644 index 000000000..82b77d11f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.google.android.material.badge.BadgeDrawable +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemMangaGridBinding +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaGridModel +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.referer + +fun mangaGridItemAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, + sizeResolver: ItemSizeResolver?, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } +) { + + var badge: BadgeDrawable? = null + + itemView.setOnClickListener { + clickListener.onItemClick(item.manga, it) + } + itemView.setOnLongClickListener { + clickListener.onItemLongClick(item.manga, it) + } + if (sizeResolver != null) { + itemView.updateLayoutParams { + width = sizeResolver.cellWidth + } + } + + bind { payloads -> + binding.textViewTitle.text = item.title + binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) + binding.imageViewCover.newImageRequest(item.coverUrl)?.run { + referer(item.manga.publicUrl) + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_placeholder) + allowRgb565(true) + lifecycle(lifecycleOwner) + enqueueWith(coil) + } + badge = itemView.bindBadge(badge, item.counter) + } + + onViewRecycled { + itemView.clearBadge(badge) + binding.progressView.percent = PROGRESS_NONE + badge = null + binding.imageViewCover.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt new file mode 100644 index 000000000..6b69445c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -0,0 +1,88 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.model.* +import kotlin.jvm.internal.Intrinsics + +class MangaListAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + listener: MangaListListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager + .addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener)) + .addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener)) + .addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener, null)) + .addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD()) + .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) + .addDelegate(ITEM_TYPE_DATE, relatedDateItemAD()) + .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) + .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) + .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener)) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) + .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(listener)) + .addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(listener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { + oldItem is MangaListModel && newItem is MangaListModel -> { + oldItem.id == newItem.id + } + oldItem is MangaListDetailedModel && newItem is MangaListDetailedModel -> { + oldItem.id == newItem.id + } + oldItem is MangaGridModel && newItem is MangaGridModel -> { + oldItem.id == newItem.id + } + oldItem is DateTimeAgo && newItem is DateTimeAgo -> { + oldItem == newItem + } + else -> oldItem.javaClass == newItem.javaClass + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + + override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? { + return when (newItem) { + is MangaItemModel -> { + oldItem as MangaItemModel + if (oldItem.progress != newItem.progress) { + PAYLOAD_PROGRESS + } else { + Unit + } + } + is CurrentFilterModel -> Unit + else -> super.getChangePayload(oldItem, newItem) + } + } + } + + companion object { + + const val ITEM_TYPE_MANGA_LIST = 0 + const val ITEM_TYPE_MANGA_LIST_DETAILED = 1 + const val ITEM_TYPE_MANGA_GRID = 2 + const val ITEM_TYPE_LOADING_FOOTER = 3 + const val ITEM_TYPE_LOADING_STATE = 4 + const val ITEM_TYPE_DATE = 5 + const val ITEM_TYPE_ERROR_STATE = 6 + const val ITEM_TYPE_ERROR_FOOTER = 7 + const val ITEM_TYPE_EMPTY = 8 + const val ITEM_TYPE_HEADER = 9 + const val ITEM_TYPE_FILTER = 10 + const val ITEM_TYPE_HEADER_FILTER = 11 + + val PAYLOAD_PROGRESS = Any() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt new file mode 100644 index 000000000..edf38d33a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -0,0 +1,57 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import org.koitharu.kotatsu.utils.ext.* +import com.google.android.material.badge.BadgeDrawable +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel +import org.koitharu.kotatsu.parsers.model.Manga + +fun mangaListDetailedItemAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) } +) { + + var badge: BadgeDrawable? = null + + itemView.setOnClickListener { + clickListener.onItemClick(item.manga, it) + } + itemView.setOnLongClickListener { + clickListener.onItemLongClick(item.manga, it) + } + + bind { payloads -> + binding.textViewTitle.text = item.title + binding.textViewSubtitle.textAndVisible = item.subtitle + binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) + binding.imageViewCover.newImageRequest(item.coverUrl)?.run { + referer(item.manga.publicUrl) + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_placeholder) + allowRgb565(true) + lifecycle(lifecycleOwner) + enqueueWith(coil) + } + binding.textViewRating.textAndVisible = item.rating + binding.textViewTags.text = item.tags + itemView.bindBadge(badge, item.counter) + } + + onViewRecycled { + itemView.clearBadge(badge) + binding.progressView.percent = PROGRESS_NONE + badge = null + binding.imageViewCover.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt new file mode 100644 index 000000000..48c157ed1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import org.koitharu.kotatsu.utils.ext.* +import com.google.android.material.badge.BadgeDrawable +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemMangaListBinding +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaListModel +import org.koitharu.kotatsu.parsers.model.Manga + +fun mangaListItemAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } +) { + + var badge: BadgeDrawable? = null + + itemView.setOnClickListener { + clickListener.onItemClick(item.manga, it) + } + itemView.setOnLongClickListener { + clickListener.onItemLongClick(item.manga, it) + } + + bind { + binding.textViewTitle.text = item.title + binding.textViewSubtitle.textAndVisible = item.subtitle + binding.imageViewCover.newImageRequest(item.coverUrl)?.run { + referer(item.manga.publicUrl) + placeholder(R.drawable.ic_placeholder) + fallback(R.drawable.ic_placeholder) + error(R.drawable.ic_placeholder) + allowRgb565(true) + lifecycle(lifecycleOwner) + enqueueWith(coil) + } + itemView.bindBadge(badge, item.counter) + } + + onViewRecycled { + itemView.clearBadge(badge) + badge = null + binding.imageViewCover.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt new file mode 100644 index 000000000..d9cc8d8e4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag + +interface MangaListListener : OnListItemClickListener { + + fun onRetryClick(error: Throwable) + fun onTagRemoveClick(tag: MangaTag) + fun onFilterClick() + fun onEmptyActionClick() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt new file mode 100644 index 000000000..98a4bc8f6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import android.widget.TextView +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun relatedDateItemAD() = adapterDelegate(R.layout.item_header) { + + bind { + (itemView as TextView).text = item.format(context.resources) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt new file mode 100644 index 000000000..19b3f11f7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.list.ui.filter + +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter + +class FilterAdapter( + listener: OnFilterChangedListener, +) : AsyncListDifferDelegationAdapter( + FilterDiffCallback(), + filterSortDelegate(listener), + filterTagDelegate(listener), + filterHeaderDelegate(), + filterLoadingDelegate(), + filterErrorDelegate(), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt new file mode 100644 index 000000000..e34b5ad94 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt @@ -0,0 +1,66 @@ +package org.koitharu.kotatsu.list.ui.filter + +import android.widget.TextView +import androidx.core.view.isVisible +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.titleRes +import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding + +fun filterSortDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onSortItemClick(item) + } + + bind { + binding.root.setText(item.order.titleRes) + binding.root.isChecked = item.isSelected + } +} + +fun filterTagDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onTagItemClick(item) + } + + bind { + binding.root.text = item.tag.title + binding.root.isChecked = item.isChecked + } +} + +fun filterHeaderDelegate() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } +) { + + bind { + binding.textViewTitle.setText(item.titleResId) + binding.badge.isVisible = if (item.counter == 0) { + false + } else { + binding.badge.text = item.counter.toString() + true + } + } +} + +fun filterLoadingDelegate() = adapterDelegate(R.layout.item_loading_footer) {} + +fun filterErrorDelegate() = adapterDelegate(R.layout.item_sources_empty) { + + bind { + (itemView as TextView).setText(item.textResId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt new file mode 100644 index 000000000..7583b2e8c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt @@ -0,0 +1,97 @@ +package org.koitharu.kotatsu.list.ui.filter + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.* +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.FragmentManager +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.databinding.SheetFilterBinding +import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel +import org.koitharu.kotatsu.utils.BottomSheetToolbarController + +class FilterBottomSheet : + BaseBottomSheet(), + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { + + private val viewModel by sharedViewModel( + owner = { requireParentFragment() } + ) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).also { + it.setOnKeyListener(this) + } + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { + return SheetFilterBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { dismiss() } + behavior?.addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + if (!resources.getBoolean(R.bool.is_tablet)) { + binding.toolbar.navigationIcon = null + } + val adapter = FilterAdapter(viewModel) + binding.recyclerView.adapter = adapter + viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems) + initOptionsMenu() + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + setExpanded(isExpanded = true, isLocked = true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + val searchView = (item.actionView as? SearchView) ?: return false + searchView.setQuery("", false) + searchView.post { setExpanded(isExpanded = false, isLocked = false) } + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean = false + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.filterSearch(newText?.trim().orEmpty()) + return true + } + + override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false + if (menuItem.isActionViewExpanded) { + if (event?.action == KeyEvent.ACTION_UP) { + menuItem.collapseActionView() + } + return true + } + } + return false + } + + private fun initOptionsMenu() { + binding.toolbar.inflateMenu(R.menu.opt_filter) + val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + companion object { + + private const val TAG = "FilterBottomSheet" + + fun show(fm: FragmentManager) = FilterBottomSheet().show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt new file mode 100644 index 000000000..5ea361168 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt @@ -0,0 +1,206 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.annotation.WorkerThread +import androidx.lifecycle.LiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.text.Collator +import java.util.* + +class FilterCoordinator( + private val repository: RemoteMangaRepository, + dataRepository: MangaDataRepository, + private val coroutineScope: CoroutineScope, +) : OnFilterChangedListener { + + private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet())) + private var searchQuery = MutableStateFlow("") + private val localTagsDeferred = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { + dataRepository.findTags(repository.source) + } + private var availableTagsDeferred = loadTagsAsync() + + val items: LiveData> = getItemsFlow() + .asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default) + + init { + observeState() + } + + override fun onSortItemClick(item: FilterItem.Sort) { + currentState.update { oldValue -> + FilterState(item.order, oldValue.tags) + } + repository.defaultSortOrder = item.order + } + + override fun onTagItemClick(item: FilterItem.Tag) { + currentState.update { oldValue -> + val newTags = if (item.isChecked) { + oldValue.tags - item.tag + } else { + oldValue.tags + item.tag + } + FilterState(oldValue.sortOrder, newTags) + } + } + + fun observeState() = currentState.asStateFlow() + + fun removeTag(tag: MangaTag) { + currentState.update { oldValue -> + FilterState(oldValue.sortOrder, oldValue.tags - tag) + } + } + + fun setTags(tags: Set) { + currentState.update { oldValue -> + FilterState(oldValue.sortOrder, tags) + } + } + + fun reset() { + currentState.update { oldValue -> + FilterState(oldValue.sortOrder, emptySet()) + } + } + + fun snapshot() = currentState.value + + fun performSearch(query: String) { + searchQuery.value = query + } + + private fun getItemsFlow() = combine( + getTagsAsFlow(), + currentState, + searchQuery, + ) { tags, state, query -> + buildFilterList(tags, state, query) + } + + private fun getTagsAsFlow() = flow { + val localTags = localTagsDeferred.await() + emit(TagsWrapper(localTags, isLoading = true, isError = false)) + val remoteTags = tryLoadTags() + if (remoteTags == null) { + emit(TagsWrapper(localTags, isLoading = false, isError = true)) + } else { + emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) + } + } + + @WorkerThread + private fun buildFilterList( + allTags: TagsWrapper, + state: FilterState, + query: String, + ): List { + val sortOrders = repository.sortOrders.sortedBy { it.ordinal } + val tags = mergeTags(state.tags, allTags.tags).toList() + val list = ArrayList(tags.size + sortOrders.size + 3) + if (query.isEmpty()) { + if (sortOrders.isNotEmpty()) { + list.add(FilterItem.Header(R.string.sort_order, 0)) + sortOrders.mapTo(list) { + FilterItem.Sort(it, isSelected = it == state.sortOrder) + } + } + if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { + list.add(FilterItem.Header(R.string.genres, state.tags.size)) + tags.mapTo(list) { + FilterItem.Tag(it, isChecked = it in state.tags) + } + } + if (allTags.isError) { + list.add(FilterItem.Error(R.string.filter_load_error)) + } else if (allTags.isLoading) { + list.add(FilterItem.Loading) + } + } else { + tags.mapNotNullTo(list) { + if (it.title.contains(query, ignoreCase = true)) { + FilterItem.Tag(it, isChecked = it in state.tags) + } else { + null + } + } + if (list.isEmpty()) { + list.add(FilterItem.Error(R.string.nothing_found)) + } + } + return list + } + + private suspend fun tryLoadTags(): Set? { + val shouldRetryOnError = availableTagsDeferred.isCompleted + val result = availableTagsDeferred.await() + if (result == null && shouldRetryOnError) { + availableTagsDeferred = loadTagsAsync() + return availableTagsDeferred.await() + } + return result + } + + private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { + runCatching { + repository.getTags() + }.onFailure { error -> + error.printStackTraceDebug() + }.getOrNull() + } + + private fun mergeTags(primary: Set, secondary: Set): Set { + val result = TreeSet(TagTitleComparator(repository.source.locale)) + result.addAll(secondary) + result.addAll(primary) + return result + } + + private class TagsWrapper( + val tags: Set, + val isLoading: Boolean, + val isError: Boolean, + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TagsWrapper + + if (tags != other.tags) return false + if (isLoading != other.isLoading) return false + if (isError != other.isError) return false + + return true + } + + override fun hashCode(): Int { + var result = tags.hashCode() + result = 31 * result + isLoading.hashCode() + result = 31 * result + isError.hashCode() + return result + } + } + + private class TagTitleComparator(lc: String?) : Comparator { + + private val collator = lc?.let { Collator.getInstance(Locale(it)) } + + override fun compare(o1: MangaTag, o2: MangaTag): Int { + val t1 = o1.title.lowercase() + val t2 = o2.title.lowercase() + return collator?.compare(t1, t2) ?: compareValues(t1, t2) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt new file mode 100644 index 000000000..4549c46cf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.recyclerview.widget.DiffUtil + +class FilterDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem === newItem -> true + oldItem.javaClass != newItem.javaClass -> false + oldItem is FilterItem.Header && newItem is FilterItem.Header -> { + oldItem.titleResId == newItem.titleResId + } + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.tag == newItem.tag + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.order == newItem.order + } + oldItem is FilterItem.Error && newItem is FilterItem.Error -> { + oldItem.textResId == newItem.textResId + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true + oldItem is FilterItem.Header && newItem is FilterItem.Header -> { + oldItem.counter == newItem.counter + } + oldItem is FilterItem.Error && newItem is FilterItem.Error -> true + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked == newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected == newItem.isSelected + } + else -> false + } + } + + override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? { + val hasPayload = when { + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked != newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected != newItem.isSelected + } + oldItem is FilterItem.Header && newItem is FilterItem.Header -> { + oldItem.counter != newItem.counter + } + else -> false + } + return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt new file mode 100644 index 000000000..bbef939cb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder + +sealed interface FilterItem { + + class Header( + @StringRes val titleResId: Int, + val counter: Int, + ) : FilterItem + + class Sort( + val order: SortOrder, + val isSelected: Boolean, + ) : FilterItem + + class Tag( + val tag: MangaTag, + val isChecked: Boolean, + ) : FilterItem + + object Loading : FilterItem + + class Error( + @StringRes val textResId: Int, + ) : FilterItem +} diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt new file mode 100644 index 000000000..d9e387b89 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterState.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.list.ui.filter + +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder + +class FilterState( + val sortOrder: SortOrder?, + val tags: Set, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FilterState + + if (sortOrder != other.sortOrder) return false + if (tags != other.tags) return false + + return true + } + + override fun hashCode(): Int { + var result = sortOrder?.hashCode() ?: 0 + result = 31 * result + tags.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt new file mode 100644 index 000000000..a28596c9f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.list.ui.filter + +interface OnFilterChangedListener { + + fun onSortItemClick(item: FilterItem.Sort) + + fun onTagItemClick(item: FilterItem.Tag) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt new file mode 100644 index 000000000..32cebb25c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.list.ui.model + +import org.koitharu.kotatsu.base.ui.widgets.ChipsView + +data class CurrentFilterModel( + val chips: Collection, +) : ListModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyState.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt index e586eff44..179bb25b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt @@ -8,9 +8,4 @@ data class EmptyState( @StringRes val textPrimary: Int, @StringRes val textSecondary: Int, @StringRes val actionStringRes: Int, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is EmptyState - } -} +) : ListModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt similarity index 53% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt index 4cdbb9c1b..7bcaf03d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt @@ -5,9 +5,4 @@ import androidx.annotation.DrawableRes data class ErrorFooter( val exception: Throwable, @DrawableRes val icon: Int -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ErrorFooter && exception == other.exception - } -} +) : ListModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorState.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt index 9f1bf50a5..43f566555 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ErrorState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt @@ -8,7 +8,4 @@ data class ErrorState( @DrawableRes val icon: Int, val canRetry: Boolean, @StringRes val buttonText: Int -) : ListModel { - - override fun areItemsTheSame(other: ListModel) = other is ErrorState -} +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt new file mode 100644 index 000000000..94f13444c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.parsers.model.SortOrder + +data class ListHeader( + val text: CharSequence?, + @StringRes val textRes: Int, + val sortOrder: SortOrder?, +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt new file mode 100644 index 000000000..1ae2536f9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.list.ui.model + +interface ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt new file mode 100644 index 000000000..b642a248b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.list.ui.model + +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.list.domain.ListExtraProvider +import org.koitharu.kotatsu.parsers.exception.AuthRequiredException +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.ifZero + +fun Manga.toListModel(counter: Int, progress: Float) = MangaListModel( + id = id, + title = title, + subtitle = tags.joinToString(", ") { it.title }, + coverUrl = coverUrl, + manga = this, + counter = counter, + progress = progress, +) + +fun Manga.toListDetailedModel(counter: Int, progress: Float) = MangaListDetailedModel( + id = id, + title = title, + subtitle = altTitle, + rating = if (hasRating) String.format("%.1f", rating * 5) else null, + tags = tags.joinToString(", ") { it.title }, + coverUrl = coverUrl, + manga = this, + counter = counter, + progress = progress, +) + +fun Manga.toGridModel(counter: Int, progress: Float) = MangaGridModel( + id = id, + title = title, + coverUrl = coverUrl, + manga = this, + counter = counter, + progress = progress, +) + +suspend fun List.toUi( + mode: ListMode, + extraProvider: ListExtraProvider, +): List = when (mode) { + ListMode.LIST -> map { + it.toListModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) + } + ListMode.DETAILED_LIST -> map { + it.toListDetailedModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) + } + ListMode.GRID -> map { + it.toGridModel(extraProvider.getCounter(it.id), extraProvider.getProgress(it.id)) + } +} + +fun List.toUi( + mode: ListMode, +): List = when (mode) { + ListMode.LIST -> map { it.toListModel(0, PROGRESS_NONE) } + ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0, PROGRESS_NONE) } + ListMode.GRID -> map { it.toGridModel(0, PROGRESS_NONE) } +} + +fun > List.toUi( + destination: C, + mode: ListMode, +): C = when (mode) { + ListMode.LIST -> mapTo(destination) { it.toListModel(0, PROGRESS_NONE) } + ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(0, PROGRESS_NONE) } + ListMode.GRID -> mapTo(destination) { it.toGridModel(0, PROGRESS_NONE) } +} + +fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState( + exception = this, + icon = getErrorIcon(this), + canRetry = canRetry, + buttonText = ExceptionResolver.getResolveStringId(this).ifZero { R.string.try_again } +) + +fun Throwable.toErrorFooter() = ErrorFooter( + exception = this, + icon = R.drawable.ic_alert_outline +) + +private fun getErrorIcon(error: Throwable) = when (error) { + is AuthRequiredException, + is CloudFlareProtectedException -> R.drawable.ic_denied_large + else -> R.drawable.ic_error_large +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt new file mode 100644 index 000000000..97a63b6a6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.list.ui.model + +object LoadingFooter : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt new file mode 100644 index 000000000..a866e7427 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.list.ui.model + +object LoadingState : ListModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt index 2b94795c6..97a414879 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt @@ -9,4 +9,4 @@ data class MangaGridModel( override val manga: Manga, override val counter: Int, override val progress: Float, -) : MangaItemModel() +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt new file mode 100644 index 000000000..ce5145d26 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.list.ui.model + +import org.koitharu.kotatsu.parsers.model.Manga + +sealed interface MangaItemModel : ListModel { + + val id: Long + val manga: Manga + val title: String + val coverUrl: String + val counter: Int + val progress: Float +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt index e64c47ada..f9957f345 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt @@ -1,15 +1,15 @@ package org.koitharu.kotatsu.list.ui.model -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.parsers.model.Manga data class MangaListDetailedModel( override val id: Long, override val title: String, val subtitle: String?, + val tags: String, override val coverUrl: String, + val rating: String?, override val manga: Manga, override val counter: Int, override val progress: Float, - val tags: List, -) : MangaItemModel() +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt index 54b0a800a..71ac5743c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt @@ -10,4 +10,4 @@ data class MangaListModel( override val manga: Manga, override val counter: Int, override val progress: Float, -) : MangaItemModel() +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt new file mode 100644 index 000000000..b116fdf97 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.local + +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.local.ui.LocalListViewModel + +val localModule + get() = module { + + factory { LocalStorageManager(androidContext(), get()) } + single { LocalMangaRepository(get()) } + + factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) } + + viewModel { LocalListViewModel(get(), get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CacheDir.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt similarity index 84% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/CacheDir.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt index 2300526c9..1cc562d7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CacheDir.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt @@ -3,6 +3,5 @@ package org.koitharu.kotatsu.local.data enum class CacheDir(val dir: String) { THUMBS("image_cache"), - FAVICONS("favicons"), PAGES("pages"); } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt similarity index 88% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index ecc5791b1..c773a05d3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import okio.buffer import okio.source -import org.koitharu.kotatsu.local.data.util.withExtraCloseable import java.util.zip.ZipFile class CbzFetcher( @@ -24,7 +23,10 @@ class CbzFetcher( val zip = ZipFile(uri.schemeSpecificPart) val entry = zip.getEntry(uri.fragment) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) - val bufferedSource = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer() + val bufferedSource = ExtraCloseableBufferedSource( + zip.getInputStream(entry).source().buffer(), + zip, + ) SourceResult( source = ImageSource( source = bufferedSource, @@ -48,4 +50,4 @@ class CbzFetcher( } class CbzMetadata(val uri: Uri) : ImageSource.Metadata() -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt new file mode 100644 index 000000000..8b8fca986 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.local.data + +import java.io.File +import java.io.FilenameFilter +import java.util.* + +class CbzFilter : FilenameFilter { + + override fun accept(dir: File, name: String): Boolean { + return isFileSupported(name) + } + + fun isFileSupported(name: String): Boolean { + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + return ext == "cbz" || ext == "zip" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt new file mode 100644 index 000000000..69ba2565a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.local.data + +import okhttp3.internal.closeQuietly +import okio.BufferedSource +import okio.Closeable + +class ExtraCloseableBufferedSource( + private val delegate: BufferedSource, + vararg closeable: Closeable, +) : BufferedSource by delegate { + + private val extraCloseable = closeable + + override fun close() { + delegate.close() + extraCloseable.forEach { x -> x.closeQuietly() } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt similarity index 59% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index c5001aece..6e9c1e399 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -2,42 +2,30 @@ package org.koitharu.kotatsu.local.data import android.content.ContentResolver import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.StatFs import androidx.annotation.WorkerThread -import androidx.core.net.toFile -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.Cache import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_FILE -import org.koitharu.kotatsu.core.util.ext.computeSize -import org.koitharu.kotatsu.core.util.ext.getStorageName -import org.koitharu.kotatsu.core.util.ext.resolveFile -import org.koitharu.kotatsu.parsers.util.mapToSet -import java.io.File -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ext.computeSize +import org.koitharu.kotatsu.utils.ext.getStorageName private const val DIR_NAME = "manga" -private const val NOMEDIA = ".nomedia" private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB -@Reusable -class LocalStorageManager @Inject constructor( - @ApplicationContext private val context: Context, +class LocalStorageManager( + private val context: Context, private val settings: AppSettings, ) { val contentResolver: ContentResolver get() = context.contentResolver - @WorkerThread fun createHttpCache(): Cache { val directory = File(context.externalCacheDir ?: context.cacheDir, "http") directory.mkdirs() @@ -49,18 +37,6 @@ class LocalStorageManager @Inject constructor( getCacheDirs(cache.dir).sumOf { it.computeSize() } } - suspend fun computeCacheSize() = withContext(Dispatchers.IO) { - getCacheDirs().sumOf { it.computeSize() } - } - - suspend fun computeStorageSize() = withContext(Dispatchers.IO) { - getConfiguredStorageDirs().sumOf { it.computeSize() } - } - - suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) { - getConfiguredStorageDirs().mapToSet { it.freeSpace }.sum() - } - suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) { getCacheDirs(cache.dir).forEach { it.deleteRecursively() } } @@ -80,42 +56,14 @@ class LocalStorageManager @Inject constructor( preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() } } - suspend fun getApplicationStorageDirs(): Set = runInterruptible(Dispatchers.IO) { - getAvailableStorageDirs() - } - - suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) { - if (uri.scheme == URI_SCHEME_FILE) { - uri.toFile() - } else { - uri.resolveFile(context) - } - } - - suspend fun setDirIsNoMedia(dir: File) = runInterruptible(Dispatchers.IO) { - File(dir, NOMEDIA).createNewFile() - } - - fun takePermissions(uri: Uri) { - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - contentResolver.takePersistableUriPermission(uri, flags) - } - - suspend fun getDirectoryDisplayName(dir: File, isFullPath: Boolean): String = runInterruptible(Dispatchers.IO) { - val packageName = context.packageName - if (dir.absolutePath.contains(packageName)) { - dir.getStorageName(context) - } else if (isFullPath) { - dir.path - } else { - dir.name - } - } + fun getStorageDisplayName(file: File) = file.getStorageName(context) @WorkerThread private fun getConfiguredStorageDirs(): MutableSet { val set = getAvailableStorageDirs() - set.addAll(settings.userSpecifiedMangaDirectories) + settings.mangaStorageDir?.let { + set.add(it) + } return set } @@ -145,14 +93,6 @@ class LocalStorageManager @Inject constructor( return result } - @WorkerThread - private fun getCacheDirs(): MutableSet { - val result = LinkedHashSet() - result += context.cacheDir - context.externalCacheDirs.filterNotNullTo(result) - return result - } - private fun calculateDiskCacheSize(cacheDirectory: File): Long { return try { val cacheDir = StatFs(cacheDirectory.absolutePath) @@ -170,4 +110,4 @@ class LocalStorageManager @Inject constructor( private fun File.isWriteable() = runCatching { canWrite() }.getOrDefault(false) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt similarity index 67% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt rename to app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 36368fddf..3a585be9c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -1,29 +1,20 @@ package org.koitharu.kotatsu.local.data -import androidx.annotation.WorkerThread import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.model.isLocal -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.* import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet import org.koitharu.kotatsu.parsers.util.toTitleCase -import java.io.File class MangaIndex(source: String?) { private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject() - fun setMangaInfo(manga: Manga) { - require(!manga.isLocal) { "Local manga information cannot be stored" } + fun setMangaInfo(manga: Manga, append: Boolean) { json.put("id", manga.id) json.put("title", manga.title) json.put("title_alt", manga.altTitle) @@ -46,9 +37,9 @@ class MangaIndex(source: String?) { jo.put("title", tag.title) a.put(jo) } - }, + } ) - if (!json.has("chapters")) { + if (!append || !json.has("chapters")) { json.put("chapters", JSONObject()) } json.put("app_id", BuildConfig.APPLICATION_ID) @@ -70,14 +61,14 @@ class MangaIndex(source: String?) { isNsfw = json.getBooleanOrDefault("nsfw", false), coverUrl = json.getString("cover"), state = json.getStringOrNull("state")?.let { stateString -> - MangaState.entries.find(stateString) + MangaState.values().find { it.name == stateString } }, description = json.getStringOrNull("description"), tags = json.getJSONArray("tags").mapJSONToSet { x -> MangaTag( title = x.getString("title").toTitleCase(), key = x.getString("key"), - source = source, + source = source ) }, chapters = getChapters(json.getJSONObject("chapters"), source), @@ -86,7 +77,7 @@ class MangaIndex(source: String?) { fun getCoverEntry(): String? = json.getStringOrNull("cover_entry") - fun addChapter(chapter: MangaChapter, filename: String?) { + fun addChapter(chapter: MangaChapter) { val chapters = json.getJSONObject("chapters") if (!chapters.has(chapter.id.toString())) { val jo = JSONObject() @@ -97,7 +88,6 @@ class MangaIndex(source: String?) { jo.put("scanlator", chapter.scanlator) jo.put("branch", chapter.branch) jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number)) - jo.put("file", filename) chapters.put(chapter.id.toString(), jo) } } @@ -106,10 +96,6 @@ class MangaIndex(source: String?) { return json.getJSONObject("chapters").remove(id.toString()) != null } - fun getChapterFileName(chapterId: Long): String? { - return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file") - } - fun setCoverEntry(name: String) { json.put("cover_entry", name) } @@ -117,42 +103,9 @@ class MangaIndex(source: String?) { fun getChapterNamesPattern(chapter: MangaChapter) = Regex( json.getJSONObject("chapters") .getJSONObject(chapter.id.toString()) - .getString("entries"), + .getString("entries") ) - fun sortChaptersByName() { - val jo = json.getJSONObject("chapters") - val list = ArrayList(jo.length()) - jo.keys().forEach { id -> - val item = jo.getJSONObject(id) - item.put("id", id) - list.add(item) - } - val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator() - list.sortWith(compareBy(comparator) { it.getString("name") }) - val newJo = JSONObject() - list.forEachIndexed { i, obj -> - obj.put("number", i + 1) - val id = obj.remove("id") as String - newJo.put(id, obj) - } - json.put("chapters", newJo) - } - - fun clear() { - val keys = json.keys() - while (keys.hasNext()) { - json.remove(keys.next()) - } - } - - fun setFrom(other: MangaIndex) { - clear() - other.json.keys().forEach { key -> - json.putOpt(key, other.json.opt(key)) - } - } - private fun getChapters(json: JSONObject, source: MangaSource): List { val chapters = ArrayList(json.length()) for (k in json.keys()) { @@ -167,7 +120,7 @@ class MangaIndex(source: String?) { scanlator = v.getStringOrNull("scanlator"), branch = v.getStringOrNull("branch"), source = source, - ), + ) ) } return chapters.sortedBy { it.number } @@ -178,18 +131,4 @@ class MangaIndex(source: String?) { } else { json.toString() } - - companion object { - - @WorkerThread - fun read(file: File): MangaIndex? { - if (file.exists() && file.canRead()) { - val text = file.readText() - if (text.length > 2) { - return MangaIndex(text) - } - } - return null - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt new file mode 100644 index 000000000..10eeea740 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -0,0 +1,73 @@ +package org.koitharu.kotatsu.local.data + +import android.content.Context +import com.tomclaw.cache.DiskLruCache +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.subdir +import org.koitharu.kotatsu.utils.ext.takeIfReadable +import java.io.File +import java.io.InputStream + +class PagesCache(context: Context) { + + private val cacheDir = context.externalCacheDir ?: context.cacheDir + private val lruCache = createDiskLruCacheSafe( + dir = cacheDir.subdir(CacheDir.PAGES.dir), + size = FileSize.MEGABYTES.convert(200, FileSize.BYTES), + ) + + operator fun get(url: String): File? { + return lruCache.get(url)?.takeIfReadable() + } + + fun put(url: String, inputStream: InputStream): File { + val file = File(cacheDir, url.longHashCode().toString()) + file.outputStream().use { out -> + inputStream.copyTo(out) + } + val res = lruCache.put(url, file) + file.delete() + return res + } + + fun put( + url: String, + inputStream: InputStream, + contentLength: Long, + progress: MutableStateFlow, + ): File { + val file = File(cacheDir, url.longHashCode().toString()) + file.outputStream().use { out -> + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + publishProgress(contentLength, bytesCopied, progress) + bytes = inputStream.read(buffer) + } + } + val res = lruCache.put(url, file) + file.delete() + return res + } + + private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow) { + if (contentLength > 0) { + progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat() + } + } +} + +private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache { + return try { + DiskLruCache.create(dir, size) + } catch (e: Exception) { + dir.deleteRecursively() + dir.mkdir() + DiskLruCache.create(dir, size) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt new file mode 100644 index 000000000..8aef4fead --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.local.data + +import java.io.File +import java.io.FilenameFilter + +class TempFileFilter : FilenameFilter { + + override fun accept(dir: File, name: String): Boolean { + return name.endsWith(".tmp", ignoreCase = true) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt similarity index 66% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt rename to app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt index 747296ceb..53e2d474a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt @@ -1,44 +1,40 @@ -package org.koitharu.kotatsu.local.data.output +package org.koitharu.kotatsu.local.domain import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.core.util.ext.readText +import okio.Closeable import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.readText import java.io.File import java.util.zip.ZipFile -class LocalMangaZipOutput( - rootFile: File, +class CbzMangaOutput( + val file: File, manga: Manga, -) : LocalMangaOutput(rootFile) { +) : Closeable { - private val output = ZipOutput(File(rootFile.path + ".tmp")) + private val output = ZipOutput(File(file.path + ".tmp")) private val index = MangaIndex(null) - private val mutex = Mutex() init { - if (!manga.isLocal) { - index.setMangaInfo(manga) - } + index.setMangaInfo(manga, false) } - override suspend fun mergeWithExisting() = mutex.withLock { - if (rootFile.exists()) { + suspend fun mergeWithExisting() { + if (file.exists()) { runInterruptible(Dispatchers.IO) { - mergeWith(rootFile) + mergeWith(file) } } } - override suspend fun addCover(file: File, ext: String) = mutex.withLock { + suspend fun addCover(file: File, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(0, 0, 0)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -52,7 +48,7 @@ class LocalMangaZipOutput( index.setCoverEntry(name) } - override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock { + suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { val name = buildString { append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) if (ext.isNotEmpty() && ext.length <= 4) { @@ -63,25 +59,21 @@ class LocalMangaZipOutput( runInterruptible(Dispatchers.IO) { output.put(name, file) } - index.addChapter(chapter, null) + index.addChapter(chapter) } - override suspend fun flushChapter(chapter: MangaChapter): Boolean = false - - override suspend fun finish() = mutex.withLock { + suspend fun finalize() { runInterruptible(Dispatchers.IO) { output.put(ENTRY_NAME_INDEX, index.toString()) output.finish() output.close() } - rootFile.deleteAwait() - output.file.renameTo(rootFile) - Unit + file.deleteAwait() + output.file.renameTo(file) } - override suspend fun cleanup() = mutex.withLock { + suspend fun cleanup() { output.file.deleteAwait() - Unit } override fun close() { @@ -97,7 +89,7 @@ class LocalMangaZipOutput( otherIndex = MangaIndex( zip.getInputStream(entry).use { it.reader().readText() - }, + } ) } else { output.copyEntryFrom(zip, entry) @@ -106,7 +98,7 @@ class LocalMangaZipOutput( } otherIndex?.getMangaInfo()?.chapters?.let { chapters -> for (chapter in chapters) { - index.addChapter(chapter, null) + index.addChapter(chapter) } } } @@ -115,9 +107,17 @@ class LocalMangaZipOutput( private const val FILENAME_PATTERN = "%08d_%03d%03d" + const val ENTRY_NAME_INDEX = "index.json" + + fun get(root: File, manga: Manga): CbzMangaOutput { + val name = manga.title.toFileNameSafe() + ".cbz" + val file = File(root, name) + return CbzMangaOutput(file, manga) + } + @WorkerThread - fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) { - ZipFile(subject.rootFile).use { zip -> + fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) { + ZipFile(subject.file).use { zip -> val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) idsToRemove.forEach { id -> index.removeChapter(id) } val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { @@ -129,15 +129,12 @@ class LocalMangaZipOutput( entry.name == ENTRY_NAME_INDEX -> { subject.output.put(ENTRY_NAME_INDEX, index.toString()) } - entry.isDirectory -> { subject.output.addDirectory(entry.name) } - entry.name == coverEntryName -> { subject.output.copyEntryFrom(zip, entry) } - else -> { val name = entry.name.substringBefore('.') if (patterns.any { it.matches(name) }) { @@ -148,9 +145,9 @@ class LocalMangaZipOutput( } subject.output.finish() subject.output.close() - subject.rootFile.delete() - subject.output.file.renameTo(subject.rootFile) + subject.file.delete() + subject.output.file.renameTo(subject.file) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt new file mode 100644 index 000000000..0fb9b63d3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -0,0 +1,345 @@ +package org.koitharu.kotatsu.local.domain + +import android.annotation.SuppressLint +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.annotation.WorkerThread +import androidx.collection.ArraySet +import androidx.core.net.toFile +import androidx.core.net.toUri +import java.io.File +import java.io.IOException +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.* +import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.local.data.CbzFilter +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.data.MangaIndex +import org.koitharu.kotatsu.local.data.TempFileFilter +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.toCamelCase +import org.koitharu.kotatsu.utils.AlphanumComparator +import org.koitharu.kotatsu.utils.CompositeMutex +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.longHashCode +import org.koitharu.kotatsu.utils.ext.readText +import org.koitharu.kotatsu.utils.ext.resolveName + +private const val MAX_PARALLELISM = 4 + +class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository { + + override val source = MangaSource.LOCAL + private val filenameFilter = CbzFilter() + private val locks = CompositeMutex() + + override suspend fun getList(offset: Int, query: String): List { + if (offset > 0) { + return emptyList() + } + val list = getRawList() + if (query.isNotEmpty()) { + list.retainAll { x -> + x.title.contains(query, ignoreCase = true) || + x.altTitle?.contains(query, ignoreCase = true) == true + } + } + return list + } + + override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List { + if (offset > 0) { + return emptyList() + } + val list = getRawList() + if (!tags.isNullOrEmpty()) { + list.retainAll { x -> + x.tags.containsAll(tags) + } + } + return list + } + + override suspend fun getDetails(manga: Manga) = when { + manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) { + "Manga is not local or saved" + } + else -> getFromFile(Uri.parse(manga.url).toFile()) + } + + override suspend fun getPages(chapter: MangaChapter): List { + return runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(chapter.url) + val file = uri.toFile() + val zip = ZipFile(file) + val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) + var entries = zip.entries().asSequence() + entries = if (index != null) { + val pattern = index.getChapterNamesPattern(chapter) + entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } + } else { + val parent = uri.fragment.orEmpty() + entries.filter { x -> + !x.isDirectory && x.name.substringBeforeLast( + File.separatorChar, + "" + ) == parent + } + } + entries + .toList() + .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) + .map { x -> + val entryUri = zipUri(file, x.name) + MangaPage( + id = entryUri.longHashCode(), + url = entryUri, + preview = null, + referer = chapter.url, + source = MangaSource.LOCAL, + ) + } + } + } + + suspend fun delete(manga: Manga): Boolean { + val file = Uri.parse(manga.url).toFile() + return file.deleteAwait() + } + + suspend fun deleteChapters(manga: Manga, ids: Set) { + lockManga(manga.id) + try { + runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(manga.url) + val file = uri.toFile() + val cbz = CbzMangaOutput(file, manga) + CbzMangaOutput.filterChapters(cbz, ids) + } + } finally { + unlockManga(manga.id) + } + } + + @WorkerThread + @SuppressLint("DefaultLocale") + fun getFromFile(file: File): Manga = ZipFile(file).use { zip -> + val fileUri = file.toUri().toString() + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + val info = index?.getMangaInfo() + if (index != null && info != null) { + return info.copy2( + source = MangaSource.LOCAL, + url = fileUri, + coverUrl = zipUri( + file, + entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty() + ), + chapters = info.chapters?.map { c -> + c.copy(url = fileUri, source = MangaSource.LOCAL) + } + ) + } + // fallback + val title = file.nameWithoutExtension.replace("_", " ").toCamelCase() + val chapters = ArraySet() + for (x in zip.entries()) { + if (!x.isDirectory) { + chapters += x.name.substringBeforeLast(File.separatorChar, "") + } + } + val uriBuilder = file.toUri().buildUpon() + Manga( + id = file.absolutePath.longHashCode(), + title = title, + url = fileUri, + publicUrl = fileUri, + source = MangaSource.LOCAL, + coverUrl = zipUri(file, findFirstImageEntry(zip.entries())?.name.orEmpty()), + chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> + MangaChapter( + id = "$i$s".longHashCode(), + name = s.ifEmpty { title }, + number = i + 1, + source = MangaSource.LOCAL, + uploadDate = 0L, + url = uriBuilder.fragment(s).build().toString(), + scanlator = null, + branch = null, + ) + }, + altTitle = null, + rating = -1f, + isNsfw = false, + tags = setOf(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + ) + } + + suspend fun getRemoteManga(localManga: Manga): Manga? { + val file = runCatching { + Uri.parse(localManga.url).toFile() + }.getOrNull() ?: return null + return runInterruptible(Dispatchers.IO) { + ZipFile(file).use { zip -> + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + index?.getMangaInfo() + } + } + } + + suspend fun findSavedManga(remoteManga: Manga): Manga? { + val files = getAllFiles() + return runInterruptible(Dispatchers.IO) { + for (file in files) { + val index = ZipFile(file).use { zip -> + val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX) + entry?.let(zip::readText)?.let(::MangaIndex) + } ?: continue + val info = index.getMangaInfo() ?: continue + if (info.id == remoteManga.id) { + val fileUri = file.toUri().toString() + return@runInterruptible info.copy2( + source = MangaSource.LOCAL, + url = fileUri, + chapters = info.chapters?.map { c -> c.copy(url = fileUri) } + ) + } + } + null + } + } + + private fun CoroutineScope.getFromFileAsync( + file: File, + context: CoroutineContext, + ): Deferred = async(context) { + runInterruptible { + runCatching { getFromFile(file) }.getOrNull() + } + } + + private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName" + + private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { + val list = entries.toList() + .filterNot { it.isDirectory } + .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) + val map = MimeTypeMap.getSingleton() + return list.firstOrNull { + map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) + ?.startsWith("image/") == true + } + } + + override val sortOrders = setOf(SortOrder.ALPHABETICAL) + + override suspend fun getPageUrl(page: MangaPage) = page.url + + override suspend fun getTags() = emptySet() + + suspend fun import(uri: Uri) { + val contentResolver = storageManager.contentResolver + withContext(Dispatchers.IO) { + val name = contentResolver.resolveName(uri) + ?: throw IOException("Cannot fetch name from uri: $uri") + if (!filenameFilter.isFileSupported(name)) { + throw UnsupportedFileException("Unsupported file on $uri") + } + val dest = File( + getOutputDir() ?: throw IOException("External files dir unavailable"), + name, + ) + runInterruptible { + contentResolver.openInputStream(uri)?.use { source -> + dest.outputStream().use { output -> + source.copyTo(output) + } + } + } ?: throw IOException("Cannot open input stream: $uri") + } + } + + suspend fun getOutputDir(): File? { + return storageManager.getDefaultWriteableDir() + } + + suspend fun cleanup() { + val dirs = storageManager.getWriteableDirs() + runInterruptible(Dispatchers.IO) { + dirs.flatMap { dir -> + dir.listFiles(TempFileFilter())?.toList().orEmpty() + }.forEach { file -> + file.delete() + } + } + } + + suspend fun lockManga(id: Long) { + locks.lock(id) + } + + suspend fun unlockManga(id: Long) { + locks.unlock(id) + } + + private suspend fun getRawList(): ArrayList { + val files = getAllFiles() + return coroutineScope { + val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) + files.map { file -> + getFromFileAsync(file, dispatcher) + }.awaitAll() + }.filterNotNullTo(ArrayList(files.size)) + } + + private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir -> + dir.listFiles(filenameFilter)?.toList().orEmpty() + } + + private fun Manga.copy2( + url: String = this.url, + coverUrl: String = this.coverUrl, + chapters: List? = this.chapters, + source: MangaSource = this.source, + ) = Manga( + id = id, + title = title, + altTitle = altTitle, + url = url, + publicUrl = publicUrl, + rating = rating, + isNsfw = isNsfw, + coverUrl = coverUrl, + tags = tags, + state = state, + author = author, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = chapters, + source = source, + ) + + private fun MangaChapter.copy( + url: String = this.url, + source: MangaSource = this.source, + ) = MangaChapter( + id = id, + name = name, + number = number, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt new file mode 100644 index 000000000..3bc53726c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -0,0 +1,80 @@ +package org.koitharu.kotatsu.local.ui + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga + +class LocalChaptersRemoveService : CoroutineIntentService() { + + private val localMangaRepository by inject() + + override suspend fun processIntent(intent: Intent?) { + val manga = intent?.getParcelableExtra(EXTRA_MANGA)?.manga ?: return + val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return + startForeground() + val mangaWithChapters = localMangaRepository.getDetails(manga) + localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) + sendBroadcast( + Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE) + .putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) + ) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + + private fun startForeground() { + val title = getString(R.string.local_manga_processing) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW) + channel.setShowBadge(false) + channel.enableVibration(false) + channel.setSound(null, null) + channel.enableLights(false) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark)) + .setSilent(true) + .setProgress(0, 0, true) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + companion object { + + private const val CHANNEL_ID = "local_processing" + private const val NOTIFICATION_ID = 21 + + private const val EXTRA_MANGA = "manga" + private const val EXTRA_CHAPTERS_IDS = "chapters_ids" + + fun start(context: Context, manga: Manga, chaptersIds: Collection) { + if (chaptersIds.isEmpty()) { + return + } + val intent = Intent(context, LocalChaptersRemoveService::class.java) + intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) + intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) + ContextCompat.startForegroundService(context, intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt new file mode 100644 index 000000000..4fff18ac6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -0,0 +1,146 @@ +package org.koitharu.kotatsu.local.ui + +import android.content.* +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.view.ActionMode +import androidx.core.net.toFile +import androidx.core.net.toUri +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.progress.Progress + +class LocalListFragment : MangaListFragment(), ActivityResultCallback> { + + override val viewModel by viewModel() + private val importCall = registerForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + this + ) + private var importSnackbar: Snackbar? = null + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) { + viewModel.onRefresh() + } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + context.registerReceiver( + downloadReceiver, + IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE) + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + addMenuProvider(LocalListMenuProvider(this::onEmptyActionClick)) + viewModel.onMangaRemoved.observe(viewLifecycleOwner) { onItemRemoved() } + viewModel.importProgress.observe(viewLifecycleOwner, ::onImportProgressChanged) + } + + override fun onDestroyView() { + importSnackbar = null + super.onDestroyView() + } + + override fun onDetach() { + requireContext().unregisterReceiver(downloadReceiver) + super.onDetach() + } + + override fun onScrolledToEnd() = Unit + + override fun onEmptyActionClick() { + try { + importCall.launch(arrayOf("*/*")) + } catch (e: ActivityNotFoundException) { + e.printStackTraceDebug() + Snackbar.make( + binding.recyclerView, + R.string.operation_not_supported, + Snackbar.LENGTH_SHORT + ).show() + } + } + + override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) { + if (result.isEmpty()) return + viewModel.importFiles(result) + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_local, menu) + return super.onCreateActionMode(mode, menu) + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_remove -> { + showDeletionConfirm(selectedItemsIds, mode) + true + } + R.id.action_share -> { + val files = selectedItems.map { it.url.toUri().toFile() } + ShareHelper(requireContext()).shareCbz(files) + mode.finish() + true + } + else -> super.onActionItemClicked(mode, item) + } + } + + private fun showDeletionConfirm(ids: Set, mode: ActionMode) { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.delete_manga) + .setMessage(getString(R.string.text_delete_local_manga_batch)) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.delete(ids) + mode.finish() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onItemRemoved() { + Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show() + } + + private fun onImportProgressChanged(progress: Progress?) { + if (progress == null) { + importSnackbar?.dismiss() + importSnackbar = null + return + } + val summaryText = getString( + R.string.importing_progress, + progress.value + 1, + progress.total, + ) + importSnackbar?.setText(summaryText) ?: run { + val snackbar = + Snackbar.make(binding.recyclerView, summaryText, Snackbar.LENGTH_INDEFINITE) + importSnackbar = snackbar + snackbar.show() + } + } + + companion object { + + fun newInstance() = LocalListFragment() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt similarity index 69% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt index 882ea4646..ce9941293 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt @@ -1,15 +1,12 @@ package org.koitharu.kotatsu.local.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.storage.directories.MangaDirectoriesActivity class LocalListMenuProvider( - private val context: Context, private val onImportClick: Function0, ) : MenuProvider { @@ -23,13 +20,7 @@ class LocalListMenuProvider( onImportClick() true } - - R.id.action_directories -> { - context.startActivity(MangaDirectoriesActivity.newIntent(context)) - true - } - else -> false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt new file mode 100644 index 000000000..ebd347913 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -0,0 +1,132 @@ +package org.koitharu.kotatsu.local.ui + +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.progress.Progress +import java.io.IOException + +class LocalListViewModel( + private val repository: LocalMangaRepository, + private val historyRepository: HistoryRepository, + settings: AppSettings, +) : MangaListViewModel(settings) { + + val onMangaRemoved = SingleLiveEvent() + val importProgress = MutableLiveData(null) + private val listError = MutableStateFlow(null) + private val mangaList = MutableStateFlow?>(null) + private val headerModel = ListHeader(null, R.string.local_storage, null) + private var importJob: Job? = null + + override val content = combine( + mangaList, + createListModeFlow(), + listError + ) { list, mode, error -> + when { + error != null -> listOf(error.toErrorState(canRetry = true)) + list == null -> listOf(LoadingState) + list.isEmpty() -> listOf( + EmptyState( + icon = R.drawable.ic_empty_local, + textPrimary = R.string.text_local_holder_primary, + textSecondary = R.string.text_local_holder_secondary, + actionStringRes = R.string._import, + ) + ) + else -> ArrayList(list.size + 1).apply { + add(headerModel) + list.toUi(this, mode) + } + } + }.asLiveDataDistinct( + viewModelScope.coroutineContext + Dispatchers.Default, + listOf(LoadingState) + ) + + init { + onRefresh() + cleanup() + } + + override fun onRefresh() { + launchLoadingJob(Dispatchers.Default) { + doRefresh() + } + } + + override fun onRetry() = onRefresh() + + fun importFiles(uris: List) { + val previousJob = importJob + importJob = launchJob(Dispatchers.Default) { + previousJob?.join() + importProgress.postValue(Progress(0, uris.size)) + for ((i, uri) in uris.withIndex()) { + repository.import(uri) + importProgress.postValue(Progress(i + 1, uris.size)) + doRefresh() + } + importProgress.postValue(null) + } + } + + fun delete(ids: Set) { + launchLoadingJob { + withContext(Dispatchers.Default) { + val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } + for (manga in itemsToRemove) { + val original = repository.getRemoteManga(manga) + repository.delete(manga) || throw IOException("Unable to delete file") + runCatching { + historyRepository.deleteOrSwap(manga, original) + } + mangaList.update { list -> + list?.filterNot { it.id == manga.id } + } + } + } + onMangaRemoved.call(Unit) + } + } + + private suspend fun doRefresh() { + try { + listError.value = null + mangaList.value = repository.getList(0, null, null) + } catch (e: Throwable) { + listError.value = e + } + } + + private fun cleanup() { + if (!DownloadService.isRunning) { + viewModelScope.launch { + runCatching { + repository.cleanup() + }.onFailure { error -> + error.printStackTraceDebug() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt new file mode 100644 index 000000000..51aa633ec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.main + +import android.app.Application +import android.os.Build +import androidx.room.InvalidationTracker +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle +import org.koitharu.kotatsu.core.os.ShortcutsUpdater +import org.koitharu.kotatsu.main.ui.MainViewModel +import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper +import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel + +val mainModule + get() = module { + single { AppProtectHelper(get()) } bind Application.ActivityLifecycleCallbacks::class + single { ActivityRecreationHandle() } bind Application.ActivityLifecycleCallbacks::class + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + single { ShortcutsUpdater(androidContext(), get(), get(), get()) } bind InvalidationTracker.Observer::class + } else { + factory { ShortcutsUpdater(androidContext(), get(), get(), get()) } + } + + viewModel { MainViewModel(get(), get()) } + viewModel { ProtectViewModel(get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/AppBarOwner.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/AppBarOwner.kt rename to app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt index 42a7960be..d5a2de5bc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/AppBarOwner.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt @@ -1,8 +1,8 @@ -package org.koitharu.kotatsu.main.ui.owners +package org.koitharu.kotatsu.main.ui import com.google.android.material.appbar.AppBarLayout interface AppBarOwner { val appBar: AppBarLayout -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt new file mode 100644 index 000000000..2c16cdb97 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -0,0 +1,485 @@ +package org.koitharu.kotatsu.main.ui + +import android.app.ActivityOptions +import android.content.res.Configuration +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.result.ActivityResultCallback +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.view.ActionMode +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.view.* +import androidx.drawerlayout.widget.DrawerLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import androidx.transition.TransitionManager +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.LayoutParams.* +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationView +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.prefs.AppSection +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.databinding.ActivityMainBinding +import org.koitharu.kotatsu.databinding.NavigationHeaderBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment +import org.koitharu.kotatsu.history.ui.HistoryListFragment +import org.koitharu.kotatsu.local.ui.LocalListFragment +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.reader.ui.ReaderActivity +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.search.ui.SearchActivity +import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity +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.AppUpdateChecker +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment +import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment +import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment +import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker +import org.koitharu.kotatsu.tracker.ui.FeedFragment +import org.koitharu.kotatsu.tracker.work.TrackWorker +import org.koitharu.kotatsu.utils.VoiceInputContract +import org.koitharu.kotatsu.utils.ext.* +import com.google.android.material.R as materialR + +private const val TAG_PRIMARY = "primary" +private const val TAG_SEARCH = "search" + +class MainActivity : + BaseActivity(), + NavigationView.OnNavigationItemSelectedListener, + AppBarOwner, + View.OnClickListener, + View.OnFocusChangeListener, + SearchSuggestionListener { + + private val viewModel by viewModel() + private val searchSuggestionViewModel by viewModel() + + private lateinit var navHeaderBinding: NavigationHeaderBinding + private var drawerToggle: ActionBarDrawerToggle? = null + private var drawer: DrawerLayout? = null + private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback()) + + override val appBar: AppBarLayout + get() = binding.appbar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityMainBinding.inflate(layoutInflater)) + navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater) + drawer = binding.root as? DrawerLayout + drawerToggle = drawer?.let { + ActionBarDrawerToggle( + this, + it, + binding.toolbar, + R.string.open_menu, + R.string.close_menu + ).apply { + setHomeAsUpIndicator( + ContextCompat.getDrawable(this@MainActivity, materialR.drawable.abc_ic_ab_back_material) + ) + setToolbarNavigationClickListener { + binding.searchView.hideKeyboard() + onBackPressed() + } + it.addDrawerListener(this) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + } + + with(binding.searchView) { + onFocusChangeListener = this@MainActivity + searchSuggestionListener = this@MainActivity + if (drawer == null) { + drawableStart = context.getThemeDrawable(materialR.attr.actionModeWebSearchDrawable) + } + } + + with(binding.navigationView) { + ViewCompat.setOnApplyWindowInsetsListener(this, NavigationViewInsetsListener()) + addHeaderView(navHeaderBinding.root) + setNavigationItemSelectedListener(this@MainActivity) + } + + binding.fab.setOnClickListener(this@MainActivity) + binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null + + supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let { + if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide() + } ?: run { + openDefaultSection() + } + if (savedInstanceState == null) { + onFirstStart() + } + + viewModel.onOpenReader.observe(this, this::onOpenReader) + viewModel.onError.observe(this, this::onError) + viewModel.isLoading.observe(this, this::onLoadingStateChanged) + viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) + viewModel.remoteSources.observe(this, this::updateSideMenu) + viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled) + viewModel.isTrackerEnabled.observe(this, this::setTrackerEnabled) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val isSearchOpened = isSearchOpened() + adjustDrawerLock(isSearchOpened) + if (isSearchOpened) { + binding.toolbarCard.updateLayoutParams { + scrollFlags = SCROLL_FLAG_NO_SCROLL + } + binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant)) + binding.appbar.updatePadding(left = 0, right = 0) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + drawerToggle?.syncState() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + drawerToggle?.onConfigurationChanged(newConfig) + } + + override fun onBackPressed() { + val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) + binding.searchView.clearFocus() + when { + drawer?.isDrawerOpen(binding.navigationView) == true -> { + drawer?.closeDrawer(binding.navigationView) + } + fragment != null -> supportFragmentManager.commit { + remove(fragment) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + runOnCommit { onSearchClosed() } + } + else -> super.onBackPressed() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return drawerToggle?.onOptionsItemSelected(item) == true || when (item.itemId) { + else -> super.onOptionsItemSelected(item) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.fab -> viewModel.openLastReader() + } + } + + override fun onNavigationItemSelected(item: MenuItem): Boolean { + if (item.groupId == R.id.group_remote_sources) { + val source = MangaSource.values().getOrNull(item.itemId) ?: return false + setPrimaryFragment(RemoteListFragment.newInstance(source)) + searchSuggestionViewModel.onSourceChanged(source) + } else { + searchSuggestionViewModel.onSourceChanged(null) + when (item.itemId) { + R.id.nav_history -> { + viewModel.defaultSection = AppSection.HISTORY + setPrimaryFragment(HistoryListFragment.newInstance()) + } + R.id.nav_favourites -> { + viewModel.defaultSection = AppSection.FAVOURITES + setPrimaryFragment(FavouritesContainerFragment.newInstance()) + } + R.id.nav_local_storage -> { + viewModel.defaultSection = AppSection.LOCAL + setPrimaryFragment(LocalListFragment.newInstance()) + } + R.id.nav_suggestions -> { + viewModel.defaultSection = AppSection.SUGGESTIONS + setPrimaryFragment(SuggestionsFragment.newInstance()) + } + R.id.nav_feed -> { + viewModel.defaultSection = AppSection.FEED + setPrimaryFragment(FeedFragment.newInstance()) + } + R.id.nav_action_settings -> { + startActivity(SettingsActivity.newIntent(this)) + return true + } + else -> return false + } + } + drawer?.closeDrawers() + appBar.setExpanded(true) + return true + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.fab.updateLayoutParams { + bottomMargin = insets.bottom + topMargin + } + binding.toolbarCard.updateLayoutParams { + topMargin = insets.top + bottomMargin + leftMargin = insets.left + rightMargin = insets.right + } + binding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + binding.container.updateLayoutParams { + topMargin = -(binding.appbar.measureHeight()) + } + } + + override fun onFocusChange(v: View?, hasFocus: Boolean) { + val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) + if (v?.id == R.id.searchView && hasFocus) { + if (fragment == null) { + supportFragmentManager.commit { + add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + runOnCommit { onSearchOpened() } + } + } + } + } + + override fun onMangaClick(manga: Manga) { + startActivity(DetailsActivity.newIntent(this, manga)) + } + + override fun onQueryClick(query: String, submit: Boolean) { + binding.searchView.query = query + if (submit) { + if (query.isNotEmpty()) { + val source = searchSuggestionViewModel.getLocalSearchSource() + if (source != null) { + startActivity(SearchActivity.newIntent(this, source, query)) + } else { + startActivity(MultiSearchActivity.newIntent(this, query)) + } + searchSuggestionViewModel.saveQuery(query) + } + } + } + + override fun onTagClick(tag: MangaTag) { + startActivity(MangaListActivity.newIntent(this, setOf(tag))) + } + + override fun onQueryChanged(query: String) { + searchSuggestionViewModel.onQueryChanged(query) + } + + override fun onVoiceSearchClick() { + val options = binding.searchView.drawableEnd?.bounds?.let { bounds -> + ActivityOptionsCompat.makeScaleUpAnimation( + binding.searchView, + bounds.centerX(), + bounds.centerY(), + bounds.width(), + bounds.height(), + ) + } + voiceInputLauncher.tryLaunch(binding.searchView.hint?.toString(), options) + } + + override fun onClearSearchHistory() { + MaterialAlertDialogBuilder(this, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) + .setTitle(R.string.clear_search_history) + .setIcon(R.drawable.ic_clear_all) + .setMessage(R.string.text_clear_search_history_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + searchSuggestionViewModel.clearSearchHistory() + }.show() + } + + override fun onSupportActionModeStarted(mode: ActionMode) { + super.onSupportActionModeStarted(mode) + adjustDrawerLock() + } + + override fun onSupportActionModeFinished(mode: ActionMode) { + super.onSupportActionModeFinished(mode) + adjustDrawerLock() + } + + private fun onOpenReader(manga: Manga) { + val options = ActivityOptions.makeScaleUpAnimation(binding.fab, 0, 0, binding.fab.width, binding.fab.height) + startActivity(ReaderActivity.newIntent(this, manga), options?.toBundle()) + } + + private fun onError(e: Throwable) { + Snackbar.make(binding.container, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show() + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + binding.fab.isEnabled = !isLoading + } + + private fun onResumeEnabledChanged(isEnabled: Boolean) { + adjustFabVisibility(isResumeEnabled = isEnabled) + } + + private fun updateSideMenu(remoteSources: List) { + val submenu = binding.navigationView.menu.findItem(R.id.nav_remote_sources).subMenu + submenu.removeGroup(R.id.group_remote_sources) + remoteSources.forEachIndexed { index, source -> + submenu.add(R.id.group_remote_sources, source.ordinal, index, source.title) + .setIcon(R.drawable.ic_manga_source) + } + submenu.setGroupCheckable(R.id.group_remote_sources, true, true) + } + + private fun setSuggestionsEnabled(isEnabled: Boolean) { + val item = binding.navigationView.menu.findItem(R.id.nav_suggestions) ?: return + if (!isEnabled && item.isChecked) { + binding.navigationView.setCheckedItem(R.id.nav_history) + } + item.isVisible = isEnabled + } + + private fun setTrackerEnabled(isEnabled: Boolean) { + val item = binding.navigationView.menu.findItem(R.id.nav_feed) ?: return + if (!isEnabled && item.isChecked) { + binding.navigationView.setCheckedItem(R.id.nav_history) + } + item.isVisible = isEnabled + } + + private fun openDefaultSection() { + when (viewModel.defaultSection) { + AppSection.LOCAL -> { + binding.navigationView.setCheckedItem(R.id.nav_local_storage) + setPrimaryFragment(LocalListFragment.newInstance()) + } + AppSection.FAVOURITES -> { + binding.navigationView.setCheckedItem(R.id.nav_favourites) + setPrimaryFragment(FavouritesContainerFragment.newInstance()) + } + AppSection.HISTORY -> { + binding.navigationView.setCheckedItem(R.id.nav_history) + setPrimaryFragment(HistoryListFragment.newInstance()) + } + AppSection.FEED -> { + binding.navigationView.setCheckedItem(R.id.nav_feed) + setPrimaryFragment(FeedFragment.newInstance()) + } + AppSection.SUGGESTIONS -> { + binding.navigationView.setCheckedItem(R.id.nav_suggestions) + setPrimaryFragment(SuggestionsFragment.newInstance()) + } + } + } + + private fun setPrimaryFragment(fragment: Fragment) { + supportFragmentManager.beginTransaction() + .replace(R.id.container, fragment, TAG_PRIMARY) + .commit() + adjustFabVisibility(topFragment = fragment) + } + + private fun onSearchOpened() { + TransitionManager.beginDelayedTransition(binding.appbar) + binding.toolbarCard.updateLayoutParams { + scrollFlags = SCROLL_FLAG_NO_SCROLL + } + binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant)) + binding.appbar.updatePadding(left = 0, right = 0) + adjustDrawerLock(isSearchOpened = true) + adjustFabVisibility(isSearchOpened = true) + } + + private fun onSearchClosed() { + TransitionManager.beginDelayedTransition(binding.appbar) + binding.toolbarCard.updateLayoutParams { + scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS + } + binding.appbar.background = null + val padding = resources.getDimensionPixelOffset(R.dimen.margin_normal) + binding.appbar.updatePadding(left = padding, right = padding) + adjustDrawerLock(isSearchOpened = false) + adjustFabVisibility(isSearchOpened = false) + } + + private fun isSearchOpened(): Boolean { + return supportFragmentManager.findFragmentByTag(TAG_SEARCH)?.isVisible == true + } + + private fun onFirstStart() { + lifecycleScope.launchWhenResumed { + val isUpdateSupported = withContext(Dispatchers.Default) { + TrackWorker.setup(applicationContext) + SuggestionsWorker.setup(applicationContext) + AppUpdateChecker.isUpdateSupported(this@MainActivity) + } + if (isUpdateSupported) { + AppUpdateChecker(this@MainActivity).checkIfNeeded() + } + val settings = get() + when { + !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) + settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) + } + } + } + + private fun adjustFabVisibility( + isResumeEnabled: Boolean = viewModel.isResumeEnabled.value == true, + topFragment: Fragment? = supportFragmentManager.findFragmentByTag(TAG_PRIMARY), + isSearchOpened: Boolean = isSearchOpened(), + ) { + val fab = binding.fab + if (isResumeEnabled && !isSearchOpened && topFragment is HistoryListFragment) { + if (!fab.isVisible) { + fab.show() + } + } else { + if (fab.isVisible) { + fab.hide() + } + } + } + + private fun adjustDrawerLock( + isSearchOpened: Boolean = isSearchOpened(), + ) { + val drawer = drawer ?: return + val isLocked = actionModeDelegate.isActionModeStarted || isSearchOpened + drawer.setDrawerLockMode( + if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED + ) + drawerToggle?.isDrawerIndicatorEnabled = !isLocked + } + + private inner class VoiceInputCallback : ActivityResultCallback { + + override fun onActivityResult(result: String?) { + if (result != null) { + binding.searchView.query = result + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt new file mode 100644 index 000000000..c3a681343 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -0,0 +1,58 @@ +package org.koitharu.kotatsu.main.ui + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException +import org.koitharu.kotatsu.core.prefs.AppSection +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsLiveData +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct + +class MainViewModel( + private val historyRepository: HistoryRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + val onOpenReader = SingleLiveEvent() + var defaultSection: AppSection + get() = settings.defaultSection + set(value) { + settings.defaultSection = value + } + + val isSuggestionsEnabled = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_SUGGESTIONS, + valueProducer = { isSuggestionsEnabled }, + ) + + val isTrackerEnabled = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_TRACKER_ENABLED, + valueProducer = { isTrackerEnabled }, + ) + + val isResumeEnabled = historyRepository + .observeHasItems() + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + val remoteSources = settings.observe() + .filter { it == AppSettings.KEY_SOURCES_ORDER || it == AppSettings.KEY_SOURCES_HIDDEN } + .onStart { emit("") } + .map { settings.getMangaSources(includeHidden = false) } + .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) + + fun openLastReader() { + launchLoadingJob { + val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() + onOpenReader.call(manga) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/NavigationViewInsetsListener.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/NavigationViewInsetsListener.kt new file mode 100644 index 000000000..f3a66a8fa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/NavigationViewInsetsListener.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.main.ui + +import android.view.View +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import java.lang.ref.WeakReference +import com.google.android.material.R as materialR + +class NavigationViewInsetsListener : OnApplyWindowInsetsListener { + + private var menuViewRef: WeakReference? = null + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val menuView = menuViewRef?.get() ?: v.findViewById(materialR.id.design_navigation_view).also { + menuViewRef = WeakReference(it) + } + val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(top = systemWindowInsets.top) + // NavigationView doesn't dispatch insets to the menu view, so pad the bottom here. + menuView.updatePadding(bottom = systemWindowInsets.bottom) + return WindowInsetsCompat.CONSUMED + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt similarity index 64% rename from app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt rename to app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt index 8a9f8ed3c..a92d866d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt @@ -1,22 +1,17 @@ package org.koitharu.kotatsu.main.ui.protect import android.app.Activity +import android.app.Application import android.content.Intent import android.os.Bundle -import org.acra.dialog.CrashReportDialog import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class AppProtectHelper @Inject constructor(private val settings: AppSettings) : - DefaultActivityLifecycleCallbacks { +class AppProtectHelper(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks { private var isUnlocked = settings.appPassword.isNullOrEmpty() override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - if (!isUnlocked && activity !is ProtectActivity && activity !is CrashReportDialog) { + if (activity !is ProtectActivity && !isUnlocked) { val sourceIntent = Intent(activity, activity.javaClass) activity.intent?.let { sourceIntent.putExtras(it) @@ -28,6 +23,16 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) : } } + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) { if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) { restoreLock() @@ -41,4 +46,4 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) : private fun restoreLock() { isUnlocked = settings.appPassword.isNullOrEmpty() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index ff6bf07bf..0da2ee55f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -10,76 +10,68 @@ import android.view.View import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.widget.TextView -import androidx.activity.viewModels import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.AuthenticationCallback import androidx.core.graphics.Insets -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.databinding.ActivityProtectBinding +import org.koitharu.kotatsu.utils.ext.getDisplayMessage -@AndroidEntryPoint class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, TextWatcher, View.OnClickListener { - private val viewModel by viewModels() + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) setContentView(ActivityProtectBinding.inflate(layoutInflater)) - viewBinding.editPassword.setOnEditorActionListener(this) - viewBinding.editPassword.addTextChangedListener(this) - viewBinding.buttonNext.setOnClickListener(this) - viewBinding.buttonCancel.setOnClickListener(this) + binding.editPassword.setOnEditorActionListener(this) + binding.editPassword.addTextChangedListener(this) + binding.buttonNext.setOnClickListener(this) + binding.buttonCancel.setOnClickListener(this) - viewModel.onError.observeEvent(this, this::onError) + viewModel.onError.observe(this, this::onError) viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.onUnlockSuccess.observeEvent(this) { - val intent = intent.getParcelableExtraCompat(EXTRA_INTENT) + viewModel.onUnlockSuccess.observe(this) { + val intent = intent.getParcelableExtra(EXTRA_INTENT) startActivity(intent) finishAfterTransition() } - } - override fun onStart() { - super.onStart() if (!useFingerprint()) { - viewBinding.editPassword.requestFocus() + binding.editPassword.requestFocus() } } override fun onWindowInsetsChanged(insets: Insets) { val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - viewBinding.root.setPadding( + binding.root.setPadding( basePadding + insets.left, basePadding + insets.top, basePadding + insets.right, - basePadding + insets.bottom, + basePadding + insets.bottom ) } override fun onClick(v: View) { when (v.id) { - R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty()) + R.id.button_next -> viewModel.tryUnlock(binding.editPassword.text?.toString().orEmpty()) R.id.button_cancel -> finish() } } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { - return if (actionId == EditorInfo.IME_ACTION_DONE && viewBinding.buttonNext.isEnabled) { - viewBinding.buttonNext.performClick() + return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) { + binding.buttonNext.performClick() true } else { false @@ -91,16 +83,16 @@ class ProtectActivity : 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() + binding.layoutPassword.error = null + binding.buttonNext.isEnabled = !s.isNullOrEmpty() } private fun onError(e: Throwable) { - viewBinding.layoutPassword.error = e.getDisplayMessage(resources) + binding.layoutPassword.error = e.getDisplayMessage(resources) } private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.layoutPassword.isEnabled = !isLoading + binding.layoutPassword.isEnabled = !isLoading } private fun useFingerprint(): Boolean { @@ -137,4 +129,4 @@ class ProtectActivity : .putExtra(EXTRA_INTENT, sourceIntent) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt similarity index 73% rename from app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt index 7f0d8f5b5..85ffe23cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt @@ -1,27 +1,23 @@ package org.koitharu.kotatsu.main.ui.protect -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.exceptions.WrongPasswordException 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.parsers.util.md5 -import javax.inject.Inject +import org.koitharu.kotatsu.utils.SingleLiveEvent private const val PASSWORD_COMPARE_DELAY = 1_000L -@HiltViewModel -class ProtectViewModel @Inject constructor( +class ProtectViewModel( private val settings: AppSettings, private val protectHelper: AppProtectHelper, ) : BaseViewModel() { private var job: Job? = null - val onUnlockSuccess = MutableEventFlow() + val onUnlockSuccess = SingleLiveEvent() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled @@ -46,4 +42,4 @@ class ProtectViewModel @Inject constructor( protectHelper.unlock() onUnlockSuccess.call(Unit) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt new file mode 100644 index 000000000..5b8faf203 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.reader + +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.reader.ui.PageSaveHelper +import org.koitharu.kotatsu.reader.ui.ReaderViewModel + +val readerModule + get() = module { + + factory { MangaDataRepository(get()) } + single { PagesCache(get()) } + + factory { PageSaveHelper(get(), androidContext()) } + + viewModel { params -> + ReaderViewModel( + intent = params[0], + initialState = params[1], + preselectedBranch = params[2], + dataRepository = get(), + historyRepository = get(), + settings = get(), + pageSaveHelper = get(), + bookmarksRepository = get(), + ) + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/data/ModelMapping.kt b/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/data/ModelMapping.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt new file mode 100644 index 000000000..696f48cc3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -0,0 +1,197 @@ +package org.koitharu.kotatsu.reader.domain + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.collection.LongSparseArray +import androidx.collection.set +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.Closeable +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +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.await +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.ext.connectivityManager +import org.koitharu.kotatsu.utils.progress.ProgressDeferred +import java.io.File +import java.util.* +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile + +private const val PROGRESS_UNDEFINED = -1f +private const val PREFETCH_LIMIT_DEFAULT = 10 + +class PageLoader : KoinComponent, Closeable { + + val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val okHttp = get() + private val cache = get() + private val settings = get() + private val connectivityManager = get().connectivityManager + private val tasks = LongSparseArray>() + private val convertLock = Mutex() + private var repository: MangaRepository? = null + private var prefetchQueue = LinkedList() + private val counter = AtomicInteger(0) + private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive + private val emptyProgressFlow: StateFlow = MutableStateFlow(-1f) + + override fun close() { + loaderScope.cancel() + tasks.clear() + } + + fun isPrefetchApplicable(): Boolean { + return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager) + } + + fun prefetch(pages: List) { + synchronized(prefetchQueue) { + for (page in pages.asReversed()) { + if (tasks.containsKey(page.id)) { + continue + } + prefetchQueue.offerFirst(page.toMangaPage()) + if (prefetchQueue.size > prefetchQueueLimit) { + prefetchQueue.pollLast() + } + } + } + if (counter.get() == 0) { + onIdle() + } + } + + fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { + if (!force) { + cache[page.url]?.let { + return getCompletedTask(it) + } + } + var task = tasks[page.id] + if (force) { + task?.cancel() + } else if (task?.isCancelled == false) { + return task + } + task = loadPageAsyncImpl(page) + tasks[page.id] = task + return task + } + + suspend fun loadPage(page: MangaPage, force: Boolean): File { + return loadPageAsync(page, force).await() + } + + suspend fun convertInPlace(file: File) { + convertLock.withLock { + runInterruptible(Dispatchers.Default) { + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + file.outputStream().use { out -> + image.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } finally { + image.recycle() + } + } + } + } + + suspend fun getPageUrl(page: MangaPage): String { + return getRepository(page.source).getPageUrl(page) + } + + private fun onIdle() { + synchronized(prefetchQueue) { + while (prefetchQueue.isNotEmpty()) { + val page = prefetchQueue.pollFirst() ?: return + if (cache[page.url] == null) { + tasks[page.id] = loadPageAsyncImpl(page) + return + } + } + } + } + + private fun loadPageAsyncImpl(page: MangaPage): ProgressDeferred { + val progress = MutableStateFlow(PROGRESS_UNDEFINED) + val deferred = loaderScope.async { + counter.incrementAndGet() + try { + loadPageImpl(page, progress) + } finally { + if (counter.decrementAndGet() == 0) { + onIdle() + } + } + } + return ProgressDeferred(deferred, progress) + } + + @Synchronized + private fun getRepository(source: MangaSource): MangaRepository { + val result = repository + return if (result != null && result.source == source) { + result + } else { + MangaRepository(source).also { repository = it } + } + } + + private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): File { + val pageUrl = getPageUrl(page) + check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } + val uri = Uri.parse(pageUrl) + return if (uri.scheme == "cbz") { + runInterruptible(Dispatchers.IO) { + val zip = ZipFile(uri.schemeSpecificPart) + val entry = zip.getEntry(uri.fragment) + zip.getInputStream(entry).use { + cache.put(pageUrl, it) + } + } + } else { + val request = Request.Builder() + .url(pageUrl) + .get() + .header(CommonHeaders.REFERER, page.referer) + .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") + .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) + .build() + okHttp.newCall(request).await().use { response -> + check(response.isSuccessful) { + "Invalid response: ${response.code} ${response.message}" + } + val body = checkNotNull(response.body) { + "Null response" + } + runInterruptible(Dispatchers.IO) { + body.byteStream().use { + cache.put(pageUrl, it, body.contentLength(), progress) + } + } + } + } + } + + private fun getCompletedTask(file: File): ProgressDeferred { + val deferred = CompletableDeferred(file) + return ProgressDeferred(deferred, emptyProgressFlow) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt new file mode 100644 index 000000000..99f173aeb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersBottomSheet.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.reader.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.databinding.SheetChaptersBinding +import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.details.ui.model.toListItem +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback +import org.koitharu.kotatsu.utils.ext.withArgs +import kotlin.math.roundToInt + +class ChaptersBottomSheet : BaseBottomSheet(), OnListItemClickListener { + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding { + return SheetChaptersBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { dismiss() } + behavior?.addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + if (!resources.getBoolean(R.bool.is_tablet)) { + binding.toolbar.navigationIcon = null + } + val chapters = arguments?.getParcelable(ARG_CHAPTERS)?.chapters + if (chapters.isNullOrEmpty()) { + dismissAllowingStateLoss() + return + } + val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L) + val currentPosition = chapters.indexOfFirst { it.id == currentId } + val dateFormat = get().getDateFormat() + val items = chapters.mapIndexed { index, chapter -> + chapter.toListItem( + isCurrent = index == currentPosition, + isUnread = index > currentPosition, + isNew = false, + isMissing = false, + isDownloaded = false, + dateFormat = dateFormat, + ) + } + binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> + if (currentPosition >= 0) { + val targetPosition = (currentPosition - 1).coerceAtLeast(0) + val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() + adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset)) + } else { + adapter.items = items + } + } + } + + override fun onItemClick(item: ChapterListItem, view: View) { + ((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let { + dismiss() + it.onChapterChanged(item.chapter) + } + } + + fun interface OnChapterChangeListener { + + fun onChapterChanged(chapter: MangaChapter) + } + + companion object { + + private const val ARG_CHAPTERS = "chapters" + private const val ARG_CURRENT_ID = "current_id" + + private const val TAG = "ChaptersBottomSheet" + + fun show( + fm: FragmentManager, + chapters: List, + currentId: Long, + ) = ChaptersBottomSheet().withArgs(2) { + putParcelable(ARG_CHAPTERS, ParcelableMangaChapters(chapters)) + putLong(ARG_CURRENT_ID, currentId) + }.show(fm, TAG) + + private fun List.asArrayList(): ArrayList { + return this as? ArrayList ?: ArrayList(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index b47a662a9..3e19c7036 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -1,36 +1,30 @@ 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.ActivityResultLauncher -import androidx.core.net.toUri -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrl import okio.IOException -import okio.buffer -import okio.sink -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.core.util.ext.toFileOrNull -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable +import org.koitharu.kotatsu.base.domain.MangaUtils +import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.reader.domain.PageLoader import java.io.File -import javax.inject.Inject import kotlin.coroutines.Continuation import kotlin.coroutines.resume private const val MAX_FILENAME_LENGTH = 10 private const val EXTENSION_FALLBACK = "png" -class PageSaveHelper @Inject constructor( - @ApplicationContext context: Context, +class PageSaveHelper( + private val cache: PagesCache, + context: Context, ) { private var continuation: Continuation? = null @@ -42,10 +36,10 @@ class PageSaveHelper @Inject constructor( saveLauncher: ActivityResultLauncher, ): Uri { val pageUrl = pageLoader.getPageUrl(page) - val pageUri = pageLoader.loadPage(page, force = false) - val proposedName = getProposedFileName(pageUrl, pageUri) + val pageFile = pageLoader.loadPage(page, force = false) + val proposedName = getProposedFileName(pageUrl, pageFile) val destination = withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> + suspendCancellableCoroutine { cont -> continuation = cont saveLauncher.launch(proposedName) }.also { @@ -53,12 +47,12 @@ class PageSaveHelper @Inject constructor( } } runInterruptible(Dispatchers.IO) { - contentResolver.openOutputStream(destination)?.sink()?.buffer() - }?.use { output -> - pageUri.source().use { input -> - output.writeAllCancellable(input) - } - } ?: throw IOException("Output stream is null") + contentResolver.openOutputStream(destination)?.use { output -> + pageFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: throw IOException("Output stream is null") + } return destination } @@ -66,16 +60,12 @@ class PageSaveHelper @Inject constructor( resume(uri) } != null - private suspend fun getProposedFileName(url: String, fileUri: Uri): String { - var name = if (url.startsWith("cbz://")) { - requireNotNull(url.toUri().fragment) - } else { - url.toHttpUrl().pathSegments.last() - } + private suspend fun getProposedFileName(url: String, file: File): String { + var name = url.toHttpUrl().pathSegments.last() var extension = name.substringAfterLast('.', "") name = name.substringBeforeLast('.') if (extension.length !in 2..4) { - val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) } + val mimeType = MangaUtils.getImageMimeType(file) extension = if (mimeType != null) { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK } else { @@ -84,12 +74,4 @@ class PageSaveHelper @Inject constructor( } return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension } - - 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 - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt new file mode 100644 index 000000000..35d203b26 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -0,0 +1,430 @@ +package org.koitharu.kotatsu.reader.ui + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.* +import androidx.activity.result.ActivityResultCallback +import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.transition.Slide +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.databinding.ActivityReaderBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener +import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.utils.GridTouchHelper +import org.koitharu.kotatsu.utils.ScreenOrientationHelper +import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.* +import java.util.concurrent.TimeUnit + +class ReaderActivity : + BaseFullscreenActivity(), + ChaptersBottomSheet.OnChapterChangeListener, + GridTouchHelper.OnGridTouchListener, + OnPageSelectListener, + ReaderConfigDialog.Callback, + ActivityResultCallback, + ReaderControlDelegate.OnInteractionListener, + OnApplyWindowInsetsListener { + + private val viewModel by viewModel { + parametersOf( + MangaIntent(intent), + intent?.getParcelableExtra(EXTRA_STATE), + intent?.getStringExtra(EXTRA_BRANCH), + ) + } + + private lateinit var touchHelper: GridTouchHelper + private lateinit var orientationHelper: ScreenOrientationHelper + private lateinit var controlDelegate: ReaderControlDelegate + private val savePageRequest = registerForActivityResult(PageSaveContract(), this) + private var gestureInsets: Insets = Insets.NONE + private lateinit var readerManager: ReaderManager + private val hideUiRunnable = Runnable { setUiIsVisible(false) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityReaderBinding.inflate(layoutInflater)) + readerManager = ReaderManager(supportFragmentManager, R.id.container) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + touchHelper = GridTouchHelper(this, this) + orientationHelper = ScreenOrientationHelper(this) + controlDelegate = ReaderControlDelegate(lifecycleScope, get(), this) + binding.toolbarBottom.inflateMenu(R.menu.opt_reader_bottom) + binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) + insetsDelegate.interceptingWindowInsetsListener = this + + orientationHelper.observeAutoOrientation() + .flowWithLifecycle(lifecycle) + .onEach { + binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it + }.launchIn(lifecycleScope) + + viewModel.onError.observe(this, this::onError) + viewModel.readerMode.observe(this, this::onInitReader) + viewModel.onPageSaved.observe(this, this::onPageSaved) + viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged) + viewModel.isLoading.observe(this, this::onLoadingStateChanged) + viewModel.content.observe(this) { + onLoadingStateChanged(viewModel.isLoading.value == true) + } + viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) + viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) + viewModel.onShowToast.observe(this) { msgId -> + Snackbar.make(binding.container, msgId, Snackbar.LENGTH_SHORT) + .setAnchorView(binding.appbarBottom) + .show() + } + } + + private fun onInitReader(mode: ReaderMode) { + if (readerManager.currentMode != mode) { + readerManager.replace(mode) + } + val iconRes = when (mode) { + ReaderMode.WEBTOON -> R.drawable.ic_script + ReaderMode.REVERSED -> R.drawable.ic_read_reversed + ReaderMode.STANDARD -> R.drawable.ic_book_page + } + binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).run { + setIcon(iconRes) + setVisible(true) + } + if (binding.appbarTop.isVisible) { + lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1)) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.opt_reader_top, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_reader_mode -> { + val currentMode = readerManager.currentMode ?: return false + ReaderConfigDialog.show(supportFragmentManager, currentMode) + } + R.id.action_settings -> { + startActivity(SettingsActivity.newReaderSettingsIntent(this)) + } + R.id.action_chapters -> { + ChaptersBottomSheet.show( + supportFragmentManager, + viewModel.manga?.chapters.orEmpty(), + viewModel.getCurrentState()?.chapterId ?: 0L + ) + } + R.id.action_screen_rotate -> { + orientationHelper.toggleOrientation() + } + R.id.action_pages_thumbs -> { + val pages = viewModel.getCurrentChapterPages() + if (!pages.isNullOrEmpty()) { + PagesThumbnailsSheet.show( + supportFragmentManager, + pages, + title?.toString().orEmpty(), + readerManager.currentReader?.getCurrentState()?.page ?: -1, + ) + } else { + return false + } + } + R.id.action_save_page -> { + viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) + val page = viewModel.getCurrentPage() ?: return false + viewModel.saveCurrentPage(page, savePageRequest) + } + R.id.action_bookmark -> { + if (viewModel.isBookmarkAdded.value == true) { + viewModel.removeBookmark() + } else { + viewModel.addBookmark() + } + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + override fun onActivityResult(uri: Uri?) { + viewModel.onActivityResult(uri) + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + val hasPages = !viewModel.content.value?.pages.isNullOrEmpty() + binding.layoutLoading.isVisible = isLoading && !hasPages + if (isLoading && hasPages) { + binding.toastView.show(R.string.loading_) + } else { + binding.toastView.hide() + } + val menu = binding.toolbarBottom.menu + menu.findItem(R.id.action_bookmark).isVisible = hasPages + menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages + menu.findItem(R.id.action_save_page).isVisible = hasPages + } + + private fun onError(e: Throwable) { + val listener = ErrorDialogListener(e) + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.error_occurred) + .setMessage(e.getDisplayMessage(resources)) + .setNegativeButton(R.string.close, listener) + .setOnCancelListener(listener) + val resolveTextId = ExceptionResolver.getResolveStringId(e) + if (resolveTextId != 0) { + dialog.setPositiveButton(resolveTextId, listener) + } else { + dialog.setPositiveButton(R.string.report, listener) + } + dialog.show() + } + + override fun onGridTouch(area: Int) { + controlDelegate.onGridTouch(area, binding.container) + } + + override fun onProcessTouch(rawX: Int, rawY: Int): Boolean { + return if ( + rawX <= gestureInsets.left || + rawY <= gestureInsets.top || + rawX >= binding.root.width - gestureInsets.right || + rawY >= binding.root.height - gestureInsets.bottom || + binding.appbarTop.hasGlobalPoint(rawX, rawY) || + binding.appbarBottom?.hasGlobalPoint(rawX, rawY) == true + ) { + false + } else { + val touchables = window.peekDecorView()?.touchables + touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true + } + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + touchHelper.dispatchTouchEvent(ev) + return super.dispatchTouchEvent(ev) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) + } + + override fun onChapterChanged(chapter: MangaChapter) { + viewModel.switchChapter(chapter.id) + } + + override fun onPageSelected(page: MangaPage) { + lifecycleScope.launch(Dispatchers.Default) { + val pages = viewModel.content.value?.pages ?: return@launch + val index = pages.indexOfFirst { it.id == page.id } + if (index != -1) { + withContext(Dispatchers.Main) { + readerManager.currentReader?.switchPageTo(index, true) + } + } + } + } + + override fun onReaderModeChanged(mode: ReaderMode) { + viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) + viewModel.switchMode(mode) + } + + private fun onPageSaved(uri: Uri?) { + if (uri != null) { + Snackbar.make(binding.container, R.string.page_saved, Snackbar.LENGTH_LONG) + .setAnchorView(binding.appbarBottom) + .setAction(R.string.share) { + ShareHelper(this).shareImage(uri) + }.show() + } else { + Snackbar.make(binding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT) + .setAnchorView(binding.appbarBottom) + .show() + } + } + + private fun setWindowSecure(isSecure: Boolean) { + if (isSecure) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + private fun setUiIsVisible(isUiVisible: Boolean) { + if (binding.appbarTop.isVisible != isUiVisible) { + val transition = TransitionSet() + .setOrdering(TransitionSet.ORDERING_TOGETHER) + .addTransition(Slide(Gravity.TOP).addTarget(binding.appbarTop)) + binding.appbarBottom?.let { bottomBar -> + transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar)) + } + TransitionManager.beginDelayedTransition(binding.root, transition) + binding.appbarTop.isVisible = isUiVisible + binding.appbarBottom?.isVisible = isUiVisible + if (isUiVisible) { + showSystemUI() + } else { + hideSystemUI() + } + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures()) + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + binding.appbarTop.updatePadding( + top = systemBars.top, + right = systemBars.right, + left = systemBars.left + ) + binding.appbarBottom?.updatePadding( + bottom = systemBars.bottom, + right = systemBars.right, + left = systemBars.left + ) + return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() + } + + override fun onWindowInsetsChanged(insets: Insets) = Unit + + override fun switchPageBy(delta: Int) { + readerManager.currentReader?.switchPageBy(delta) + } + + override fun toggleUiVisibility() { + setUiIsVisible(!binding.appbarTop.isVisible) + } + + private fun onBookmarkStateChanged(isAdded: Boolean) { + val menuItem = binding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return + menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add) + menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) + } + + private fun onUiStateChanged(uiState: ReaderUiState?, previous: ReaderUiState?) { + title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_) + supportActionBar?.subtitle = if (uiState != null && uiState.chapterNumber in 1..uiState.chaptersTotal) { + getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal) + } else { + null + } + if (uiState != null && previous?.chapterName != null && uiState.chapterName != previous.chapterName) { + if (!uiState.chapterName.isNullOrEmpty()) { + binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) + } + } + } + + private inner class ErrorDialogListener( + private val exception: Throwable, + ) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener { + + override fun onClick(dialog: DialogInterface?, which: Int) { + if (which == DialogInterface.BUTTON_POSITIVE) { + dialog?.dismiss() + if (ExceptionResolver.canResolve(exception)) { + tryResolve(exception) + } else { + exception.report("ReaderActivity::onError") + } + } else { + onCancel(dialog) + } + } + + override fun onCancel(dialog: DialogInterface?) { + if (viewModel.content.value?.pages.isNullOrEmpty()) { + finishAfterTransition() + } + } + + private fun tryResolve(e: Throwable) { + lifecycleScope.launch { + if (exceptionResolver.resolve(e)) { + viewModel.reload() + } else { + onCancel(null) + } + } + } + } + + companion object { + + const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" + private const val EXTRA_STATE = "state" + private const val EXTRA_BRANCH = "branch" + private const val TOAST_DURATION = 1500L + + fun newIntent(context: Context, manga: Manga): Intent { + return Intent(context, ReaderActivity::class.java) + .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + } + + fun newIntent(context: Context, manga: Manga, branch: String?): Intent { + return Intent(context, ReaderActivity::class.java) + .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + .putExtra(EXTRA_BRANCH, branch) + } + + fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent { + return Intent(context, ReaderActivity::class.java) + .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true)) + .putExtra(EXTRA_STATE, state) + } + + fun newIntent(context: Context, bookmark: Bookmark): Intent { + val state = ReaderState(bookmark.chapterId, bookmark.page, bookmark.scroll) + return newIntent(context, bookmark.manga, state) + } + + fun newIntent(context: Context, mangaId: Long): Intent { + return Intent(context, ReaderActivity::class.java) + .putExtra(MangaIntent.KEY_ID, mangaId) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt new file mode 100644 index 000000000..85f326c68 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderConfigDialog.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.reader.ui + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.databinding.DialogReaderConfigBinding +import org.koitharu.kotatsu.utils.ext.withArgs + +class ReaderConfigDialog : AlertDialogFragment(), + CheckableButtonGroup.OnCheckedChangeListener { + + private lateinit var mode: ReaderMode + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = DialogReaderConfigBinding.inflate(inflater, container, false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mode = arguments?.getInt(ARG_MODE) + ?.let { ReaderMode.valueOf(it) } + ?: ReaderMode.STANDARD + } + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setTitle(R.string.read_mode) + .setPositiveButton(R.string.done, null) + .setCancelable(true) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD + binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED + binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON + + binding.checkableGroup.onCheckedChangeListener = this + } + + override fun onDismiss(dialog: DialogInterface) { + ((parentFragment as? Callback) + ?: (activity as? Callback))?.onReaderModeChanged(mode) + super.onDismiss(dialog) + } + + override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { + mode = when (checkedId) { + R.id.button_standard -> ReaderMode.STANDARD + R.id.button_webtoon -> ReaderMode.WEBTOON + R.id.button_reversed -> ReaderMode.REVERSED + else -> return + } + } + + interface Callback { + + fun onReaderModeChanged(mode: ReaderMode) + } + + companion object { + + private const val TAG = "ReaderConfigDialog" + private const val ARG_MODE = "mode" + + fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigDialog().withArgs(1) { + putInt(ARG_MODE, mode.id) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderContent.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderContent.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderContent.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderContent.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt index a936c8bb2..dbe853894 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt @@ -1,43 +1,33 @@ package org.koitharu.kotatsu.reader.ui -import android.content.SharedPreferences -import android.content.res.Resources import android.view.KeyEvent import android.view.SoundEffectConstants import android.view.View -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import org.koitharu.kotatsu.R +import androidx.lifecycle.LifecycleCoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ReaderMode -import org.koitharu.kotatsu.core.util.GridTouchHelper +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.utils.GridTouchHelper class ReaderControlDelegate( - resources: Resources, - private val settings: AppSettings, - private val listener: OnInteractionListener, - owner: LifecycleOwner, -) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener { + scope: LifecycleCoroutineScope, + settings: AppSettings, + private val listener: OnInteractionListener +) { private var isTapSwitchEnabled: Boolean = true private var isVolumeKeysSwitchEnabled: Boolean = false - private var isReaderTapsAdaptive: Boolean = true - private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min) init { - owner.lifecycle.addObserver(this) - settings.subscribe(this) - updateSettings() - } - - override fun onDestroy(owner: LifecycleOwner) { - settings.unsubscribe(this) - owner.lifecycle.removeObserver(this) - super.onDestroy(owner) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - updateSettings() + settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch } + .flowOn(Dispatchers.Default) + .onEach { + isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it + isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it + }.launchIn(scope) } fun onGridTouch(area: Int, view: View) { @@ -46,24 +36,20 @@ class ReaderControlDelegate( listener.toggleUiVisibility() view.playSoundEffect(SoundEffectConstants.CLICK) } - GridTouchHelper.AREA_TOP -> if (isTapSwitchEnabled) { listener.switchPageBy(-1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP) } - GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) { - listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1) + listener.switchPageBy(-1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT) } - GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) { listener.switchPageBy(1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN) } - GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) { - listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1) + listener.switchPageBy(1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT) } } @@ -76,58 +62,31 @@ class ReaderControlDelegate( } else { false } - KeyEvent.KEYCODE_VOLUME_DOWN -> if (isVolumeKeysSwitchEnabled) { listener.switchPageBy(1) true } else { false } - KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_PAGE_DOWN, - -> { - listener.switchPageBy(1) - true - } - + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { - listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1) + listener.switchPageBy(1) true } - KeyEvent.KEYCODE_PAGE_UP, - -> { - listener.switchPageBy(-1) - true - } - + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { - listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1) + listener.switchPageBy(-1) true } - KeyEvent.KEYCODE_DPAD_CENTER -> { listener.toggleUiVisibility() true } - - KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, - KeyEvent.KEYCODE_DPAD_UP -> { - if (!listener.scrollBy(-minScrollDelta, smooth = true)) { - listener.switchPageBy(-1) - } - true - } - - KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, - KeyEvent.KEYCODE_DPAD_DOWN -> { - if (!listener.scrollBy(minScrollDelta, smooth = true)) { - listener.switchPageBy(1) - } - true - } - else -> false } @@ -138,27 +97,10 @@ class ReaderControlDelegate( ) } - private fun updateSettings() { - val switch = settings.readerPageSwitch - isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in switch - isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in switch - isReaderTapsAdaptive = settings.isReaderTapsAdaptive - } - - private fun isReaderTapsReversed(): Boolean { - return isReaderTapsAdaptive && listener.readerMode == ReaderMode.REVERSED - } - interface OnInteractionListener { - val readerMode: ReaderMode? - fun switchPageBy(delta: Int) - fun scrollBy(delta: Int, smooth: Boolean): Boolean - fun toggleUiVisibility() - - fun isReaderResumed(): Boolean } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt similarity index 73% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt index e454a7106..c5497fe8a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt @@ -4,18 +4,18 @@ import androidx.annotation.IdRes import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit import org.koitharu.kotatsu.core.prefs.ReaderMode -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment +import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment -import java.util.EnumMap +import java.util.* class ReaderManager( private val fragmentManager: FragmentManager, @IdRes private val containerResId: Int, ) { - private val modeMap = EnumMap>>(ReaderMode::class.java) + private val modeMap = EnumMap>>(ReaderMode::class.java) init { modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java @@ -23,8 +23,8 @@ class ReaderManager( modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java } - val currentReader: BaseReaderFragment<*>? - get() = fragmentManager.findFragmentById(containerResId) as? BaseReaderFragment<*> + val currentReader: BaseReader<*>? + get() = fragmentManager.findFragmentById(containerResId) as? BaseReader<*> val currentMode: ReaderMode? get() { @@ -35,15 +35,11 @@ class ReaderManager( fun replace(newMode: ReaderMode) { val readerClass = requireNotNull(modeMap[newMode]) fragmentManager.commit { - setReorderingAllowed(true) replace(containerResId, readerClass, null, null) } } - fun replace(reader: BaseReaderFragment<*>) { - fragmentManager.commit { - setReorderingAllowed(true) - replace(containerResId, reader) - } + fun replace(reader: BaseReader<*>) { + fragmentManager.commit { replace(containerResId, reader) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderState.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt new file mode 100644 index 000000000..895bb6628 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -0,0 +1,404 @@ +package org.koitharu.kotatsu.reader.ui + +import android.net.Uri +import android.util.LongSparseArray +import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.domain.MangaUtils +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.* +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.history.domain.PROGRESS_NONE +import org.koitharu.kotatsu.parsers.exception.NotFoundException +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.data.filterChapters +import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import org.koitharu.kotatsu.utils.ext.processLifecycleScope +import java.util.* + +private const val BOUNDS_PAGE_OFFSET = 2 +private const val PAGES_TRIM_THRESHOLD = 120 +private const val PREFETCH_LIMIT = 10 + +class ReaderViewModel( + private val intent: MangaIntent, + initialState: ReaderState?, + private val preselectedBranch: String?, + private val dataRepository: MangaDataRepository, + private val historyRepository: HistoryRepository, + private val bookmarksRepository: BookmarksRepository, + private val settings: AppSettings, + private val pageSaveHelper: PageSaveHelper, +) : BaseViewModel() { + + private var loadingJob: Job? = null + private var pageSaveJob: Job? = null + private var bookmarkJob: Job? = null + private val currentState = MutableStateFlow(initialState) + private val mangaData = MutableStateFlow(intent.manga) + private val chapters = LongSparseArray() + + val pageLoader = PageLoader() + + val readerMode = MutableLiveData() + val onPageSaved = SingleLiveEvent() + val onShowToast = SingleLiveEvent() + val uiState = combine( + mangaData, + currentState, + ) { manga, state -> + val chapter = state?.chapterId?.let(chapters::get) + ReaderUiState( + mangaName = manga?.title, + chapterName = chapter?.name, + chapterNumber = chapter?.number ?: 0, + chaptersTotal = chapters.size() + ) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null) + + val content = MutableLiveData(ReaderContent(emptyList(), null)) + val manga: Manga? + get() = mangaData.value + + val readerAnimation = settings.observeAsLiveData( + context = viewModelScope.coroutineContext + Dispatchers.Default, + key = AppSettings.KEY_READER_ANIMATION, + valueProducer = { readerAnimation } + ) + + val isScreenshotsBlockEnabled = combine( + mangaData, + settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }, + ) { manga, policy -> + policy == ScreenshotsPolicy.BLOCK_ALL || + (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) + + val onZoomChanged = SingleLiveEvent() + + val isBookmarkAdded: LiveData = currentState.flatMapLatest { state -> + val manga = mangaData.value + if (state == null || manga == null) { + flowOf(false) + } else { + bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) + .map { it != null } + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false) + + init { + loadImpl() + subscribeToSettings() + } + + override fun onCleared() { + pageLoader.close() + super.onCleared() + } + + fun reload() { + loadingJob?.cancel() + loadImpl() + } + + fun switchMode(newMode: ReaderMode) { + launchJob { + val manga = checkNotNull(mangaData.value) + dataRepository.savePreferences( + manga = manga, + mode = newMode + ) + readerMode.value = newMode + content.value?.run { + content.value = copy( + state = getCurrentState() + ) + } + } + } + + // TODO check performance + fun saveCurrentState(state: ReaderState? = null) { + if (state != null) { + currentState.value = state + } + val readerState = state ?: currentState.value ?: return + historyRepository.saveStateAsync( + manga = mangaData.value ?: return, + state = readerState, + percent = computePercent(readerState.chapterId, readerState.page), + ) + } + + fun getCurrentState() = currentState.value + + fun getCurrentChapterPages(): List? { + val chapterId = currentState.value?.chapterId ?: return null + val pages = content.value?.pages ?: return null + return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } + } + + fun saveCurrentPage( + page: MangaPage, + saveLauncher: ActivityResultLauncher, + ) { + val prevJob = pageSaveJob + pageSaveJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + try { + val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) + onPageSaved.postCall(dest) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e.printStackTraceDebug() + onPageSaved.postCall(null) + } + } + } + + fun onActivityResult(uri: Uri?) { + if (uri != null) { + pageSaveHelper.onActivityResult(uri) + } else { + pageSaveJob?.cancel() + pageSaveJob = null + } + } + + fun getCurrentPage(): MangaPage? { + val state = currentState.value ?: return null + return content.value?.pages?.find { + it.chapterId == state.chapterId && it.index == state.page + }?.toMangaPage() + } + + fun switchChapter(id: Long) { + val prevJob = loadingJob + loadingJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + content.postValue(ReaderContent(emptyList(), null)) + val newPages = loadChapter(id) + content.postValue(ReaderContent(newPages, ReaderState(id, 0, 0))) + } + } + + fun onCurrentPageChanged(position: Int) { + val pages = content.value?.pages ?: return + pages.getOrNull(position)?.let { page -> + currentState.update { cs -> + cs?.copy(chapterId = page.chapterId, page = page.index) + } + } + if (pages.isEmpty() || loadingJob?.isActive == true) { + return + } + if (position <= BOUNDS_PAGE_OFFSET) { + loadPrevNextChapter(pages.first().chapterId, -1) + } + if (position >= pages.size - BOUNDS_PAGE_OFFSET) { + loadPrevNextChapter(pages.last().chapterId, 1) + } + if (pageLoader.isPrefetchApplicable()) { + pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) + } + } + + fun addBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob(Dispatchers.Default) { + loadingJob?.join() + val state = checkNotNull(currentState.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + val bookmark = Bookmark( + manga = checkNotNull(mangaData.value), + pageId = page.id, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll, + imageUrl = page.preview ?: pageLoader.getPageUrl(page), + createdAt = Date(), + percent = computePercent(state.chapterId, state.page), + ) + bookmarksRepository.addBookmark(bookmark) + onShowToast.postCall(R.string.bookmark_added) + } + } + + fun removeBookmark() { + if (bookmarkJob?.isActive == true) { + return + } + bookmarkJob = launchJob { + loadingJob?.join() + val manga = checkNotNull(mangaData.value) + val page = checkNotNull(getCurrentPage()) { "Page not found" } + bookmarksRepository.removeBookmark(manga.id, page.id) + onShowToast.call(R.string.bookmark_removed) + } + } + + private fun loadImpl() { + loadingJob = launchLoadingJob(Dispatchers.Default) { + var manga = dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "") + mangaData.value = manga + val repo = MangaRepository(manga.source) + manga = repo.getDetails(manga) + manga.chapters?.forEach { + chapters.put(it.id, it) + } + // determine mode + val mode = detectReaderMode(manga, repo) + // obtain state + if (currentState.value == null) { + currentState.value = historyRepository.getOne(manga)?.let { + ReaderState(it) + } ?: ReaderState(manga, preselectedBranch) + } + + val branch = chapters[currentState.value?.chapterId ?: 0L].branch + mangaData.value = manga.filterChapters(branch) + readerMode.postValue(mode) + + val pages = loadChapter(requireNotNull(currentState.value).chapterId) + // save state + currentState.value?.let { + val percent = computePercent(it.chapterId, it.page) + historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) + } + + content.postValue(ReaderContent(pages, currentState.value)) + } + } + + private suspend fun loadChapter(chapterId: Long): List { + val manga = checkNotNull(mangaData.value) { "Manga is null" } + val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } + val repo = MangaRepository(manga.source) + return repo.getPages(chapter).mapIndexed { index, page -> + ReaderPage(page, index, chapterId) + } + } + + private fun loadPrevNextChapter(currentId: Long, delta: Int) { + loadingJob = launchLoadingJob(Dispatchers.Default) { + val chapters = mangaData.value?.chapters ?: return@launchLoadingJob + val predicate: (MangaChapter) -> Boolean = { it.id == currentId } + val index = + if (delta < 0) chapters.indexOfLast(predicate) else chapters.indexOfFirst(predicate) + if (index == -1) return@launchLoadingJob + val newChapter = chapters.getOrNull(index + delta) ?: return@launchLoadingJob + val newPages = loadChapter(newChapter.id) + var currentPages = content.value?.pages ?: return@launchLoadingJob + // trim pages + if (currentPages.size > PAGES_TRIM_THRESHOLD) { + val firstChapterId = currentPages.first().chapterId + val lastChapterId = currentPages.last().chapterId + if (firstChapterId != lastChapterId) { + currentPages = when (delta) { + 1 -> currentPages.dropWhile { it.chapterId == firstChapterId } + -1 -> currentPages.dropLastWhile { it.chapterId == lastChapterId } + else -> currentPages + } + } + } + val pages = when (delta) { + 0 -> newPages + -1 -> newPages + currentPages + 1 -> currentPages + newPages + else -> error("Invalid delta $delta") + } + content.postValue(ReaderContent(pages, null)) + } + } + + private fun subscribeToSettings() { + settings.observe() + .onEach { key -> + if (key == AppSettings.KEY_ZOOM_MODE) onZoomChanged.postCall(Unit) + }.launchIn(viewModelScope + Dispatchers.Default) + } + + private fun List.trySublist(fromIndex: Int, toIndex: Int): List { + val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) + val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) + return if (fromIndexBounded == toIndexBounded) { + emptyList() + } else { + subList(fromIndexBounded, toIndexBounded) + } + } + + private suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode { + dataRepository.getReaderMode(manga.id)?.let { return it } + val defaultMode = settings.defaultReaderMode + if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { + return defaultMode + } + val chapter = currentState.value?.chapterId?.let(chapters::get) + ?: manga.chapters?.randomOrNull() + ?: error("There are no chapters in this manga") + val pages = repo.getPages(chapter) + return runCatching { + val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages) + if (isWebtoon) ReaderMode.WEBTOON else defaultMode + }.onSuccess { + dataRepository.savePreferences(manga, it) + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(defaultMode) + } + + private fun computePercent(chapterId: Long, pageIndex: Int): Float { + val chapters = manga?.chapters ?: return PROGRESS_NONE + val chaptersCount = chapters.size + val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } + val pages = content.value?.pages ?: return PROGRESS_NONE + val pagesCount = pages.count { x -> x.chapterId == chapterId } + if (chaptersCount == 0 || pagesCount == 0) { + return PROGRESS_NONE + } + val pagePercent = (pageIndex + 1) / pagesCount.toFloat() + val ppc = 1f / chaptersCount + return ppc * chapterIndex + ppc * pagePercent + } +} + +/** + * This function is not a member of the ReaderViewModel + * because it should work independently of the ViewModel's lifecycle. + */ +private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job { + return processLifecycleScope.launch(Dispatchers.Default) { + runCatching { + addOrUpdate( + manga = manga, + chapterId = state.chapterId, + page = state.page, + scroll = state.scroll, + percent = percent, + ) + }.onFailure { + it.printStackTraceDebug() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt new file mode 100644 index 000000000..d3980c687 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.reader.ui.pager + +import android.content.Context +import androidx.annotation.CallSuper +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding +import org.koitharu.kotatsu.reader.domain.PageLoader + +abstract class BasePageHolder( + protected val binding: B, + loader: PageLoader, + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { + + @Suppress("LeakingThis") + protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver) + protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) + + val context: Context + get() = itemView.context + + var boundData: ReaderPage? = null + private set + + fun requireData(): ReaderPage { + return checkNotNull(boundData) { "Calling requireData() before bind()" } + } + + fun bind(data: ReaderPage) { + boundData = data + onBind(data) + } + + protected abstract fun onBind(data: ReaderPage) + + @CallSuper + open fun onRecycled() { + delegate.onRecycle() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt new file mode 100644 index 000000000..7b7c4d498 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReader.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.reader.ui.pager + +import android.os.Bundle +import android.view.View +import androidx.core.graphics.Insets +import androidx.viewbinding.ViewBinding +import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.ReaderViewModel + +private const val KEY_STATE = "state" + +abstract class BaseReader : BaseFragment() { + + protected val viewModel by sharedViewModel() + private var stateToSave: ReaderState? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var restoredState = savedInstanceState?.getParcelable(KEY_STATE) + + viewModel.content.observe(viewLifecycleOwner) { + onPagesChanged(it.pages, restoredState ?: it.state) + restoredState = null + } + } + + override fun onPause() { + super.onPause() + viewModel.saveCurrentState(getCurrentState()) + } + + override fun onDestroyView() { + stateToSave = getCurrentState() + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + getCurrentState()?.let { + stateToSave = it + } + outState.putParcelable(KEY_STATE, stateToSave) + } + + override fun onWindowInsetsChanged(insets: Insets) = Unit + + abstract fun switchPageBy(delta: Int) + + abstract fun switchPageTo(position: Int, smooth: Boolean) + + abstract fun getCurrentState(): ReaderState? + + protected abstract fun onPagesChanged(pages: List, pendingState: ReaderState?) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt index 7a289cbdf..d097c1bc2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt @@ -5,24 +5,23 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.util.ext.resetTransformations +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings +import org.koitharu.kotatsu.utils.ext.resetTransformations import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Suppress("LeakingThis") abstract class BaseReaderAdapter>( private val loader: PageLoader, - private val readerSettings: ReaderSettings, - private val networkState: NetworkState, + private val settings: AppSettings, private val exceptionResolver: ExceptionResolver, ) : RecyclerView.Adapter() { private val differ = AsyncListDiffer(this, DiffCallback()) init { + setHasStableIds(true) stateRestorationPolicy = StateRestorationPolicy.PREVENT } @@ -36,28 +35,20 @@ abstract class BaseReaderAdapter>( super.onViewRecycled(holder) } - override fun onViewAttachedToWindow(holder: H) { - super.onViewAttachedToWindow(holder) - holder.onAttachedToWindow() - } - - override fun onViewDetachedFromWindow(holder: H) { - holder.onDetachedFromWindow() - super.onViewDetachedFromWindow(holder) - } - open fun getItem(position: Int): ReaderPage = differ.currentList[position] open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position) + override fun getItemId(position: Int) = differ.currentList[position].id + final override fun getItemCount() = differ.currentList.size final override fun onCreateViewHolder( parent: ViewGroup, - viewType: Int, - ): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver) + viewType: Int + ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) - suspend fun setItems(items: List) = suspendCoroutine { cont -> + suspend fun setItems(items: List) = suspendCoroutine { cont -> differ.submitList(items) { cont.resume(Unit) } @@ -66,19 +57,19 @@ abstract class BaseReaderAdapter>( protected abstract fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, + settings: AppSettings, + exceptionResolver: ExceptionResolver ): H private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { - return oldItem.id == newItem.id && oldItem.chapterId == newItem.chapterId + return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean { return oldItem == newItem } + } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/OnBoundsScrollListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/OnBoundsScrollListener.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/OnBoundsScrollListener.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/OnBoundsScrollListener.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt new file mode 100644 index 000000000..5630cb1ba --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -0,0 +1,143 @@ +package org.koitharu.kotatsu.reader.ui.pager + +import android.net.Uri +import androidx.core.net.toUri +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.domain.PageLoader +import java.io.File +import java.io.IOException + +class PageHolderDelegate( + private val loader: PageLoader, + private val settings: AppSettings, + private val callback: Callback, + private val exceptionResolver: ExceptionResolver +) : SubsamplingScaleImageView.DefaultOnImageEventListener() { + + private val scope = loader.loaderScope + Dispatchers.Main.immediate + private var state = State.EMPTY + private var job: Job? = null + private var file: File? = null + private var error: Throwable? = null + + fun onBind(page: MangaPage) { + val prevJob = job + job = scope.launch { + prevJob?.cancelAndJoin() + doLoad(page, force = false) + } + } + + fun retry(page: MangaPage) { + val prevJob = job + job = scope.launch { + prevJob?.cancelAndJoin() + val e = error + if (e != null && ExceptionResolver.canResolve(e)) { + exceptionResolver.resolve(e) + } + doLoad(page, force = true) + } + } + + fun onRecycle() { + state = State.EMPTY + file = null + error = null + job?.cancel() + } + + override fun onReady() { + state = State.SHOWING + error = null + callback.onImageShowing(settings.zoomMode) + } + + override fun onImageLoaded() { + state = State.SHOWN + error = null + callback.onImageShown() + } + + override fun onImageLoadError(e: Exception) { + val file = this.file + error = e + if (state == State.LOADED && e is IOException && file != null && file.exists()) { + tryConvert(file, e) + } else { + state = State.ERROR + callback.onError(e) + } + } + + private fun tryConvert(file: File, e: Exception) { + val prevJob = job + job = scope.launch { + prevJob?.join() + state = State.CONVERTING + try { + loader.convertInPlace(file) + state = State.CONVERTED + callback.onImageReady(file.toUri()) + } catch (e2: Throwable) { + e.addSuppressed(e2) + state = State.ERROR + callback.onError(e) + } + } + } + + private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) { + state = State.LOADING + error = null + callback.onLoadingStarted() + try { + val task = loader.loadPageAsync(data, force) + val progressObserver = observeProgress(this, task.progressAsFlow()) + val file = task.await() + progressObserver.cancel() + this@PageHolderDelegate.file = file + state = State.LOADED + callback.onImageReady(file.toUri()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + state = State.ERROR + error = e + callback.onError(e) + } + } + + private fun observeProgress(scope: CoroutineScope, progress: Flow) = progress + .debounce(500) + .onEach { callback.onProgressChanged((100 * it).toInt()) } + .launchIn(scope) + + private enum class State { + EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR + } + + interface Callback { + + fun onLoadingStarted() + + fun onError(e: Throwable) + + fun onImageReady(uri: Uri) + + fun onImageShowing(zoom: ZoomMode) + + fun onImageShown() + + fun onProgressChanged(progress: Int) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt index a9c97cf96..6773f5dc3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource data class ReaderPage( val id: Long, val url: String, + val referer: String, val preview: String?, val chapterId: Long, val index: Int, @@ -18,6 +19,7 @@ data class ReaderPage( constructor(page: MangaPage, index: Int, chapterId: Long) : this( id = page.id, url = page.url, + referer = page.referer, preview = page.preview, chapterId = chapterId, index = index, @@ -27,7 +29,8 @@ data class ReaderPage( fun toMangaPage() = MangaPage( id = id, url = url, + referer = referer, preview = preview, source = source, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt new file mode 100644 index 000000000..be72b896c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.reader.ui.pager + +data class ReaderUiState( + val mangaName: String?, + val chapterName: String?, + val chapterNumber: Int, + val chaptersTotal: Int +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt index 2348965a0..33920e631 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt @@ -3,69 +3,61 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.graphics.PointF import android.view.Gravity import android.widget.FrameLayout -import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode -import org.koitharu.kotatsu.core.os.NetworkState +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder class ReversedPageHolder( - owner: LifecycleOwner, binding: ItemPageBinding, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) { + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : PageHolder(binding, loader, settings, exceptionResolver) { init { (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) .gravity = Gravity.START or Gravity.BOTTOM } - override fun onImageShowing(settings: ReaderSettings) { + override fun onImageShowing(zoom: ZoomMode) { with(binding.ssiv) { maxScale = 2f * maxOf( width / sWidth.toFloat(), - height / sHeight.toFloat(), + height / sHeight.toFloat() ) - binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() - when (settings.zoomMode) { + when (zoom) { ZoomMode.FIT_CENTER -> { - minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) resetScaleAndCenter() } - ZoomMode.FIT_HEIGHT -> { - minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) minScale = height / sHeight.toFloat() setScaleAndCenter( minScale, - PointF(sWidth.toFloat(), sHeight / 2f), + PointF(sWidth.toFloat(), sHeight / 2f) ) } - ZoomMode.FIT_WIDTH -> { - minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) minScale = width / sWidth.toFloat() setScaleAndCenter( minScale, - PointF(sWidth / 2f, 0f), + PointF(sWidth / 2f, 0f) ) } - ZoomMode.KEEP_START -> { - minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) setScaleAndCenter( maxScale, - PointF(sWidth.toFloat(), 0f), + PointF(sWidth.toFloat(), 0f) ) } } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt index e81b1cfec..aebb58b18 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPagesAdapter.kt @@ -2,34 +2,27 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.view.LayoutInflater import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class ReversedPagesAdapter( - private val lifecycleOwner: LifecycleOwner, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, networkState, exceptionResolver) { + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : BaseReaderAdapter(loader, settings, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, + settings: AppSettings, + exceptionResolver: ExceptionResolver ) = ReversedPageHolder( - owner = lifecycleOwner, binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, settings = settings, - networkState = networkState, - exceptionResolver = exceptionResolver, + exceptionResolver = exceptionResolver ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt new file mode 100644 index 000000000..d43f6aee3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt @@ -0,0 +1,112 @@ +package org.koitharu.kotatsu.reader.ui.pager.reversed + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import kotlin.math.absoluteValue +import kotlinx.coroutines.async +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.pager.BaseReader +import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class ReversedReaderFragment : BaseReader() { + + private var pagerAdapter: ReversedPagesAdapter? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentReaderStandardBinding.inflate(inflater, container, false) + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, get(), exceptionResolver) + with(binding.pager) { + adapter = pagerAdapter + offscreenPageLimit = 2 + doOnPageChanged(::notifyPageChanged) + } + + viewModel.readerAnimation.observe(viewLifecycleOwner) { + val transformer = if (it) ReversedPageAnimTransformer() else null + binding.pager.setPageTransformer(transformer) + if (transformer == null) { + binding.pager.recyclerView?.children?.forEach { + it.resetTransformations() + } + } + } + viewModel.onZoomChanged.observe(viewLifecycleOwner) { + pagerAdapter?.notifyDataSetChanged() + } + } + + override fun onDestroyView() { + pagerAdapter = null + super.onDestroyView() + } + + override fun switchPageBy(delta: Int) { + with(binding.pager) { + setCurrentItem(currentItem - delta, true) + } + } + + override fun switchPageTo(position: Int, smooth: Boolean) { + binding.pager.setCurrentItem( + reversed(position), + smooth && (binding.pager.currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT + ) + } + + override fun onPagesChanged(pages: List, pendingState: ReaderState?) { + val reversedPages = pages.asReversed() + viewLifecycleScope.launchWhenCreated { + val items = async { + pagerAdapter?.setItems(reversedPages) + } + if (pendingState != null) { + val position = reversedPages.indexOfLast { + it.chapterId == pendingState.chapterId && it.index == pendingState.page + } + items.await() ?: return@launchWhenCreated + if (position != -1) { + binding.pager.setCurrentItem(position, false) + notifyPageChanged(position) + } + } else { + items.await() + } + } + } + + override fun getCurrentState(): ReaderState? = bindingOrNull()?.run { + val adapter = pager.adapter as? BaseReaderAdapter<*> + val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null + ReaderState( + chapterId = page.chapterId, + page = page.index, + scroll = 0 + ) + } + + private fun notifyPageChanged(page: Int) { + viewModel.onCurrentPageChanged(reversed(page)) + } + + private fun reversed(position: Int): Int { + return ((pagerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt new file mode 100644 index 000000000..58aba0ee6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -0,0 +1,119 @@ +package org.koitharu.kotatsu.reader.ui.pager.standard + +import android.annotation.SuppressLint +import android.graphics.PointF +import android.net.Uri +import android.view.View +import androidx.core.view.isVisible +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.databinding.ItemPageBinding +import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.ext.* + +open class PageHolder( + binding: ItemPageBinding, + loader: PageLoader, + settings: AppSettings, + exceptionResolver: ExceptionResolver, +) : BasePageHolder(binding, loader, settings, exceptionResolver), + View.OnClickListener { + + init { + binding.ssiv.setOnImageEventListener(delegate) + @Suppress("LeakingThis") + bindingInfo.buttonRetry.setOnClickListener(this) + binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled + } + + @SuppressLint("SetTextI18n") + override fun onBind(data: ReaderPage) { + delegate.onBind(data.toMangaPage()) + binding.textViewNumber.text = (data.index + 1).toString() + } + + override fun onRecycled() { + super.onRecycled() + binding.ssiv.recycle() + } + + override fun onLoadingStarted() { + bindingInfo.layoutError.isVisible = false + bindingInfo.progressBar.showCompat() + binding.ssiv.recycle() + } + + override fun onProgressChanged(progress: Int) { + if (progress in 0..100) { + bindingInfo.progressBar.isIndeterminate = false + bindingInfo.progressBar.setProgressCompat(progress, true) + } else { + bindingInfo.progressBar.isIndeterminate = true + } + } + + override fun onImageReady(uri: Uri) { + binding.ssiv.setImage(ImageSource.uri(uri)) + } + + override fun onImageShowing(zoom: ZoomMode) { + binding.ssiv.maxScale = 2f * maxOf( + binding.ssiv.width / binding.ssiv.sWidth.toFloat(), + binding.ssiv.height / binding.ssiv.sHeight.toFloat() + ) + when (zoom) { + ZoomMode.FIT_CENTER -> { + binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) + binding.ssiv.resetScaleAndCenter() + } + ZoomMode.FIT_HEIGHT -> { + binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) + binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() + binding.ssiv.setScaleAndCenter( + binding.ssiv.minScale, + PointF(0f, binding.ssiv.sHeight / 2f) + ) + } + ZoomMode.FIT_WIDTH -> { + binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) + binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() + binding.ssiv.setScaleAndCenter( + binding.ssiv.minScale, + PointF(binding.ssiv.sWidth / 2f, 0f) + ) + } + ZoomMode.KEEP_START -> { + binding.ssiv.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE) + binding.ssiv.setScaleAndCenter( + binding.ssiv.maxScale, + PointF(0f, 0f) + ) + } + } + } + + override fun onImageShown() { + bindingInfo.progressBar.hideCompat() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) + } + } + + override fun onError(e: Throwable) { + bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) + bindingInfo.buttonRetry.setText( + ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again } + ) + bindingInfo.layoutError.isVisible = true + bindingInfo.progressBar.hideCompat() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt new file mode 100644 index 000000000..1f9c40cbf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.reader.ui.pager.standard + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener + +class PagerPaginationListener( + private val adapter: RecyclerView.Adapter<*>, + private val offset: Int, + private val listener: OnBoundsScrollListener +) : ViewPager2.OnPageChangeCallback() { + + private var firstItemId: Long = 0 + private var lastItemId: Long = 0 + + override fun onPageSelected(position: Int) { + val itemCount = adapter.itemCount + if (itemCount == 0) { + return + } + if (position <= offset && adapter.getItemId(0) != firstItemId) { + firstItemId = adapter.getItemId(0) + listener.onScrolledToStart() + } else if (position >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) { + lastItemId = adapter.getItemId(itemCount - 1) + listener.onScrolledToEnd() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt new file mode 100644 index 000000000..a0a53b2d1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.reader.ui.pager.standard + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import kotlin.math.absoluteValue +import kotlinx.coroutines.async +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.pager.BaseReader +import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class PagerReaderFragment : BaseReader() { + + private var pagesAdapter: PagesAdapter? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentReaderStandardBinding.inflate(inflater, container, false) + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + pagesAdapter = PagesAdapter(viewModel.pageLoader, get(), exceptionResolver) + with(binding.pager) { + adapter = pagesAdapter + offscreenPageLimit = 2 + doOnPageChanged(::notifyPageChanged) + } + + viewModel.readerAnimation.observe(viewLifecycleOwner) { + val transformer = if (it) PageAnimTransformer() else null + binding.pager.setPageTransformer(transformer) + if (transformer == null) { + binding.pager.recyclerView?.children?.forEach { view -> + view.resetTransformations() + } + } + } + viewModel.onZoomChanged.observe(viewLifecycleOwner) { + pagesAdapter?.notifyDataSetChanged() + } + } + + override fun onDestroyView() { + pagesAdapter = null + super.onDestroyView() + } + + override fun onPagesChanged(pages: List, pendingState: ReaderState?) { + viewLifecycleScope.launchWhenCreated { + val items = async { + pagesAdapter?.setItems(pages) + } + if (pendingState != null) { + val position = pages.indexOfFirst { + it.chapterId == pendingState.chapterId && it.index == pendingState.page + } + items.await() ?: return@launchWhenCreated + if (position != -1) { + binding.pager.setCurrentItem(position, false) + notifyPageChanged(position) + } + } else { + items.await() + } + } + } + + override fun switchPageBy(delta: Int) { + with(binding.pager) { + setCurrentItem(currentItem + delta, true) + } + } + + override fun switchPageTo(position: Int, smooth: Boolean) { + binding.pager.setCurrentItem( + position, + smooth && (binding.pager.currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT + ) + } + + override fun getCurrentState(): ReaderState? = bindingOrNull()?.run { + val adapter = pager.adapter as? BaseReaderAdapter<*> + val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null + ReaderState( + chapterId = page.chapterId, + page = page.index, + scroll = 0 + ) + } + + private fun notifyPageChanged(page: Int) { + viewModel.onCurrentPageChanged(page) + } + + companion object { + + const val SMOOTH_SCROLL_LIMIT = 3 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt index 0c562ae4d..553139c76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagesAdapter.kt @@ -2,34 +2,27 @@ package org.koitharu.kotatsu.reader.ui.pager.standard import android.view.LayoutInflater import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class PagesAdapter( - private val lifecycleOwner: LifecycleOwner, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, networkState, exceptionResolver) { + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : BaseReaderAdapter(loader, settings, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, + settings: AppSettings, + exceptionResolver: ExceptionResolver ) = PageHolder( - owner = lifecycleOwner, binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), loader = loader, settings = settings, - networkState = networkState, - exceptionResolver = exceptionResolver, + exceptionResolver = exceptionResolver ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt new file mode 100644 index 000000000..a3879a762 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.reader.ui.pager.webtoon + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener + +class ListPaginationListener( + private val offset: Int, + private val listener: OnBoundsScrollListener +) : RecyclerView.OnScrollListener() { + + private var firstItemId: Long = 0 + private var lastItemId: Long = 0 + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val adapter = recyclerView.adapter ?: return + val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return + val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() + val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() + val itemCount = adapter.itemCount + if (itemCount == 0) { + return + } + if (lastVisiblePosition >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) { + lastItemId = adapter.getItemId(itemCount - 1) + listener.onScrolledToEnd() + } else if (firstVisiblePosition <= offset && adapter.getItemId(0) != firstItemId) { + firstItemId = adapter.getItemId(0) + listener.onScrolledToStart() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt index 607f33ad6..a089d4d17 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonAdapter.kt @@ -2,38 +2,31 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.view.LayoutInflater import android.view.ViewGroup -import androidx.lifecycle.LifecycleOwner import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter class WebtoonAdapter( - private val lifecycleOwner: LifecycleOwner, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : BaseReaderAdapter(loader, settings, networkState, exceptionResolver) { + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : BaseReaderAdapter(loader, settings, exceptionResolver) { override fun onCreateViewHolder( parent: ViewGroup, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, + settings: AppSettings, + exceptionResolver: ExceptionResolver ) = WebtoonHolder( - owner = lifecycleOwner, binding = ItemPageWebtoonBinding.inflate( LayoutInflater.from(parent.context), parent, - false, + false ), loader = loader, settings = settings, - networkState = networkState, - exceptionResolver = exceptionResolver, + exceptionResolver = exceptionResolver ) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt similarity index 61% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt index 719b4a790..2fe267156 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt @@ -3,20 +3,15 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout -import androidx.annotation.AttrRes import org.koitharu.kotatsu.R class WebtoonFrameLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = 0, + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { - private var _target: WebtoonImageView? = null - val target: WebtoonImageView - get() = _target ?: findViewById(R.id.ssiv).also { - _target = it - } + private val target by lazy { + findViewById(R.id.ssiv) + } fun dispatchVerticalScroll(dy: Int): Int { if (dy == 0) { @@ -26,4 +21,4 @@ class WebtoonFrameLayout @JvmOverloads constructor( target.scrollBy(dy) return target.getScroll() - oldScroll } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 48111be7b..cc4370748 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -3,56 +3,32 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.net.Uri import android.view.View import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero +import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.ext.* + class WebtoonHolder( - owner: LifecycleOwner, binding: ItemPageWebtoonBinding, loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : BasePageHolder(binding, loader, settings, networkState, exceptionResolver, owner), + settings: AppSettings, + exceptionResolver: ExceptionResolver +) : BasePageHolder(binding, loader, settings, exceptionResolver), View.OnClickListener { private var scrollToRestore = 0 - private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar) init { - binding.ssiv.bindToLifecycle(owner) - binding.ssiv.addOnImageEventListener(delegate) + binding.ssiv.setOnImageEventListener(delegate) bindingInfo.buttonRetry.setOnClickListener(this) - bindingInfo.buttonErrorDetails.setOnClickListener(this) - } - - override fun onResume() { - super.onResume() - binding.ssiv.applyDownsampling(isForeground = true) - } - - override fun onPause() { - super.onPause() - binding.ssiv.applyDownsampling(isForeground = false) - } - - override fun onConfigChanged() { - super.onConfigChanged() - if (settings.applyBitmapConfig(binding.ssiv)) { - delegate.reload() - } - binding.ssiv.applyDownsampling(isResumed()) } override fun onBind(data: ReaderPage) { @@ -64,19 +40,9 @@ class WebtoonHolder( binding.ssiv.recycle() } - override fun onAttachedToWindow() { - super.onAttachedToWindow() - goneOnInvisibleListener.attach() - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - goneOnInvisibleListener.detach() - } - override fun onLoadingStarted() { bindingInfo.layoutError.isVisible = false - bindingInfo.progressBar.show() + bindingInfo.progressBar.showCompat() binding.ssiv.recycle() } @@ -90,41 +56,42 @@ class WebtoonHolder( } override fun onImageReady(uri: Uri) { - binding.ssiv.setImage(ImageSource.Uri(uri)) + binding.ssiv.setImage(ImageSource.uri(uri)) } - override fun onImageShowing(settings: ReaderSettings) { - binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() + override fun onImageShowing(zoom: ZoomMode) { with(binding.ssiv) { + maxScale = 2f * width / sWidth.toFloat() + setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CUSTOM) + minScale = width / sWidth.toFloat() scrollTo( when { scrollToRestore != 0 -> scrollToRestore itemView.top < 0 -> getScrollRange() else -> 0 - }, + } ) scrollToRestore = 0 } } override fun onImageShown() { - bindingInfo.progressBar.hide() + bindingInfo.progressBar.hideCompat() } override fun onClick(v: View) { when (v.id) { - R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true) - R.id.button_error_details -> delegate.showErrorDetails(boundData?.url) + R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) } } override fun onError(e: Throwable) { bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) bindingInfo.buttonRetry.setText( - ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, + ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again } ) bindingInfo.layoutError.isVisible = true - bindingInfo.progressBar.hide() + bindingInfo.progressBar.hideCompat() } fun getScrollY() = binding.ssiv.getScroll() @@ -136,4 +103,4 @@ class WebtoonHolder( scrollToRestore = scroll } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt index b4a2de35c..31396bd06 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -1,16 +1,13 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon +import android.app.Activity import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint import android.graphics.PointF import android.util.AttributeSet -import androidx.core.view.ancestors -import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.util.ext.resolveDp -import kotlin.math.roundToInt +import org.koitharu.kotatsu.parsers.util.toIntUp + +private const val SCROLL_UNKNOWN = -1 class WebtoonImageView @JvmOverloads constructor( context: Context, @@ -18,16 +15,14 @@ class WebtoonImageView @JvmOverloads constructor( ) : SubsamplingScaleImageView(context, attr) { private val ct = PointF() + private val displayHeight = if (context is Activity) { + context.window.decorView.height + } else { + context.resources.displayMetrics.heightPixels + } private var scrollPos = 0 - private var debugPaint: Paint? = null - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (BuildConfig.DEBUG) { - drawDebug(canvas) - } - } + private var scrollRange = SCROLL_UNKNOWN fun scrollBy(delta: Int) { val maxScroll = getScrollRange() @@ -41,7 +36,6 @@ class WebtoonImageView @JvmOverloads constructor( fun scrollTo(y: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { - resetScaleAndCenter() return } scrollToInternal(y.coerceIn(0, maxScroll)) @@ -50,25 +44,22 @@ class WebtoonImageView @JvmOverloads constructor( fun getScroll() = scrollPos fun getScrollRange(): Int { - if (!isReady) { - return 0 + if (scrollRange == SCROLL_UNKNOWN) { + computeScrollRange() } - val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt() - return (totalHeight - height).coerceAtLeast(0) + return scrollRange.coerceAtLeast(0) } override fun recycle() { + scrollRange = SCROLL_UNKNOWN scrollPos = 0 super.recycle() } override fun getSuggestedMinimumHeight(): Int { var desiredHeight = super.getSuggestedMinimumHeight() - if (sHeight == 0) { - val parentHeight = parentHeight() - if (desiredHeight < parentHeight) { - desiredHeight = parentHeight - } + if (sHeight == 0 && desiredHeight < displayHeight) { + desiredHeight = displayHeight } return desiredHeight } @@ -93,58 +84,21 @@ class WebtoonImageView @JvmOverloads constructor( } } width = width.coerceAtLeast(suggestedMinimumWidth) - height = height.coerceAtLeast(suggestedMinimumHeight).coerceAtMost(parentHeight()) + height = height.coerceIn(suggestedMinimumHeight, displayHeight) setMeasuredDimension(width, height) } - override fun onDownsamplingChanged() { - super.onDownsamplingChanged() - adjustScale() - } - - override fun onReady() { - super.onReady() - adjustScale() - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - if (oldh != h && oldw != 0 && oldh != 0 && isReady) { - ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll() - } else { - return - } - } - private fun scrollToInternal(pos: Int) { - minScale = width / sWidth.toFloat() - maxScale = minScale scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) setScaleAndCenter(minScale, ct) } - private fun adjustScale() { - minScale = width / sWidth.toFloat() - maxScale = minScale - minimumScaleType = SCALE_TYPE_CUSTOM - } - - private fun parentHeight(): Int { - return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 - } - - private fun drawDebug(canvas: Canvas) { - val paint = debugPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = android.graphics.Color.RED - strokeWidth = context.resources.resolveDp(2f) - textAlign = android.graphics.Paint.Align.LEFT - textSize = context.resources.resolveDp(14f) - debugPaint = this + private fun computeScrollRange() { + if (!isReady) { + return } - paint.style = Paint.Style.STROKE - canvas.drawRect(1f, 1f, width.toFloat() - 1f, height.toFloat() - 1f, paint) - paint.style = Paint.Style.FILL - canvas.drawText("${getScroll()} / ${getScrollRange()}", 100f, 100f, paint) + val totalHeight = (sHeight * minScale).toIntUp() + scrollRange = (totalHeight - height).coerceAtLeast(0) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt similarity index 98% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt index 067ada3ac..29f21d08e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonLayoutManager.kt @@ -6,7 +6,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.sign -@Suppress("unused") class WebtoonLayoutManager : LinearLayoutManager { private var scrollDirection: Int = 0 diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt new file mode 100644 index 000000000..d0c25311d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt @@ -0,0 +1,103 @@ +package org.koitharu.kotatsu.reader.ui.pager.webtoon + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import kotlinx.coroutines.async +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.pager.BaseReader +import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter +import org.koitharu.kotatsu.reader.ui.pager.ReaderPage +import org.koitharu.kotatsu.utils.ext.findCenterViewPosition +import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class WebtoonReaderFragment : BaseReader() { + + private val scrollInterpolator = AccelerateDecelerateInterpolator() + private var webtoonAdapter: WebtoonAdapter? = null + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentReaderWebtoonBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, get(), exceptionResolver) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = webtoonAdapter + addOnPageScrollListener(PageScrollListener()) + } + } + + override fun onDestroyView() { + webtoonAdapter = null + super.onDestroyView() + } + + override fun onPagesChanged(pages: List, pendingState: ReaderState?) { + viewLifecycleScope.launchWhenCreated { + val setItems = async { webtoonAdapter?.setItems(pages) } + if (pendingState != null) { + val position = pages.indexOfFirst { + it.chapterId == pendingState.chapterId && it.index == pendingState.page + } + setItems.await() ?: return@launchWhenCreated + if (position != -1) { + with(binding.recyclerView) { + firstVisibleItemPosition = position + post { + (findViewHolderForAdapterPosition(position) as? WebtoonHolder) + ?.restoreScroll(pendingState.scroll) + } + } + notifyPageChanged(position) + } + } else { + setItems.await() + } + } + } + + override fun getCurrentState(): ReaderState? = bindingOrNull()?.run { + val currentItem = recyclerView.findCenterViewPosition() + val adapter = recyclerView.adapter as? BaseReaderAdapter<*> + val page = adapter?.getItemOrNull(currentItem) ?: return@run null + ReaderState( + chapterId = page.chapterId, + page = page.index, + scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder) + ?.getScrollY() ?: 0 + ) + } + + private fun notifyPageChanged(page: Int) { + viewModel.onCurrentPageChanged(page) + } + + override fun switchPageBy(delta: Int) { + binding.recyclerView.smoothScrollBy( + 0, + (binding.recyclerView.height * 0.9).toInt() * delta, + scrollInterpolator + ) + } + + override fun switchPageTo(position: Int, smooth: Boolean) { + binding.recyclerView.firstVisibleItemPosition = position + } + + private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() { + + override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) { + super.onPageChanged(recyclerView, index) + notifyPageChanged(index) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt similarity index 66% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt rename to app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt index 788a11107..aa28971af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt @@ -2,32 +2,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context import android.util.AttributeSet -import android.view.View import androidx.core.view.ViewCompat.TYPE_TOUCH -import androidx.core.view.forEach -import androidx.core.view.iterator import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition -import java.util.LinkedList -import java.util.WeakHashMap +import org.koitharu.kotatsu.utils.ext.findCenterViewPosition +import java.util.* class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { private var onPageScrollListeners: MutableList? = null - private val detachedViews = WeakHashMap() - private var isFixingScroll: Boolean = false - - override fun onChildDetachedFromWindow(child: View) { - super.onChildDetachedFromWindow(child) - detachedViews[child] = Unit - } - - override fun onChildAttachedToWindow(child: View) { - super.onChildAttachedToWindow(child) - detachedViews.remove(child) - } override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH) @@ -56,13 +40,6 @@ class WebtoonRecyclerView @JvmOverloads constructor( return consumedY != 0 || dy == 0 } - override fun onScrollStateChanged(state: Int) { - super.onScrollStateChanged(state) - if (state == SCROLL_STATE_IDLE) { - updateChildrenScroll() - } - } - private fun consumeVerticalScroll(dy: Int): Int { if (childCount == 0) { return 0 @@ -83,7 +60,6 @@ class WebtoonRecyclerView @JvmOverloads constructor( } return consumedByChild } - dy < 0 -> { val child = getChildAt(childCount - 1) as WebtoonFrameLayout var consumedByChild = child.dispatchVerticalScroll(dy) @@ -121,47 +97,6 @@ class WebtoonRecyclerView @JvmOverloads constructor( listeners.forEach { it.dispatchScroll(this, dy, centerPosition) } } - fun relayoutChildren() { - forEach { child -> - (child as WebtoonFrameLayout).target.requestLayout() - } - detachedViews.keys.forEach { child -> - (child as WebtoonFrameLayout).target.requestLayout() - } - } - - fun updateChildrenScroll() { - if (isFixingScroll) { - return - } - isFixingScroll = true - for (child in this) { - val ssiv = (child as WebtoonFrameLayout).target - if (adjustScroll(child, ssiv)) { - break - } - } - isFixingScroll = false - } - - private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when { - child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> { - val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll()) - scrollBy(0, -distance) - ssiv.scrollBy(distance) - true - } - - child.top > 0 && ssiv.getScroll() > 0 -> { - val distance = minOf(child.top, ssiv.getScroll()) - scrollBy(0, distance) - ssiv.scrollBy(-distance) - true - } - - else -> false - } - abstract class OnPageScrollListener { private var lastPosition = NO_POSITION @@ -178,4 +113,4 @@ class WebtoonRecyclerView @JvmOverloads constructor( open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt new file mode 100644 index 000000000..38db30b5e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails + +import org.koitharu.kotatsu.parsers.model.MangaPage + +fun interface OnPageSelectListener { + + fun onPageSelected(page: MangaPage) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt new file mode 100644 index 000000000..22c5ddad5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails + +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.parsers.model.MangaPage + +data class PageThumbnail( + val number: Int, + val isCurrent: Boolean, + val repository: MangaRepository, + val page: MangaPage +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt new file mode 100644 index 000000000..55d59adea --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt @@ -0,0 +1,150 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.databinding.SheetPagesBinding +import org.koitharu.kotatsu.list.ui.MangaListSpanResolver +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderViewModel +import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import org.koitharu.kotatsu.utils.ext.withArgs + +class PagesThumbnailsSheet : + BaseBottomSheet(), + OnListItemClickListener { + + private lateinit var thumbnails: List + private val spanResolver = MangaListSpanResolver() + private var currentPageIndex = -1 + private var pageLoader: PageLoader? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pages = arguments?.getParcelable(ARG_PAGES)?.pages + if (pages.isNullOrEmpty()) { + dismissAllowingStateLoss() + return + } + currentPageIndex = requireArguments().getInt(ARG_CURRENT, currentPageIndex) + val repository = MangaRepository(pages.first().source) + thumbnails = pages.mapIndexed { i, x -> + PageThumbnail( + number = i + 1, + isCurrent = i == currentPageIndex, + repository = repository, + page = x + ) + } + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { + return SheetPagesBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val title = arguments?.getString(ARG_TITLE) + binding.toolbar.title = title + binding.toolbar.setNavigationOnClickListener { dismiss() } + binding.toolbar.subtitle = null + behavior?.addBottomSheetCallback(ToolbarController(binding.toolbar)) + + if (!resources.getBoolean(R.bool.is_tablet)) { + binding.toolbar.navigationIcon = null + } else { + binding.toolbar.subtitle = + resources.getQuantityString(R.plurals.pages, thumbnails.size, thumbnails.size) + } + + with(binding.recyclerView) { + addItemDecoration( + SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)) + ) + adapter = PageThumbnailAdapter( + dataSet = thumbnails, + coil = get(), + scope = viewLifecycleScope, + loader = getPageLoader(), + clickListener = this@PagesThumbnailsSheet + ) + addOnLayoutChangeListener(spanResolver) + spanResolver.setGridSize(get().gridSize / 100f, this) + if (currentPageIndex > 0) { + val offset = resources.getDimensionPixelOffset(R.dimen.preferred_grid_width) + (layoutManager as GridLayoutManager).scrollToPositionWithOffset(currentPageIndex, offset) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + pageLoader?.close() + pageLoader = null + } + + override fun onItemClick(item: MangaPage, view: View) { + ( + (parentFragment as? OnPageSelectListener) + ?: (activity as? OnPageSelectListener) + )?.run { + onPageSelected(item) + dismiss() + } + } + + private fun getPageLoader(): PageLoader { + val viewModel = (activity as? ReaderActivity)?.getViewModel() + return viewModel?.pageLoader ?: PageLoader().also { pageLoader = it } + } + + private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) { + override fun onStateChanged(bottomSheet: View, newState: Int) { + super.onStateChanged(bottomSheet, newState) + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + toolbar.subtitle = resources.getQuantityString( + R.plurals.pages, + thumbnails.size, + thumbnails.size + ) + } else { + toolbar.subtitle = null + } + } + } + + companion object { + + private const val ARG_PAGES = "pages" + private const val ARG_TITLE = "title" + private const val ARG_CURRENT = "current" + + private const val TAG = "PagesThumbnailsSheet" + + fun show(fm: FragmentManager, pages: List, title: String, currentPage: Int) = + PagesThumbnailsSheet().withArgs(3) { + putParcelable(ARG_PAGES, ParcelableMangaPages(pages)) + putString(ARG_TITLE, title) + putInt(ARG_CURRENT, currentPage) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt new file mode 100644 index 000000000..8cddae963 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt @@ -0,0 +1,83 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails.adapter + +import android.graphics.drawable.Drawable +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Size +import com.google.android.material.R as materialR +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import kotlinx.coroutines.* +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemPageThumbBinding +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail +import org.koitharu.kotatsu.utils.ext.referer +import org.koitharu.kotatsu.utils.ext.setTextColorAttr + +fun pageThumbnailAD( + coil: ImageLoader, + scope: CoroutineScope, + loader: PageLoader, + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) } +) { + + var job: Job? = null + val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) + val thumbSize = Size( + width = gridWidth, + height = (gridWidth * 13f / 18f).toInt() + ) + + suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) { + item.page.preview?.let { url -> + coil.execute( + ImageRequest.Builder(context) + .data(url) + .referer(item.page.referer) + .size(thumbSize) + .allowRgb565(true) + .build() + ).drawable + }?.let { drawable -> + return@withContext drawable + } + val file = loader.loadPage(item.page, force = false) + coil.execute( + ImageRequest.Builder(context) + .data(file) + .size(thumbSize) + .allowRgb565(true) + .build() + ).drawable + } + + binding.root.setOnClickListener { + clickListener.onItemClick(item.page, itemView) + } + + bind { + job?.cancel() + binding.imageViewThumb.setImageDrawable(null) + with(binding.textViewNumber) { + setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) + setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary) + text = (item.number).toString() + } + job = scope.launch { + val drawable = runCatching { + loadPageThumbnail(item) + }.getOrNull() + binding.imageViewThumb.setImageDrawable(drawable) + } + } + + onViewRecycled { + job?.cancel() + job = null + binding.imageViewThumb.setImageDrawable(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt new file mode 100644 index 000000000..b293d2865 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.reader.ui.thumbnails.adapter + +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter +import kotlinx.coroutines.CoroutineScope +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail + +class PageThumbnailAdapter( + dataSet: List, + coil: ImageLoader, + scope: CoroutineScope, + loader: PageLoader, + clickListener: OnListItemClickListener +) : ListDelegationAdapter>() { + + init { + delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener)) + setItems(dataSet) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt new file mode 100644 index 000000000..7f36562b9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/RemoteListModule.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.remotelist + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel + +val remoteListModule + get() = module { + + viewModel { params -> + RemoteListViewModel( + repository = MangaRepository(params[0]) as RemoteMangaRepository, + settings = get(), + dataRepository = get(), + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt new file mode 100644 index 000000000..6dcd26b0f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.remotelist.ui + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.appcompat.view.ActionMode +import androidx.core.view.MenuProvider +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.serializableArgument +import org.koitharu.kotatsu.utils.ext.withArgs + +class RemoteListFragment : MangaListFragment() { + + override val viewModel by viewModel { + parametersOf(source) + } + + private val source by serializableArgument(ARG_SOURCE) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + addMenuProvider(RemoteListMenuProvider()) + } + + override fun onScrolledToEnd() { + viewModel.loadNextPage() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + + override fun onFilterClick() { + FilterBottomSheet.show(childFragmentManager) + } + + override fun onEmptyActionClick() { + viewModel.resetFilter() + } + + private inner class RemoteListMenuProvider: MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_list_remote, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_source_settings -> { + startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source)) + true + } + R.id.action_filter -> { + onFilterClick() + true + } + else -> false + } + } + + companion object { + + private const val ARG_SOURCE = "provider" + + fun newInstance(provider: MangaSource) = RemoteListFragment().withArgs(1) { + putSerializable(ARG_SOURCE, provider) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt new file mode 100644 index 000000000..092194b96 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -0,0 +1,159 @@ +package org.koitharu.kotatsu.remotelist.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaDataRepository +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator +import org.koitharu.kotatsu.list.ui.filter.FilterItem +import org.koitharu.kotatsu.list.ui.filter.FilterState +import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +private const val FILTER_MIN_INTERVAL = 750L + +class RemoteListViewModel( + private val repository: RemoteMangaRepository, + settings: AppSettings, + dataRepository: MangaDataRepository, +) : MangaListViewModel(settings), OnFilterChangedListener { + + private val filter = FilterCoordinator(repository, dataRepository, viewModelScope) + private val mangaList = MutableStateFlow?>(null) + private val hasNextPage = MutableStateFlow(false) + private val listError = MutableStateFlow(null) + private var loadingJob: Job? = null + + val filterItems: LiveData> + get() = filter.items + + override val content = combine( + mangaList, + createListModeFlow(), + filter.observeState(), + listError, + hasNextPage, + ) { list, mode, filterState, error, hasNext -> + buildList(list?.size?.plus(3) ?: 3) { + add(ListHeader(repository.source.title, 0, filterState.sortOrder)) + createFilterModel(filterState)?.let { add(it) } + when { + list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true)) + list == null -> add(LoadingState) + list.isEmpty() -> add(createEmptyState(filterState)) + else -> { + list.toUi(this, mode) + when { + error != null -> add(error.toErrorFooter()) + hasNext -> add(LoadingFooter) + } + } + } + } + }.asLiveDataDistinct( + viewModelScope.coroutineContext + Dispatchers.Default, + listOf(ListHeader(repository.source.title, 0, null), LoadingState), + ) + + init { + filter.observeState() + .debounce(FILTER_MIN_INTERVAL) + .onEach { filterState -> + loadingJob?.cancelAndJoin() + mangaList.value = null + hasNextPage.value = false + loadList(filterState, false) + }.catch { error -> + listError.value = error + }.launchIn(viewModelScope) + } + + override fun onRefresh() { + loadList(filter.snapshot(), append = false) + } + + override fun onRetry() { + loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) + } + + override fun onRemoveFilterTag(tag: MangaTag) { + filter.removeTag(tag) + } + + override fun onSortItemClick(item: FilterItem.Sort) { + filter.onSortItemClick(item) + } + + override fun onTagItemClick(item: FilterItem.Tag) { + filter.onTagItemClick(item) + } + + fun loadNextPage() { + if (hasNextPage.value && listError.value == null) { + loadList(filter.snapshot(), append = true) + } + } + + fun filterSearch(query: String) = filter.performSearch(query) + + fun resetFilter() = filter.reset() + + fun applyFilter(tags: Set) { + filter.setTags(tags) + } + + private fun loadList(filterState: FilterState, append: Boolean) { + if (loadingJob?.isActive == true) { + return + } + loadingJob = launchLoadingJob(Dispatchers.Default) { + try { + listError.value = null + val list = repository.getList( + offset = if (append) mangaList.value?.size ?: 0 else 0, + sortOrder = filterState.sortOrder, + tags = filterState.tags, + ) + if (!append) { + mangaList.value = list + } else if (list.isNotEmpty()) { + mangaList.value = mangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } catch (e: Throwable) { + e.printStackTraceDebug() + listError.value = e + if (!mangaList.value.isNullOrEmpty()) { + errorEvent.postCall(e) + } + } + } + } + + private fun createFilterModel(filterState: FilterState): CurrentFilterModel? { + return if (filterState.tags.isEmpty()) { + null + } else { + CurrentFilterModel(filterState.tags.map { ChipsView.ChipModel(0, it.title, it) }) + } + } + + private fun createEmptyState(filterState: FilterState) = EmptyState( + icon = R.drawable.ic_empty_search, + textPrimary = R.string.nothing_found, + textSecondary = 0, + actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter, + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingDao.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt similarity index 69% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingDao.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt index c24a93b55..72cf83031 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingDao.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.scrobbling.common.data +package org.koitharu.kotatsu.scrobbling.data import androidx.room.* import kotlinx.coroutines.flow.Flow @@ -12,12 +12,12 @@ abstract class ScrobblingDao { @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") abstract fun observe(scrobbler: Int, mangaId: Long): Flow - @Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler") - abstract fun observe(scrobbler: Int): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entity: ScrobblingEntity) - @Upsert - abstract suspend fun upsert(entity: ScrobblingEntity) + @Update + abstract suspend fun update(entity: ScrobblingEntity) @Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId") abstract suspend fun delete(scrobbler: Int, mangaId: Long) -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingEntity.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt index 32764e32b..dc4e02d8e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblingEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/data/ScrobblingEntity.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.scrobbling.common.data +package org.koitharu.kotatsu.scrobbling.data import androidx.room.ColumnInfo import androidx.room.Entity @@ -32,4 +32,4 @@ class ScrobblingEntity( comment = comment, rating = rating, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt new file mode 100644 index 000000000..b730d19cd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/Scrobbler.kt @@ -0,0 +1,80 @@ +package org.koitharu.kotatsu.scrobbling.domain + +import androidx.collection.LongSparseArray +import androidx.collection.getOrElse +import androidx.core.text.parseAsHtml +import java.util.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.scrobbling.domain.model.* +import org.koitharu.kotatsu.utils.ext.findKeyByValue +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +abstract class Scrobbler( + protected val db: MangaDatabase, + val scrobblerService: ScrobblerService, +) { + + private val infoCache = LongSparseArray() + protected val statuses = EnumMap(ScrobblingStatus::class.java) + + abstract val isAvailable: Boolean + + abstract suspend fun findManga(query: String, offset: Int): List + + abstract suspend fun linkManga(mangaId: Long, targetId: Long) + + abstract suspend fun scrobble(mangaId: Long, chapter: MangaChapter) + + suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null + return entity.toScrobblingInfo(mangaId) + } + + abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?) + + fun observeScrobblingInfo(mangaId: Long): Flow { + return db.scrobblingDao.observe(scrobblerService.id, mangaId) + .map { it?.toScrobblingInfo(mangaId) } + } + + abstract suspend fun unregisterScrobbling(mangaId: Long) + + protected abstract suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo + + private suspend fun ScrobblingEntity.toScrobblingInfo(mangaId: Long): ScrobblingInfo? { + val mangaInfo = infoCache.getOrElse(targetId) { + runCatching { + getMangaInfo(targetId) + }.onFailure { + it.printStackTraceDebug() + }.onSuccess { + infoCache.put(targetId, it) + }.getOrNull() ?: return null + } + return ScrobblingInfo( + scrobbler = scrobblerService, + mangaId = mangaId, + targetId = targetId, + status = statuses.findKeyByValue(status), + chapter = chapter, + comment = comment, + rating = rating, + title = mangaInfo.name, + coverUrl = mangaInfo.cover, + description = mangaInfo.descriptionHtml.parseAsHtml(), + externalUrl = mangaInfo.url, + ) + } +} + +suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { + return runCatching { + scrobble(mangaId, chapter) + }.onFailure { + it.printStackTraceDebug() + }.isSuccess +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt new file mode 100644 index 000000000..9e28c9d7d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerManga.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +import org.koitharu.kotatsu.list.ui.model.ListModel + +class ScrobblerManga( + val id: Long, + val name: String, + val altName: String?, + val cover: String, + val url: String, +) : ListModel { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScrobblerManga + + if (id != other.id) return false + if (name != other.name) return false + if (altName != other.altName) return false + if (cover != other.cover) return false + if (url != other.url) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + altName.hashCode() + result = 31 * result + cover.hashCode() + result = 31 * result + url.hashCode() + return result + } + + override fun toString(): String { + return "ScrobblerManga #$id \"$name\" $url" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerMangaInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt similarity index 67% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerMangaInfo.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt index 3aa638053..940262041 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerMangaInfo.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerMangaInfo.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model +package org.koitharu.kotatsu.scrobbling.domain.model class ScrobblerMangaInfo( val id: Long, @@ -6,4 +6,4 @@ class ScrobblerMangaInfo( val cover: String, val url: String, val descriptionHtml: String, -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt index 5b47a3a25..45038ed12 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model +package org.koitharu.kotatsu.scrobbling.domain.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -10,7 +10,5 @@ enum class ScrobblerService( @DrawableRes val iconResId: Int, ) { - SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori), - ANILIST(2, R.string.anilist, R.drawable.ic_anilist), - MAL(3, R.string.mal, R.drawable.ic_mal) -} + SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt new file mode 100644 index 000000000..87393d6ec --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingInfo.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +class ScrobblingInfo( + val scrobbler: ScrobblerService, + val mangaId: Long, + val targetId: Long, + val status: ScrobblingStatus?, + val chapter: Int, + val comment: String?, + val rating: Float, + val title: String, + val coverUrl: String, + val description: CharSequence?, + val externalUrl: String, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScrobblingInfo + + if (scrobbler != other.scrobbler) return false + if (mangaId != other.mangaId) return false + if (targetId != other.targetId) return false + if (status != other.status) return false + if (chapter != other.chapter) return false + if (comment != other.comment) return false + if (rating != other.rating) return false + if (title != other.title) return false + if (coverUrl != other.coverUrl) return false + if (description != other.description) return false + if (externalUrl != other.externalUrl) return false + + return true + } + + override fun hashCode(): Int { + var result = scrobbler.hashCode() + result = 31 * result + mangaId.hashCode() + result = 31 * result + targetId.hashCode() + result = 31 * result + (status?.hashCode() ?: 0) + result = 31 * result + chapter + result = 31 * result + (comment?.hashCode() ?: 0) + result = 31 * result + rating.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + coverUrl.hashCode() + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + externalUrl.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt new file mode 100644 index 000000000..cfb408094 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblingStatus.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.scrobbling.domain.model + +enum class ScrobblingStatus { + + PLANNED, + READING, + RE_READING, + COMPLETED, + ON_HOLD, + DROPPED, +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt new file mode 100644 index 000000000..ec3c65b57 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ShikimoriModule.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.scrobbling.shikimori + +import okhttp3.OkHttpClient +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage +import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsViewModel +import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorViewModel + +val shikimoriModule + get() = module { + single { ShikimoriStorage(androidContext()) } + factory { + val okHttp = OkHttpClient.Builder().apply { + authenticator(ShikimoriAuthenticator(get(), ::get)) + addInterceptor(ShikimoriInterceptor(get())) + }.build() + ShikimoriRepository(okHttp, get(), get()) + } + factory { ShikimoriScrobbler(get(), get()) } bind Scrobbler::class + viewModel { params -> + ShikimoriSettingsViewModel(get(), params.getOrNull()) + } + viewModel { params -> ScrobblingSelectorViewModel(params[0], get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt similarity index 69% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt index a7dc0b77d..8a94bf98a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt @@ -5,17 +5,12 @@ import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import javax.inject.Inject -import javax.inject.Provider -class ShikimoriAuthenticator @Inject constructor( - @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage, - private val repositoryProvider: Provider, +class ShikimoriAuthenticator( + private val storage: ShikimoriStorage, + private val repositoryProvider: () -> ShikimoriRepository, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { @@ -45,10 +40,12 @@ class ShikimoriAuthenticator @Inject constructor( } private fun refreshAccessToken(): String? = runCatching { - val repository = repositoryProvider.get() + val repository = repositoryProvider() runBlocking { repository.authorize(null) } return storage.accessToken }.onFailure { - it.printStackTraceDebug() + if (BuildConfig.DEBUG) { + it.printStackTrace() + } }.getOrNull() -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt similarity index 60% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt index dfd6e93aa..f203f2e4c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriInterceptor.kt @@ -4,20 +4,16 @@ import okhttp3.Interceptor import okhttp3.Response import okio.IOException import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage private const val USER_AGENT_SHIKIMORI = "Kotatsu" -class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor { +class ShikimoriInterceptor(private val storage: ShikimoriStorage) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val sourceRequest = chain.request() - val request = sourceRequest.newBuilder() + val request = chain.request().newBuilder() request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI) - if (!sourceRequest.url.pathSegments.contains("oauth")) { - storage.accessToken?.let { - request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") - } + storage.accessToken?.let { + request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") } val response = chain.proceed(request.build()) if (!response.isSuccessful && !response.isRedirect) { @@ -25,4 +21,4 @@ class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor } return response } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt similarity index 61% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt index 6bc2eb657..119d33637 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriRepository.kt @@ -1,15 +1,12 @@ package org.koitharu.kotatsu.scrobbling.shikimori.data -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject -import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.util.ext.toRequestBody import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.getStringOrNull @@ -17,50 +14,39 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser -import javax.inject.Inject -import javax.inject.Singleton - -private const val DOMAIN = "shikimori.one" +import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.ext.toRequestBody + private const val REDIRECT_URI = "kotatsu://shikimori-auth" -private const val BASE_URL = "https://$DOMAIN/" +private const val BASE_URL = "https://shikimori.one/" private const val MANGA_PAGE_SIZE = 10 -@Singleton -class ShikimoriRepository @Inject constructor( - @ApplicationContext context: Context, - @ScrobblerType(ScrobblerService.SHIKIMORI) private val okHttp: OkHttpClient, - @ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage, +class ShikimoriRepository( + private val okHttp: OkHttpClient, + private val storage: ShikimoriStorage, private val db: MangaDatabase, -) : ScrobblerRepository { - - private val clientId = context.getString(R.string.shikimori_clientId) - private val clientSecret = context.getString(R.string.shikimori_clientSecret) +) { - override val oauthUrl: String - get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" + + val oauthUrl: String + get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.SHIKIMORI_CLIENT_ID}&" + "redirect_uri=$REDIRECT_URI&response_type=code&scope=" - override val isAuthorized: Boolean + val isAuthorized: Boolean get() = storage.accessToken != null - override suspend fun authorize(code: String?) { + suspend fun authorize(code: String?) { val body = FormBody.Builder() - body.add("client_id", clientId) - body.add("client_secret", clientSecret) + body.add("grant_type", "authorization_code") + body.add("client_id", BuildConfig.SHIKIMORI_CLIENT_ID) + body.add("client_secret", BuildConfig.SHIKIMORI_CLIENT_SECRET) if (code != null) { - body.add("grant_type", "authorization_code") body.add("redirect_uri", REDIRECT_URI) body.add("code", code) } else { - body.add("grant_type", "refresh_token") body.add("refresh_token", checkNotNull(storage.refreshToken)) } val request = Request.Builder() @@ -71,7 +57,7 @@ class ShikimoriRepository @Inject constructor( storage.refreshToken = response.getString("refresh_token") } - override suspend fun loadUser(): ScrobblerUser { + suspend fun loadUser(): ShikimoriUser { val request = Request.Builder() .get() .url("${BASE_URL}api/users/whoami") @@ -79,20 +65,19 @@ class ShikimoriRepository @Inject constructor( return ShikimoriUser(response).also { storage.user = it } } - override val cachedUser: ScrobblerUser? - get() { - return storage.user - } + fun getCachedUser(): ShikimoriUser? { + return storage.user + } - override suspend fun unregister(mangaId: Long) { - return db.getScrobblingDao().delete(ScrobblerService.SHIKIMORI.id, mangaId) + suspend fun unregister(mangaId: Long) { + return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId) } - override fun logout() { + fun logout() { storage.clear() } - override suspend fun findManga(query: String, offset: Int): List { + suspend fun findManga(query: String, offset: Int): List { val page = offset / MANGA_PAGE_SIZE val pageOffset = offset % MANGA_PAGE_SIZE val url = BASE_URL.toHttpUrl().newBuilder() @@ -109,16 +94,16 @@ class ShikimoriRepository @Inject constructor( return if (pageOffset != 0) list.drop(pageOffset) else list } - override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { - val user = cachedUser ?: loadUser() + suspend fun createRate(mangaId: Long, shikiMangaId: Long) { + val user = getCachedUser() ?: loadUser() val payload = JSONObject() payload.put( "user_rate", JSONObject().apply { - put("target_id", scrobblerMangaId) + put("target_id", shikiMangaId) put("target_type", "Manga") put("user_id", user.id) - }, + } ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") @@ -130,13 +115,13 @@ class ShikimoriRepository @Inject constructor( saveRate(response, mangaId) } - override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { + suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { val payload = JSONObject() payload.put( "user_rate", JSONObject().apply { put("chapters", chapter.number) - }, + } ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") @@ -149,7 +134,7 @@ class ShikimoriRepository @Inject constructor( saveRate(response, mangaId) } - override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { + suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { val payload = JSONObject() payload.put( "user_rate", @@ -161,7 +146,7 @@ class ShikimoriRepository @Inject constructor( if (status != null) { put("status", status) } - }, + } ) val url = BASE_URL.toHttpUrl().newBuilder() .addPathSegment("api") @@ -174,7 +159,7 @@ class ShikimoriRepository @Inject constructor( saveRate(response, mangaId) } - override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { val request = Request.Builder() .get() .url("${BASE_URL}api/mangas/$id") @@ -191,32 +176,24 @@ class ShikimoriRepository @Inject constructor( status = json.getString("status"), chapter = json.getInt("chapters"), comment = json.getString("text"), - rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f), + rating = json.getDouble("score").toFloat() / 10f, ) - db.getScrobblingDao().upsert(entity) + db.scrobblingDao.insert(entity) } private fun ScrobblerManga(json: JSONObject) = ScrobblerManga( id = json.getLong("id"), name = json.getString("name"), altName = json.getStringOrNull("russian"), - cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN), - url = json.getString("url").toAbsoluteUrl(DOMAIN), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), ) private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( id = json.getLong("id"), name = json.getString("name"), - cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN), - url = json.getString("url").toAbsoluteUrl(DOMAIN), + cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl("shikimori.one"), + url = json.getString("url").toAbsoluteUrl("shikimori.one"), descriptionHtml = json.getString("description_html"), ) - - @Suppress("FunctionName") - private fun ShikimoriUser(json: JSONObject) = ScrobblerUser( - id = json.getLong("id"), - nickname = json.getString("nickname"), - avatar = json.getString("avatar"), - service = ScrobblerService.SHIKIMORI, - ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt new file mode 100644 index 000000000..0dfe0421e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriStorage.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.data + +import android.content.Context +import androidx.core.content.edit +import org.json.JSONObject +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser + +private const val PREF_NAME = "shikimori" +private const val KEY_ACCESS_TOKEN = "access_token" +private const val KEY_REFRESH_TOKEN = "refresh_token" +private const val KEY_USER = "user" + +class ShikimoriStorage(context: Context) { + + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + var accessToken: String? + get() = prefs.getString(KEY_ACCESS_TOKEN, null) + set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } + + var refreshToken: String? + get() = prefs.getString(KEY_REFRESH_TOKEN, null) + set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } + + var user: ShikimoriUser? + get() = prefs.getString(KEY_USER, null)?.let { + ShikimoriUser(JSONObject(it)) + } + set(value) = prefs.edit { + putString(KEY_USER, value?.toJson()?.toString()) + } + + fun clear() = prefs.edit { + clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt new file mode 100644 index 000000000..79ecfa6c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/data/model/ShikimoriUser.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.data.model + +import org.json.JSONObject + +class ShikimoriUser( + val id: Long, + val nickname: String, + val avatar: String, +) { + + constructor(json: JSONObject) : this( + id = json.getLong("id"), + nickname = json.getString("nickname"), + avatar = json.getString("avatar"), + ) + + fun toJson() = JSONObject().apply { + put("id", id) + put("nickname", nickname) + put("avatar", avatar) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShikimoriUser + + if (id != other.id) return false + if (nickname != other.nickname) return false + if (avatar != other.avatar) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + nickname.hashCode() + result = 31 * result + avatar.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt new file mode 100644 index 000000000..72f9d5cbf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt @@ -0,0 +1,68 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.domain + +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerMangaInfo +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository + +private const val RATING_MAX = 10f + +class ShikimoriScrobbler( + private val repository: ShikimoriRepository, + db: MangaDatabase, +) : Scrobbler(db, ScrobblerService.SHIKIMORI) { + + init { + statuses[ScrobblingStatus.PLANNED] = "planned" + statuses[ScrobblingStatus.READING] = "watching" + statuses[ScrobblingStatus.RE_READING] = "rewatching" + statuses[ScrobblingStatus.COMPLETED] = "completed" + statuses[ScrobblingStatus.ON_HOLD] = "on_hold" + statuses[ScrobblingStatus.DROPPED] = "dropped" + } + + override val isAvailable: Boolean + get() = repository.isAuthorized + + override suspend fun findManga(query: String, offset: Int): List { + return repository.findManga(query, offset) + } + + override suspend fun linkManga(mangaId: Long, targetId: Long) { + repository.createRate(mangaId, targetId) + } + + override suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return + repository.updateRate(entity.id, entity.mangaId, chapter) + } + + override suspend fun updateScrobblingInfo( + mangaId: Long, + rating: Float, + status: ScrobblingStatus?, + comment: String?, + ) { + val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) + requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } + repository.updateRate( + rateId = entity.id, + mangaId = entity.mangaId, + rating = rating * RATING_MAX, + status = statuses[status], + comment = comment, + ) + } + + override suspend fun unregisterScrobbling(mangaId: Long) { + repository.unregister(mangaId) + } + + override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { + return repository.getMangaInfo(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt new file mode 100644 index 000000000..10098a239 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsFragment.kt @@ -0,0 +1,79 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.ui + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import coil.ImageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser +import org.koitharu.kotatsu.utils.PreferenceIconTarget +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.withArgs + +class ShikimoriSettingsFragment : BasePreferenceFragment(R.string.shikimori) { + + private val viewModel by viewModel { + parametersOf(arguments?.getString(ARG_AUTH_CODE)) + } + private val coil by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_shikimori) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.user.observe(viewLifecycleOwner, this::onUserChanged) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_USER -> openAuthorization() + KEY_LOGOUT -> { + viewModel.logout() + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun onUserChanged(user: ShikimoriUser?) { + val pref = findPreference(KEY_USER) ?: return + pref.isSelectable = user == null + pref.title = user?.nickname ?: getString(R.string.sign_in) + ImageRequest.Builder(requireContext()) + .data(user?.avatar) + .transformations(CircleCropTransformation()) + .target(PreferenceIconTarget(pref)) + .enqueueWith(coil) + findPreference(KEY_LOGOUT)?.isVisible = user != null + } + + private fun openAuthorization(): Boolean { + return runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(viewModel.authorizationUrl) + startActivity(intent) + }.isSuccess + } + + companion object { + + private const val KEY_USER = "shiki_user" + private const val KEY_LOGOUT = "shiki_logout" + + private const val ARG_AUTH_CODE = "auth_code" + + fun newInstance(authCode: String?) = ShikimoriSettingsFragment().withArgs(1) { + putString(ARG_AUTH_CODE, authCode) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt new file mode 100644 index 000000000..ef8f73b85 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/shikimori/ui/ShikimoriSettingsViewModel.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.scrobbling.shikimori.ui + +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.scrobbling.shikimori.data.model.ShikimoriUser + +class ShikimoriSettingsViewModel( + private val repository: ShikimoriRepository, + authCode: String?, +) : BaseViewModel() { + + val authorizationUrl: String + get() = repository.oauthUrl + + val user = MutableLiveData() + + init { + if (authCode != null) { + authorize(authCode) + } else { + loadUser() + } + } + + fun logout() { + launchJob(Dispatchers.Default) { + repository.logout() + user.postValue(null) + } + } + + private fun loadUser() = launchJob(Dispatchers.Default) { + val userModel = if (repository.isAuthorized) { + repository.getCachedUser()?.let(user::postValue) + repository.loadUser() + } else { + null + } + user.postValue(userModel) + } + + private fun authorize(code: String) = launchJob(Dispatchers.Default) { + repository.authorize(code) + user.postValue(repository.loadUser()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt new file mode 100644 index 000000000..276502ec7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt @@ -0,0 +1,155 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.* +import android.widget.Toast +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.FragmentManager +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.base.ui.BaseBottomSheet +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikiMangaSelectionDecoration +import org.koitharu.kotatsu.scrobbling.ui.selector.adapter.ShikimoriSelectorAdapter +import org.koitharu.kotatsu.utils.BottomSheetToolbarController +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.withArgs + +class ScrobblingSelectorBottomSheet : + BaseBottomSheet(), + OnListItemClickListener, + PaginationScrollListener.Callback, + View.OnClickListener, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener, + DialogInterface.OnKeyListener { + + private val viewModel by viewModel { + parametersOf(requireNotNull(requireArguments().getParcelable(MangaIntent.KEY_MANGA)).manga) + } + + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding { + return SheetScrobblingSelectorBinding.inflate(inflater, container, false) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).also { + it.setOnKeyListener(this) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setNavigationOnClickListener { dismiss() } + addBottomSheetCallback(BottomSheetToolbarController(binding.toolbar)) + val listAdapter = ShikimoriSelectorAdapter(viewLifecycleOwner, get(), this) + val decoration = ShikiMangaSelectionDecoration(view.context) + with(binding.recyclerView) { + adapter = listAdapter + addItemDecoration(decoration) + addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorBottomSheet)) + } + binding.buttonDone.setOnClickListener(this) + initOptionsMenu() + + viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } + viewModel.selectedItemId.observe(viewLifecycleOwner) { + decoration.checkedItemId = it + binding.recyclerView.invalidateItemDecorations() + } + viewModel.onError.observe(viewLifecycleOwner, ::onError) + viewModel.onClose.observe(viewLifecycleOwner) { + dismiss() + } + viewModel.searchQuery.observe(viewLifecycleOwner) { + binding.toolbar.subtitle = it + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_done -> viewModel.onDoneClick() + } + } + + override fun onItemClick(item: ScrobblerManga, view: View) { + viewModel.selectedItemId.value = item.id + } + + override fun onScrolledToEnd() { + viewModel.loadList(append = true) + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + setExpanded(isExpanded = true, isLocked = true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + val searchView = (item.actionView as? SearchView) ?: return false + searchView.setQuery("", false) + searchView.post { setExpanded(isExpanded = false, isLocked = false) } + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + if (query == null || query.length < 3) { + return false + } + viewModel.search(query) + binding.toolbar.menu.findItem(R.id.action_search)?.collapseActionView() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean = false + + override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_search) ?: return false + if (menuItem.isActionViewExpanded) { + if (event?.action == KeyEvent.ACTION_UP) { + menuItem.collapseActionView() + } + return true + } + } + return false + } + + private fun onError(e: Throwable) { + Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() + if (viewModel.isEmpty) { + dismissAllowingStateLoss() + } + } + + private fun initOptionsMenu() { + binding.toolbar.inflateMenu(R.menu.opt_shiki_selector) + val searchMenuItem = binding.toolbar.menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + companion object { + + private const val TAG = "ScrobblingSelectorBottomSheet" + + fun show(fm: FragmentManager, manga: Manga) = + ScrobblingSelectorBottomSheet().withArgs(1) { + putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false)) + }.show(fm, TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt new file mode 100644 index 000000000..2c881b23b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt @@ -0,0 +1,101 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.RecyclerView.NO_ID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import org.koitharu.kotatsu.base.ui.BaseViewModel +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.parsers.model.Manga +import org.koitharu.kotatsu.scrobbling.domain.Scrobbler +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct + +class ScrobblingSelectorViewModel( + val manga: Manga, + private val scrobbler: Scrobbler, +) : BaseViewModel() { + + private val shikiMangaList = MutableStateFlow?>(null) + private val hasNextPage = MutableStateFlow(false) + private var loadingJob: Job? = null + private var doneJob: Job? = null + + val content: LiveData> = combine( + shikiMangaList.filterNotNull(), + hasNextPage + ) { list, isHasNextPage -> + when { + list.isEmpty() -> listOf() + isHasNextPage -> list + LoadingFooter + else -> list + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + val selectedItemId = MutableLiveData(NO_ID) + val searchQuery = MutableLiveData(manga.title) + val onClose = SingleLiveEvent() + + val isEmpty: Boolean + get() = shikiMangaList.value.isNullOrEmpty() + + init { + launchJob(Dispatchers.Default) { + try { + val info = scrobbler.getScrobblingInfoOrNull(manga.id) + if (info != null) { + selectedItemId.postValue(info.targetId) + } + } finally { + loadList(append = false) + } + } + } + + fun search(query: String) { + loadingJob?.cancel() + searchQuery.value = query + loadList(append = false) + } + + fun loadList(append: Boolean) { + if (loadingJob?.isActive == true) { + return + } + if (append && !hasNextPage.value) { + return + } + loadingJob = launchLoadingJob(Dispatchers.Default) { + val offset = if (append) shikiMangaList.value?.size ?: 0 else 0 + val list = scrobbler.findManga(checkNotNull(searchQuery.value), offset) + if (!append) { + shikiMangaList.value = list + } else if (list.isNotEmpty()) { + shikiMangaList.value = shikiMangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } + } + + fun onDoneClick() { + if (doneJob?.isActive == true) { + return + } + val targetId = selectedItemId.value ?: NO_ID + if (targetId == NO_ID) { + onClose.call(Unit) + } + doneJob = launchJob(Dispatchers.Default) { + scrobbler.linkManga(manga.id, targetId) + onClose.postCall(Unit) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerMangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt similarity index 81% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerMangaSelectionDecoration.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt index 4fa7f1a6c..3cd806a99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerMangaSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikiMangaSelectionDecoration.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter import android.content.Context import android.graphics.Canvas @@ -7,11 +7,11 @@ import android.graphics.RectF import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID -import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.utils.ext.getItem -class ScrobblerMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { +class ShikiMangaSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { var checkedItemId: Long get() = selection.singleOrNull() ?: NO_ID @@ -49,4 +49,4 @@ class ScrobblerMangaSelectionDecoration(context: Context) : MangaSelectionDecora draw(canvas) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt similarity index 57% rename from app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt rename to app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt index df3fb42ad..c9f76e965 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblingMangaAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriMangaAD.kt @@ -1,24 +1,26 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.textAndVisible -fun scrobblingMangaAD( +fun shikimoriMangaAD( lifecycleOwner: LifecycleOwner, coil: ImageLoader, clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } ) { + itemView.setOnClickListener { clickListener.onItemClick(item, it) } @@ -26,12 +28,17 @@ fun scrobblingMangaAD( bind { binding.textViewTitle.text = item.name binding.textViewSubtitle.textAndVisible = item.altName - binding.imageViewCover.newImageRequest(lifecycleOwner, item.cover)?.run { + binding.imageViewCover.newImageRequest(item.cover)?.run { placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) + error(R.drawable.ic_placeholder) allowRgb565(true) + lifecycle(lifecycleOwner) enqueueWith(coil) } } -} + + onViewRecycled { + binding.imageViewCover.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt new file mode 100644 index 000000000..90c6af56b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ShikimoriSelectorAdapter.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlin.jvm.internal.Intrinsics +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +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.scrobbling.domain.model.ScrobblerManga + +class ShikimoriSelectorAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(loadingStateAD()) + .addDelegate(shikimoriMangaAD(lifecycleOwner, coil, clickListener)) + .addDelegate(loadingFooterAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return when { + oldItem === newItem -> true + oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id + else -> false + } + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt new file mode 100644 index 000000000..b06e06bfd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.search + +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider +import org.koitharu.kotatsu.search.ui.SearchViewModel +import org.koitharu.kotatsu.search.ui.multi.MultiSearchViewModel +import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel + +val searchModule + get() = module { + + factory { MangaSearchRepository(get(), get(), androidContext(), get()) } + factory { MangaSuggestionsProvider.createSuggestions(androidContext()) } + + viewModel { params -> SearchViewModel(MangaRepository(params[0]), params[1], get()) } + viewModel { SearchSuggestionViewModel(get(), get()) } + viewModel { params -> MultiSearchViewModel(params[0], get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt similarity index 51% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt rename to app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index e2b474ee4..2c95fb632 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -1,52 +1,56 @@ package org.koitharu.kotatsu.search.domain +import android.annotation.SuppressLint import android.app.SearchManager import android.content.Context import android.provider.SearchRecentSuggestions -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTag -import org.koitharu.kotatsu.core.db.entity.toMangaTagsList +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.ContentType 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.parsers.util.levenshteinDistance -import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider -import javax.inject.Inject -@Reusable -class MangaSearchRepository @Inject constructor( +class MangaSearchRepository( + private val settings: AppSettings, private val db: MangaDatabase, - private val sourcesRepository: MangaSourcesRepository, - @ApplicationContext private val context: Context, + private val context: Context, private val recentSuggestions: SearchRecentSuggestions, - private val settings: AppSettings, ) { + fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow = + settings.getMangaSources(includeHidden = false).asFlow() + .flatMapMerge(concurrency) { source -> + runCatching { + MangaRepository(source).getList( + offset = 0, + query = query, + ) + }.getOrElse { + emptyList() + }.asFlow() + }.filter { + match(it, query) + } + suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List { if (query.isEmpty()) { return emptyList() } - val skipNsfw = settings.isNsfwContentDisabled return if (source != null) { - db.getMangaDao().searchByTitle("%$query%", source.name, limit) + db.mangaDao.searchByTitle("%$query%", source.name, limit) } else { - db.getMangaDao().searchByTitle("%$query%", limit) - }.let { - if (skipNsfw) it.filterNot { x -> x.manga.isNsfw } else it - } - .map { it.toManga() } + db.mangaDao.searchByTitle("%$query%", limit) + }.map { it.toManga() } .sortedBy { x -> x.title.levenshteinDistance(query) } } @@ -56,10 +60,10 @@ class MangaSearchRepository @Inject constructor( ): List = withContext(Dispatchers.IO) { context.contentResolver.query( MangaSuggestionsProvider.QUERY_URI, - arrayOf(SearchManager.SUGGEST_COLUMN_QUERY), + SUGGESTION_PROJECTION, "${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?", arrayOf("%$query%"), - "date DESC", + "date DESC" )?.use { cursor -> val count = minOf(cursor.count, limit) if (count == 0) { @@ -76,58 +80,14 @@ class MangaSearchRepository @Inject constructor( }.orEmpty() } - suspend fun getQueryHintSuggestion( - query: String, - limit: Int, - ): List { - if (query.isEmpty()) { - return emptyList() - } - val titles = db.getSuggestionDao().getTitles("$query%") - if (titles.isEmpty()) { - return emptyList() - } - return titles.shuffled().take(limit) - } - suspend fun getTagsSuggestion(query: String, limit: Int, source: MangaSource?): List { return when { - query.isNotEmpty() && source != null -> db.getTagsDao() - .findTags(source.name, "%$query%", limit) - query.isNotEmpty() -> db.getTagsDao().findTags("%$query%", limit) - source != null -> db.getTagsDao().findPopularTags(source.name, limit) - else -> db.getTagsDao().findPopularTags(limit) - }.toMangaTagsList() - } - - suspend fun getTagsSuggestion(tags: Set): List { - val ids = tags.mapToSet { it.toEntity().id } - return if (ids.size == 1) { - db.getTagsDao().findRelatedTags(ids.first()) - } else { - db.getTagsDao().findRelatedTags(ids) - }.mapNotNull { x -> - if (x.id in ids) null else x.toMangaTag() - } - } - - suspend fun getRareTags(source: MangaSource, limit: Int): List { - return db.getTagsDao().findRareTags(source.name, limit).toMangaTagsList() - } - - fun getSourcesSuggestion(query: String, limit: Int): List { - if (query.length < 3) { - return emptyList() - } - val skipNsfw = settings.isNsfwContentDisabled - val sources = sourcesRepository.allMangaSources - .filter { x -> - (x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true) - } - return if (limit == 0) { - sources - } else { - sources.take(limit) + query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit) + query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit) + source != null -> db.tagsDao.findPopularTags(source.name, limit) + else -> db.tagsDao.findPopularTags(limit) + }.map { + it.toMangaTag() } } @@ -150,10 +110,33 @@ class MangaSearchRepository @Inject constructor( suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) { context.contentResolver.query( MangaSuggestionsProvider.QUERY_URI, - arrayOf(SearchManager.SUGGEST_COLUMN_QUERY), + SUGGESTION_PROJECTION, null, arrayOfNulls(1), - null, + null )?.use { cursor -> cursor.count } ?: 0 } -} + + private companion object { + + private val REGEX_SPACE = Regex("\\s+") + val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY) + + @SuppressLint("DefaultLocale") + fun match(manga: Manga, query: String): Boolean { + val words = HashSet() + words += manga.title.lowercase().split(REGEX_SPACE) + words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty() + val words2 = query.lowercase().split(REGEX_SPACE).toSet() + for (w in words) { + for (w2 in words2) { + val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f) + if (diff < 0.5) { + return true + } + } + } + return false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt new file mode 100644 index 000000000..4904ab34a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.search.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import org.koin.androidx.viewmodel.ext.android.getViewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags +import org.koitharu.kotatsu.databinding.ActivityContainerBinding +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment +import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel + +class MangaListActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityContainerBinding.inflate(layoutInflater)) + val tags = intent.getParcelableExtra(EXTRA_TAGS)?.tags ?: run { + finishAfterTransition() + return + } + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val fm = supportFragmentManager + if (fm.findFragmentById(R.id.container) == null) { + fm.commit { + val fragment = RemoteListFragment.newInstance(tags.first().source) + replace(R.id.container, fragment) + runOnCommit(ApplyFilterRunnable(fragment, tags)) + } + } + } + + override fun onWindowInsetsChanged(insets: Insets) { + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + binding.container.updatePadding( + bottom = insets.bottom + ) + } + + private class ApplyFilterRunnable( + private val fragment: Fragment, + private val tags: Set, + ) : Runnable { + + override fun run() { + val viewModel = fragment.getViewModel { + parametersOf(tags.first().source) + } + viewModel.applyFilter(tags) + } + } + + companion object { + + private const val EXTRA_TAGS = "tags" + + fun newIntent(context: Context, tags: Set) = + Intent(context, MangaListActivity::class.java) + .putExtra(EXTRA_TAGS, ParcelableMangaTags(tags)) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSuggestionsProvider.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt similarity index 61% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt index cb89e0860..ea5551701 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -3,39 +3,35 @@ package org.koitharu.kotatsu.search.ui import android.content.Context import android.content.Intent import android.os.Bundle -import androidx.activity.viewModels +import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets -import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.commit -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showKeyboard +import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.databinding.ActivitySearchBinding import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel +import org.koitharu.kotatsu.utils.ext.showKeyboard -@AndroidEntryPoint class SearchActivity : BaseActivity(), SearchView.OnQueryTextListener { - private val searchSuggestionViewModel by viewModels() + private val searchSuggestionViewModel by viewModel() private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySearchBinding.inflate(layoutInflater)) - source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: run { + source = intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: run { finishAfterTransition() return } val query = intent.getStringExtra(EXTRA_QUERY) supportActionBar?.setDisplayHomeAsUpEnabled(true) - searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) - with(viewBinding.searchView) { + with(binding.searchView) { queryHint = getString(R.string.search_on_s, source.title) setOnQueryTextListener(this@SearchActivity) @@ -49,12 +45,17 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery } override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.container.updatePadding( - bottom = insets.bottom, + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } + binding.container.updatePadding( + bottom = insets.bottom ) } @@ -65,26 +66,15 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery } title = query supportFragmentManager.commit { - setReorderingAllowed(true) replace(R.id.container, SearchFragment.newInstance(source, q)) } - viewBinding.searchView.clearFocus() + binding.searchView.clearFocus() searchSuggestionViewModel.saveQuery(q) return true } override fun onQueryTextChange(newText: String?): Boolean = false - private fun onIncognitoModeChanged(isIncognito: Boolean) { - var options = viewBinding.searchView.imeOptions - options = if (isIncognito) { - options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } else { - options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() - } - viewBinding.searchView.imeOptions = options - } - companion object { private const val EXTRA_SOURCE = "source" @@ -95,4 +85,4 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery .putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_QUERY, query) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt new file mode 100644 index 000000000..6aec88710 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.search.ui + +import android.view.Menu +import androidx.appcompat.view.ActionMode +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.utils.ext.serializableArgument +import org.koitharu.kotatsu.utils.ext.stringArgument +import org.koitharu.kotatsu.utils.ext.withArgs + +class SearchFragment : MangaListFragment() { + + override val viewModel by viewModel { + parametersOf(source, query) + } + + private val query by stringArgument(ARG_QUERY) + private val source by serializableArgument(ARG_SOURCE) + + override fun onScrolledToEnd() { + viewModel.loadNextPage() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + + companion object { + + private const val ARG_QUERY = "query" + private const val ARG_SOURCE = "source" + + fun newInstance(source: MangaSource, query: String) = SearchFragment().withArgs(2) { + putSerializable(ARG_SOURCE, source) + putString(ARG_QUERY, query) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 2b6f4a2a7..9615e84a6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -1,45 +1,24 @@ package org.koitharu.kotatsu.search.ui -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -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.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.MangaListViewModel -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.toErrorFooter -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi +import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -@HiltViewModel -class SearchViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - repositoryFactory: MangaRepository.Factory, - settings: AppSettings, - private val extraProvider: ListExtraProvider, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { +class SearchViewModel( + private val repository: MangaRepository, + private val query: String, + settings: AppSettings +) : MangaListViewModel(settings) { - private val query = savedStateHandle.require(SearchFragment.ARG_QUERY) - private val repository = repositoryFactory.create(savedStateHandle.require(SearchFragment.ARG_SOURCE)) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) @@ -47,33 +26,32 @@ class SearchViewModel @Inject constructor( override val content = combine( mangaList, - listMode, + createListModeFlow(), listError, - hasNextPage, + hasNextPage ) { list, mode, error, hasNext -> when { list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) list == null -> listOf(LoadingState) list.isEmpty() -> listOf( EmptyState( - icon = R.drawable.ic_empty_common, + icon = R.drawable.ic_empty_search, textPrimary = R.string.nothing_found, textSecondary = R.string.text_search_holder_secondary, actionStringRes = 0, - ), + ) ) - else -> { val result = ArrayList(list.size + 1) - list.toUi(result, mode, extraProvider) + list.toUi(result, mode) when { error != null -> result += error.toErrorFooter() - hasNext -> result += LoadingFooter() + hasNext -> result += LoadingFooter } result } } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) init { loadList(append = false) @@ -102,7 +80,7 @@ class SearchViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value?.size ?: 0 else 0, - filter = MangaListFilter.Search(query) + query = query, ) if (!append) { mangaList.value = list @@ -110,11 +88,9 @@ class SearchViewModel @Inject constructor( mangaList.value = mangaList.value?.plus(list) ?: list } hasNextPage.value = list.isNotEmpty() - } catch (e: CancellationException) { - throw e } catch (e: Throwable) { listError.value = e } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt new file mode 100644 index 000000000..369594eae --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -0,0 +1,169 @@ +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.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.view.ActionMode +import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.base.ui.list.ListSelectionController +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet +import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration +import org.koitharu.kotatsu.list.ui.adapter.MangaListListener +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.ui.SearchActivity +import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver +import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter +import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.findViewsByType + +class MultiSearchActivity : BaseActivity(), MangaListListener, + ListSelectionController.Callback { + + private val viewModel by viewModel { + parametersOf(intent.getStringExtra(EXTRA_QUERY).orEmpty()) + } + private lateinit var adapter: MultiSearchAdapter + private lateinit var selectionController: ListSelectionController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivitySearchMultiBinding.inflate(layoutInflater)) + + val itemCLickListener = object : OnListItemClickListener { + override fun onItemClick(item: MultiSearchListModel, view: View) { + startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value)) + } + } + val sizeResolver = ItemSizeResolver(resources, get()) + val selectionDecoration = MangaSelectionDecoration(this) + selectionController = ListSelectionController( + activity = this, + decoration = selectionDecoration, + registryOwner = this, + callback = this, + ) + adapter = MultiSearchAdapter( + lifecycleOwner = this, + coil = get(), + listener = this, + itemClickListener = itemCLickListener, + sizeResolver = sizeResolver, + selectionDecoration = selectionDecoration, + ) + binding.recyclerView.adapter = adapter + binding.recyclerView.setHasFixedSize(true) + + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setSubtitle(R.string.search_results) + } + + viewModel.query.observe(this) { title = it } + viewModel.list.observe(this) { adapter.items = it } + } + + override fun onWindowInsetsChanged(insets: Insets) { + with(binding.toolbar) { + updatePadding( + left = insets.left, + right = insets.right, + ) + updateLayoutParams { + topMargin = insets.top + } + } + binding.recyclerView.updatePadding( + bottom = insets.bottom, + left = insets.left, + right = insets.right, + ) + } + + override fun onItemClick(item: Manga, view: View) { + if (!selectionController.onItemClick(item.id)) { + val intent = DetailsActivity.newIntent(this, item) + startActivity(intent) + } + } + + override fun onItemLongClick(item: Manga, view: View): Boolean { + return selectionController.onItemLongClick(item.id) + } + + override fun onRetryClick(error: Throwable) { + viewModel.doSearch(viewModel.query.value.orEmpty()) + } + + override fun onTagRemoveClick(tag: MangaTag) = Unit + + override fun onFilterClick() = Unit + + override fun onEmptyActionClick() = Unit + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.title = selectionController.count.toString() + return true + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_share -> { + ShareHelper(this).shareMangaLinks(collectSelectedItems()) + mode.finish() + true + } + R.id.action_favourite -> { + FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems()) + mode.finish() + true + } + R.id.action_save -> { + DownloadService.confirmAndStart(this, collectSelectedItems()) + mode.finish() + true + } + else -> false + } + } + + override fun onSelectionChanged(count: Int) { + binding.recyclerView.findViewsByType(RecyclerView::class.java).forEach { + it.invalidateItemDecorations() + } + } + + private fun collectSelectedItems(): Set { + return viewModel.getItems(selectionController.peekCheckedIds()) + } + + companion object { + + private const val EXTRA_QUERY = "query" + + fun newIntent(context: Context, query: String) = + Intent(context, MultiSearchActivity::class.java) + .putExtra(EXTRA_QUERY, query) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt new file mode 100644 index 000000000..f8801ca11 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.search.ui.multi + +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaItemModel +import org.koitharu.kotatsu.parsers.model.MangaSource + +class MultiSearchListModel( + val source: MangaSource, + val hasMore: Boolean, + val list: List, +) : ListModel { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MultiSearchListModel + + if (source != other.source) return false + if (hasMore != other.hasMore) return false + if (list != other.list) return false + + return true + } + + override fun hashCode(): Int { + var result = source.hashCode() + result = 31 * result + hasMore.hashCode() + result = 31 * result + list.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt new file mode 100644 index 000000000..b2ababf6d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -0,0 +1,130 @@ +package org.koitharu.kotatsu.search.ui.multi + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.exceptions.CompositeException +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.list.ui.model.* +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +private const val MAX_PARALLELISM = 4 +private const val MIN_HAS_MORE_ITEMS = 8 + +class MultiSearchViewModel( + initialQuery: String, + private val settings: AppSettings, +) : BaseViewModel() { + + private var searchJob: Job? = null + private val listData = MutableStateFlow>(emptyList()) + private val loadingData = MutableStateFlow(false) + private var listError = MutableStateFlow(null) + + val query = MutableLiveData(initialQuery) + val list: LiveData> = combine( + listData, + loadingData, + listError, + ) { list, loading, error -> + when { + list.isEmpty() -> listOf( + when { + loading -> LoadingState + error != null -> error.toErrorState(canRetry = true) + else -> EmptyState( + icon = R.drawable.ic_empty_search, + textPrimary = R.string.nothing_found, + textSecondary = R.string.text_search_holder_secondary, + actionStringRes = 0, + ) + } + ) + loading -> list + LoadingFooter + else -> list + } + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) + + init { + doSearch(initialQuery) + } + + fun getItems(ids: Set): Set { + val result = HashSet(ids.size) + listData.value.forEach { x -> + for (item in x.list) { + if (item.id in ids) { + result.add(item.manga) + } + } + } + return result + } + + fun doSearch(q: String) { + val prevJob = searchJob + searchJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + try { + listError.value = null + listData.value = emptyList() + loadingData.value = true + query.postValue(q) + searchImpl(q) + } catch (e: Throwable) { + listError.value = e + } finally { + loadingData.value = false + } + } + } + + private suspend fun searchImpl(q: String) = coroutineScope { + val sources = settings.getMangaSources(includeHidden = false) + val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) + val deferredList = sources.map { source -> + async(dispatcher) { + runCatching { + val list = MangaRepository(source).getList(offset = 0, query = q) + .toUi(ListMode.GRID) + if (list.isNotEmpty()) { + MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list) + } else { + null + } + }.onFailure { + it.printStackTraceDebug() + } + } + } + + val errors = ArrayList() + for (deferred in deferredList) { + deferred.await() + .onSuccess { item -> + if (item != null) { + listData.update { x -> x + item } + } + }.onFailure { + errors.add(it) + } + } + if (listData.value.isEmpty()) { + when (errors.size) { + 0 -> Unit + 1 -> throw errors[0] + else -> throw CompositeException(errors) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt new file mode 100644 index 000000000..a5f5d3f72 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.search.ui.multi.adapter + +import android.content.res.Resources +import kotlin.math.roundToInt +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings + +class ItemSizeResolver(resources: Resources, settings: AppSettings) { + + private val scaleFactor = settings.gridSize / 100f + private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width) + + val cellWidth: Int + get() = (gridWidth * scaleFactor).roundToInt() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt new file mode 100644 index 000000000..35afb49d4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.search.ui.multi.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView.RecycledViewPool +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration +import org.koitharu.kotatsu.list.ui.adapter.* +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel +import kotlin.jvm.internal.Intrinsics + +class MultiSearchAdapter( + lifecycleOwner: LifecycleOwner, + coil: ImageLoader, + listener: MangaListListener, + itemClickListener: OnListItemClickListener, + sizeResolver: ItemSizeResolver, + selectionDecoration: MangaSelectionDecoration, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + val pool = RecycledViewPool() + delegatesManager + .addDelegate( + searchResultsAD( + sharedPool = pool, + lifecycleOwner = lifecycleOwner, + coil = coil, + sizeResolver = sizeResolver, + selectionDecoration = selectionDecoration, + listener = listener, + itemClickListener = itemClickListener, + ) + ) + .addDelegate(loadingStateAD()) + .addDelegate(loadingFooterAD()) + .addDelegate(emptyStateListAD(listener)) + .addDelegate(errorStateListAD(listener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return when { + oldItem is MultiSearchListModel && newItem is MultiSearchListModel -> { + oldItem.source == newItem.source + } + else -> oldItem.javaClass == newItem.javaClass + } + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index 6f945677f..ca95bacc4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.search.ui.multi.adapter -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView.RecycledViewPool @@ -8,16 +7,13 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.databinding.ItemListGroupBinding import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel @@ -30,12 +26,12 @@ fun searchResultsAD( listener: OnListItemClickListener, itemClickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, + { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) } ) { binding.recyclerView.setRecycledViewPool(sharedPool) val adapter = ListDelegationAdapter( - mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listener), + mangaGridItemAD(coil, lifecycleOwner, listener, sizeResolver) ) binding.recyclerView.addItemDecoration(selectionDecoration) binding.recyclerView.adapter = adapter @@ -47,9 +43,7 @@ fun searchResultsAD( bind { binding.textViewTitle.text = item.source.title binding.buttonMore.isVisible = item.hasMore - adapter.items = item.list adapter.notifyDataSetChanged() - binding.recyclerView.isGone = item.list.isEmpty() - binding.textViewError.textAndVisible = item.error?.getDisplayMessage(context.resources) + adapter.items = item.list } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 58ec1eac3..7d9a3b6cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -2,52 +2,39 @@ package org.koitharu.kotatsu.search.ui.suggestion import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ItemTouchHelper -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.os.VoiceInputContract -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding +import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ext.measureHeight -@AndroidEntryPoint class SearchSuggestionFragment : BaseFragment(), SearchSuggestionItemCallback.SuggestionItemListener { - @Inject - lateinit var coil: ImageLoader + private val viewModel by sharedViewModel() - private val viewModel by activityViewModels() - private val voiceInputLauncher = registerForActivityResult(VoiceInputContract()) { result -> - if (result != null) { - viewModel.onQueryChanged(result) - } - } - - override fun onCreateViewBinding( + override fun onInflateView( inflater: LayoutInflater, container: ViewGroup?, ) = FragmentSearchSuggestionBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: FragmentSearchSuggestionBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val adapter = SearchSuggestionAdapter( - coil = coil, + coil = get(), lifecycleOwner = viewLifecycleOwner, listener = requireActivity() as SearchSuggestionListener, ) - addMenuProvider(SearchSuggestionMenuProvider(binding.root.context, voiceInputLauncher, viewModel)) binding.root.adapter = adapter - binding.root.setHasFixedSize(true) viewModel.suggestion.observe(viewLifecycleOwner) { adapter.items = it } @@ -56,12 +43,11 @@ class SearchSuggestionFragment : } override fun onWindowInsetsChanged(insets: Insets) { + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing) - requireViewBinding().root.updatePadding( - top = extraPadding, - right = insets.right, - left = insets.left, - bottom = insets.bottom, + binding.root.updatePadding( + top = headerHeight + extraPadding, + bottom = insets.bottom + extraPadding, ) } @@ -69,13 +55,8 @@ class SearchSuggestionFragment : viewModel.deleteQuery(query) } - override fun onResume() { - super.onResume() - viewModel.onResume() - } - companion object { fun newInstance() = SearchSuggestionFragment() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt index 84120ad16..7c65c7c42 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt @@ -2,9 +2,9 @@ package org.koitharu.kotatsu.search.ui.suggestion import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.search.ui.suggestion.adapter.SEARCH_SUGGESTION_ITEM_TYPE_QUERY import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem +import org.koitharu.kotatsu.utils.ext.getItem class SearchSuggestionItemCallback( private val listener: SuggestionItemListener, @@ -12,7 +12,7 @@ class SearchSuggestionItemCallback( private val movementFlags = makeMovementFlags( 0, - ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT ) override fun getMovementFlags( @@ -39,4 +39,4 @@ class SearchSuggestionItemCallback( fun onRemoveQuery(query: String) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index 0a35e13a7..ea9dfd6f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.search.ui.suggestion import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag interface SearchSuggestionListener { @@ -12,9 +11,9 @@ interface SearchSuggestionListener { fun onQueryChanged(query: String) - fun onSourceToggle(source: MangaSource, isEnabled: Boolean) - - fun onSourceClick(source: MangaSource) + fun onClearSearchHistory() fun onTagClick(tag: MangaTag) -} + + fun onVoiceSearchClick() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt new file mode 100644 index 000000000..605f3a231 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -0,0 +1,124 @@ +package org.koitharu.kotatsu.search.ui.suggestion + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem + +private const val DEBOUNCE_TIMEOUT = 500L +private const val MAX_MANGA_ITEMS = 6 +private const val MAX_QUERY_ITEMS = 16 +private const val MAX_TAGS_ITEMS = 8 + +class SearchSuggestionViewModel( + private val repository: MangaSearchRepository, + private val settings: AppSettings, +) : BaseViewModel() { + + private val query = MutableStateFlow("") + private val source = MutableStateFlow(null) + private val isLocalSearch = MutableStateFlow(settings.isSearchSingleSource) + private var suggestionJob: Job? = null + + val suggestion = MutableLiveData>() + + init { + setupSuggestion() + isLocalSearch.onEach { + settings.isSearchSingleSource = it + }.launchIn(viewModelScope) + } + + fun onQueryChanged(newQuery: String) { + query.value = newQuery + } + + fun onSourceChanged(newSource: MangaSource?) { + source.value = newSource + } + + fun saveQuery(query: String) { + repository.saveSearchQuery(query) + } + + fun getLocalSearchSource(): MangaSource? { + return source.value?.takeIf { isLocalSearch.value } + } + + fun clearSearchHistory() { + launchJob { + repository.clearSearchHistory() + setupSuggestion() + } + } + + fun deleteQuery(query: String) { + launchJob { + repository.deleteSearchQuery(query) + setupSuggestion() + } + } + + private fun setupSuggestion() { + suggestionJob?.cancel() + suggestionJob = combine( + query.debounce(DEBOUNCE_TIMEOUT), + source, + isLocalSearch, + ::Triple, + ).mapLatest { (searchQuery, src, srcOnly) -> + buildSearchSuggestion(searchQuery, src, srcOnly) + }.distinctUntilChanged() + .onEach { + suggestion.postValue(it) + }.launchIn(viewModelScope + Dispatchers.Default) + } + + private suspend fun buildSearchSuggestion( + searchQuery: String, + src: MangaSource?, + srcOnly: Boolean, + ): List = coroutineScope { + val queriesDeferred = async { + repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) + } + val tagsDeferred = async { + repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, src.takeIf { srcOnly }) + } + val mangaDeferred = async { + repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, src.takeIf { srcOnly }) + } + + val tags = tagsDeferred.await() + val mangaList = mangaDeferred.await() + val queries = queriesDeferred.await() + + buildList(queries.size + 3) { + if (src != null) { + add(SearchSuggestionItem.Header(src, isLocalSearch)) + } + if (tags.isNotEmpty()) { + add(SearchSuggestionItem.Tags(mapTags(tags))) + } + if (mangaList.isNotEmpty()) { + add(SearchSuggestionItem.MangaList(mangaList)) + } + queries.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } + } + } + + private fun mapTags(tags: List): List = tags.map { tag -> + ChipsView.ChipModel( + icon = 0, + title = tag.title, + data = tag, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..86a8d5283 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.search.ui.suggestion.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener +import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem +import kotlin.jvm.internal.Intrinsics + +const val SEARCH_SUGGESTION_ITEM_TYPE_QUERY = 0 + +class SearchSuggestionAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + listener: SearchSuggestionListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager + .addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener)) + .addDelegate(searchSuggestionHeaderAD(listener)) + .addDelegate(searchSuggestionTagsAD(listener)) + .addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: SearchSuggestionItem, + newItem: SearchSuggestionItem, + ): Boolean = when { + oldItem is SearchSuggestionItem.RecentQuery && newItem is SearchSuggestionItem.RecentQuery -> { + oldItem.query == newItem.query + } + else -> oldItem.javaClass == newItem.javaClass + } + + override fun areContentsTheSame( + oldItem: SearchSuggestionItem, + newItem: SearchSuggestionItem, + ): Boolean = Intrinsics.areEqual(oldItem, newItem) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt new file mode 100644 index 000000000..be60708cd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionHeaderAD.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.search.ui.suggestion.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemSearchSuggestionHeaderBinding +import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener +import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem + +fun searchSuggestionHeaderAD( + listener: SearchSuggestionListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemSearchSuggestionHeaderBinding.inflate(inflater, parent, false) } + ) { + + binding.switchLocal.setOnCheckedChangeListener { _, isChecked -> + item.isChecked.value = isChecked + } + binding.buttonClear.setOnClickListener { + listener.onClearSearchHistory() + } + + bind { + binding.switchLocal.text = getString( + R.string.search_only_on_s, + item.source.title, + ) + binding.switchLocal.isChecked = item.isChecked.value + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt new file mode 100644 index 000000000..25e3eaf7d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.search.ui.suggestion.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener +import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem + +fun searchSuggestionTagsAD( + listener: SearchSuggestionListener, +) = adapterDelegate(R.layout.item_search_suggestion_tags) { + + val chipGroup = itemView as ChipsView + + chipGroup.onChipClickListener = ChipsView.OnChipClickListener { _, data -> + listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener) + } + + bind { + chipGroup.setChips(item.tags) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt similarity index 82% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt index 7609d4881..7e35fa201 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionsMangaListAD.kt @@ -9,21 +9,22 @@ import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.databinding.ItemSearchSuggestionMangaGridBinding import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem +import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest fun searchSuggestionMangaListAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, listener: SearchSuggestionListener, ) = adapterDelegate(R.layout.item_search_suggestion_manga_list) { + val adapter = AsyncListDifferDelegationAdapter( SuggestionMangaDiffCallback(), searchSuggestionMangaGridAD(coil, lifecycleOwner, listener), @@ -48,23 +49,28 @@ private fun searchSuggestionMangaGridAD( lifecycleOwner: LifecycleOwner, listener: SearchSuggestionListener, ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) }, + { layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) } ) { + itemView.setOnClickListener { listener.onMangaClick(item) } bind { - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { + binding.imageViewCover.newImageRequest(item.coverUrl)?.run { placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) + error(R.drawable.ic_placeholder) allowRgb565(true) - source(item.source) + lifecycle(lifecycleOwner) enqueueWith(coil) } binding.textViewTitle.text = item.title } + + onViewRecycled { + binding.imageViewCover.disposeImageRequest() + } } private class SuggestionMangaDiffCallback : DiffUtil.ItemCallback() { @@ -76,4 +82,5 @@ private class SuggestionMangaDiffCallback : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: Manga, newItem: Manga): Boolean { return oldItem.title == newItem.title && oldItem.coverUrl == newItem.coverUrl } -} + +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt new file mode 100644 index 000000000..341c89ac8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.search.ui.suggestion.model + +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.areItemsEquals + +sealed interface SearchSuggestionItem { + + class MangaList( + val items: List, + ) : SearchSuggestionItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MangaList + + return items.areItemsEquals(other.items) { a, b -> + a.title == b.title && a.coverUrl == b.coverUrl + } + } + + override fun hashCode(): Int { + return items.fold(0) { acc, t -> + var r = 31 * acc + t.title.hashCode() + r = 31 * r + t.coverUrl.hashCode() + r + } + } + } + + class RecentQuery( + val query: String, + ) : SearchSuggestionItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RecentQuery + + if (query != other.query) return false + + return true + } + + override fun hashCode(): Int { + return query.hashCode() + } + } + + class Header( + val source: MangaSource, + val isChecked: MutableStateFlow, + ) : SearchSuggestionItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Header + + if (source != other.source) return false + if (isChecked !== other.isChecked) return false + + return true + } + + override fun hashCode(): Int { + var result = source.hashCode() + result = 31 * result + isChecked.hashCode() + return result + } + } + + class Tags( + val tags: List, + ) : SearchSuggestionItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Tags + + if (tags != other.tags) return false + + return true + } + + override fun hashCode(): Int { + return tags.hashCode() + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt new file mode 100644 index 000000000..e13a694de --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.search.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomnavigation.BottomNavigationView + +class SearchBehavior(context: Context?, attrs: AttributeSet?) : + CoordinatorLayout.Behavior(context, attrs) { + + override fun layoutDependsOn( + parent: CoordinatorLayout, + child: SearchToolbar, + dependency: View, + ): Boolean { + return when (dependency) { + is AppBarLayout -> true + is LinearLayout, is BottomNavigationView -> { + dependency.z = child.z + 1 + true + } + else -> super.layoutDependsOn(parent, child, dependency) + } + } + + override fun onDependentViewChanged( + parent: CoordinatorLayout, + child: SearchToolbar, + dependency: View, + ): Boolean { + if (dependency is AppBarLayout) { + child.translationY = dependency.getY() + return true + } + return super.onDependentViewChanged(parent, child, dependency) + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: SearchToolbar, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean { + return axes == ViewCompat.SCROLL_AXIS_VERTICAL + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 914f5ec69..261648fce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -3,26 +3,20 @@ package org.koitharu.kotatsu.search.ui.widget import android.annotation.SuppressLint import android.content.Context import android.os.Parcelable -import android.text.Spannable -import android.text.SpannableString -import android.text.style.TextAppearanceSpan import android.util.AttributeSet -import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.SoundEffectConstants import android.view.accessibility.AccessibilityEvent import android.view.inputmethod.EditorInfo import androidx.annotation.AttrRes -import androidx.annotation.CheckResult -import androidx.annotation.StringRes import androidx.appcompat.widget.AppCompatEditText import androidx.core.content.ContextCompat +import com.google.android.material.R as materialR import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.drawableEnd -import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener -import com.google.android.material.R as materialR +import org.koitharu.kotatsu.utils.ext.drawableEnd +import org.koitharu.kotatsu.utils.ext.drawableStart private const val DRAWABLE_END = 2 @@ -33,13 +27,15 @@ class SearchEditText @JvmOverloads constructor( ) : AppCompatEditText(context, attrs, defStyleAttr) { var searchSuggestionListener: SearchSuggestionListener? = null - private val clearIcon = - ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material) + private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material) + private val voiceIcon = ContextCompat.getDrawable(context, R.drawable.ic_voice_input) private var isEmpty = text.isNullOrEmpty() - init { - hint = wrapHint() - } + var isVoiceSearchEnabled: Boolean = false + set(value) { + field = value + updateActionIcon() + } var query: String get() = text?.trim()?.toString().orEmpty() @@ -54,25 +50,12 @@ class SearchEditText @JvmOverloads constructor( if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { if (hasFocus()) { clearFocus() + // return true } } return super.onKeyPreIme(keyCode, event) } - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { - if (event.isFromSource(InputDevice.SOURCE_KEYBOARD) - && (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) - && event.hasNoModifiers() - && query.isNotEmpty() - ) { - cancelLongPress() - searchSuggestionListener?.onQueryClick(query, submit = true) - clearFocus() - return true - } - return super.onKeyUp(keyCode, event) - } - override fun onEditorAction(actionCode: Int) { super.onEditorAction(actionCode) if (actionCode == EditorInfo.IME_ACTION_SEARCH) { @@ -103,8 +86,7 @@ class SearchEditText @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_UP) { - val drawable = - compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event) + val drawable = compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event) val isOnDrawable = drawable.isVisible && if (layoutDirection == LAYOUT_DIRECTION_RTL) { event.x.toInt() in paddingLeft..(drawable.bounds.width() + paddingLeft) } else { @@ -125,36 +107,21 @@ class SearchEditText @JvmOverloads constructor( text?.clear() } - fun setHintCompat(@StringRes resId: Int) { - hint = wrapHint(context.getString(resId)) - } - private fun onActionIconClick() { when { !text.isNullOrEmpty() -> text?.clear() + isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick() } } private fun updateActionIcon() { val icon = when { !text.isNullOrEmpty() -> clearIcon + isVoiceSearchEnabled -> voiceIcon else -> null } if (icon !== drawableEnd) { setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, icon, null) } } - - @CheckResult - private fun wrapHint(raw: CharSequence? = hint): SpannableString? { - val rawHint = raw?.toString() ?: return null - val formatted = SpannableString(rawHint) - formatted.setSpan( - TextAppearanceSpan(context, R.style.TextAppearance_Kotatsu_SearchView), - 0, - formatted.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) - return formatted - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchToolbar.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchToolbar.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchToolbar.kt rename to app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchToolbar.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt new file mode 100644 index 000000000..603fe1ce5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt @@ -0,0 +1,108 @@ +package org.koitharu.kotatsu.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.core.net.toUri +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.github.AppVersion +import org.koitharu.kotatsu.core.github.GithubRepository +import org.koitharu.kotatsu.core.github.VersionId +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.util.byte2HexFormatted +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit + +class AppUpdateChecker(private val activity: ComponentActivity) { + + private val settings = activity.get() + private val repo = activity.get() + + suspend fun checkIfNeeded(): Boolean? = if ( + settings.isUpdateCheckingEnabled && + settings.lastUpdateCheckTimestamp + PERIOD < System.currentTimeMillis() + ) { + checkNow() + } else { + null + } + + suspend fun checkNow() = runCatching { + val version = repo.getLatestVersion() + val newVersionId = VersionId(version.name) + val currentVersionId = VersionId(BuildConfig.VERSION_NAME) + val result = newVersionId > currentVersionId + if (result) { + withContext(Dispatchers.Main) { + showUpdateDialog(version) + } + } + settings.lastUpdateCheckTimestamp = System.currentTimeMillis() + result + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + + @MainThread + private fun showUpdateDialog(version: AppVersion) { + val message = buildString { + append(activity.getString(R.string.new_version_s, version.name)) + appendLine() + append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize))) + appendLine() + appendLine() + append(version.description) + } + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.app_update_available) + .setMessage(message) + .setPositiveButton(R.string.download) { _, _ -> + val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri()) + activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser))) + } + .setNegativeButton(R.string.close, null) + .setCancelable(false) + .create() + .show() + } + + companion object { + + private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE" + private val PERIOD = TimeUnit.HOURS.toMillis(6) + + fun isUpdateSupported(context: Context): Boolean { + return BuildConfig.DEBUG || getCertificateSHA1Fingerprint(context) == CERT_SHA1 + } + + @Suppress("DEPRECATION") + @SuppressLint("PackageManagerGetSignatures") + private fun getCertificateSHA1Fingerprint(context: Context): String? = runCatching { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES) + val signatures = requireNotNull(packageInfo?.signatures) + val cert: ByteArray = signatures.first().toByteArray() + val input: InputStream = ByteArrayInputStream(cert) + val cf = CertificateFactory.getInstance("X509") + val c = cf.generateCertificate(input) as X509Certificate + val md: MessageDigest = MessageDigest.getInstance("SHA1") + val publicKey: ByteArray = md.digest(c.encoded) + return publicKey.byte2HexFormatted() + }.onFailure { error -> + error.printStackTraceDebug() + }.getOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt new file mode 100644 index 000000000..50bb254df --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.settings + +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.view.postDelayed +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.TwoStatePreference +import com.google.android.material.color.DynamicColors +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.parsers.util.names +import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity +import org.koitharu.kotatsu.settings.utils.SliderPreference +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat +import java.util.* + +class AppearanceSettingsFragment : + BasePreferenceFragment(R.string.appearance), + SharedPreferences.OnSharedPreferenceChangeListener { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_appearance) + findPreference(AppSettings.KEY_GRID_SIZE)?.run { + val pattern = context.getString(R.string.percent_string_pattern) + summary = pattern.format(value.toString()) + setOnPreferenceChangeListener { preference, newValue -> + preference.summary = pattern.format(newValue.toString()) + true + } + } + preferenceScreen?.findPreference(AppSettings.KEY_LIST_MODE)?.run { + entryValues = ListMode.values().names() + setDefaultValueCompat(ListMode.GRID.name) + } + findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = DynamicColors.isDynamicColorAvailable() + findPreference(AppSettings.KEY_DATE_FORMAT)?.run { + entryValues = resources.getStringArray(R.array.date_formats) + val now = Date().time + entries = entryValues.map { value -> + val formattedDate = settings.getDateFormat(value.toString()).format(now) + if (value == "") { + "${context.getString(R.string.system_default)} ($formattedDate)" + } else { + formattedDate + } + }.toTypedArray() + setDefaultValueCompat("") + summary = "%s" + } + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_THEME -> { + AppCompatDelegate.setDefaultNightMode(settings.theme) + } + AppSettings.KEY_DYNAMIC_THEME -> { + postRestart() + } + AppSettings.KEY_THEME_AMOLED -> { + postRestart() + } + AppSettings.KEY_APP_PASSWORD -> { + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() + } + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_PROTECT_APP -> { + val pref = (preference as? TwoStatePreference ?: return false) + if (pref.isChecked) { + pref.isChecked = false + startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) + } else { + settings.appPassword = null + } + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun postRestart() { + view?.postDelayed(400) { + get().recreateAll() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt new file mode 100644 index 000000000..ed9cae71e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -0,0 +1,114 @@ +package org.koitharu.kotatsu.settings + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.preference.ListPreference +import androidx.preference.Preference +import java.io.File +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog +import org.koitharu.kotatsu.core.network.DoHProvider +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.parsers.util.names +import org.koitharu.kotatsu.settings.utils.SliderPreference +import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class ContentSettingsFragment : + BasePreferenceFragment(R.string.content), + SharedPreferences.OnSharedPreferenceChangeListener, + StorageSelectDialog.OnStorageSelectListener { + + private val storageManager by inject() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_content) + + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled + ) + findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run { + summary = value.toString() + setOnPreferenceChangeListener { preference, newValue -> + preference.summary = newValue.toString() + true + } + } + findPreference(AppSettings.KEY_DOH)?.run { + entryValues = arrayOf( + DoHProvider.NONE, + DoHProvider.GOOGLE, + DoHProvider.CLOUDFLARE, + DoHProvider.ADGUARD, + ).names() + setDefaultValueCompat(DoHProvider.NONE.name) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() + bindRemoteSourcesSummary() + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_LOCAL_STORAGE -> { + findPreference(key)?.bindStorageName() + } + AppSettings.KEY_SUGGESTIONS -> { + findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( + if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled + ) + } + AppSettings.KEY_SOURCES_HIDDEN -> { + bindRemoteSourcesSummary() + } + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_LOCAL_STORAGE -> { + val ctx = context ?: return false + StorageSelectDialog.Builder(ctx, storageManager, this) + .setTitle(preference.title ?: "") + .setNegativeButton(android.R.string.cancel) + .create() + .show() + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + override fun onStorageSelected(file: File) { + settings.mangaStorageDir = file + } + + private fun Preference.bindStorageName() { + viewLifecycleScope.launch { + val storage = storageManager.getDefaultWriteableDir() + summary = storage?.getStorageName(context) ?: getString(R.string.not_available) + } + } + + private fun bindRemoteSourcesSummary() { + findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run { + val total = settings.remoteMangaSources.size + summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/DomainValidator.kt b/app/src/main/java/org/koitharu/kotatsu/settings/DomainValidator.kt new file mode 100644 index 000000000..6fc5adab3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/DomainValidator.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.settings + +import okhttp3.internal.toCanonicalHost +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.utils.EditTextValidator + +class DomainValidator : EditTextValidator() { + + override fun validate(text: String): ValidationResult { + if (text.isBlank()) { + return ValidationResult.Success + } + val host = text.trim().toCanonicalHost() + return if (host == null) { + ValidationResult.Failed(context.getString(R.string.invalid_domain_message)) + } else { + ValidationResult.Success + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt new file mode 100644 index 000000000..c4c5f46ba --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -0,0 +1,180 @@ +package org.koitharu.kotatsu.settings + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.network.AndroidCookieJar +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.utils.FileSize +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cache) { + + private val trackerRepo by inject(mode = LazyThreadSafetyMode.NONE) + private val searchRepository by inject(mode = LazyThreadSafetyMode.NONE) + private val storageManager by inject(mode = LazyThreadSafetyMode.NONE) + private val shikimoriRepository by inject(mode = LazyThreadSafetyMode.NONE) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_history) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.PAGES) + findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindSummaryToCacheSize(CacheDir.THUMBS) + findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> + viewLifecycleScope.launchWhenResumed { + val items = searchRepository.getSearchHistoryCount() + pref.summary = + pref.context.resources.getQuantityString(R.plurals.items, items, items) + } + } + findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> + viewLifecycleScope.launchWhenResumed { + val items = trackerRepo.getLogsCount() + pref.summary = + pref.context.resources.getQuantityString(R.plurals.items, items, items) + } + } + } + + override fun onResume() { + super.onResume() + bindShikimoriSummary() + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_PAGES_CACHE_CLEAR -> { + clearCache(preference, CacheDir.PAGES) + true + } + AppSettings.KEY_THUMBS_CACHE_CLEAR -> { + clearCache(preference, CacheDir.THUMBS) + true + } + AppSettings.KEY_COOKIES_CLEAR -> { + clearCookies() + true + } + AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { + clearSearchHistory(preference) + true + } + AppSettings.KEY_UPDATES_FEED_CLEAR -> { + viewLifecycleScope.launch { + trackerRepo.clearLogs() + preference.summary = preference.context.resources + .getQuantityString(R.plurals.items, 0, 0) + Snackbar.make( + view ?: return@launch, + R.string.updates_feed_cleared, + Snackbar.LENGTH_SHORT + ).show() + } + true + } + AppSettings.KEY_SHIKIMORI -> { + if (!shikimoriRepository.isAuthorized) { + launchShikimoriAuth() + true + } else { + super.onPreferenceTreeClick(preference) + } + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun clearCache(preference: Preference, cache: CacheDir) { + val ctx = preference.context.applicationContext + viewLifecycleScope.launch { + try { + preference.isEnabled = false + storageManager.clearCache(cache) + val size = storageManager.computeCacheSize(cache) + preference.summary = FileSize.BYTES.format(ctx, size) + } catch (e: Exception) { + preference.summary = e.getDisplayMessage(ctx.resources) + } finally { + preference.isEnabled = true + } + } + } + + private fun Preference.bindSummaryToCacheSize(dir: CacheDir) = viewLifecycleScope.launch { + val size = storageManager.computeCacheSize(dir) + summary = FileSize.BYTES.format(context, size) + } + + private fun clearSearchHistory(preference: Preference) { + 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) { _, _ -> + viewLifecycleScope.launch { + searchRepository.clearSearchHistory() + preference.summary = preference.context.resources + .getQuantityString(R.plurals.items, 0, 0) + Snackbar.make( + view ?: return@launch, + R.string.search_history_cleared, + Snackbar.LENGTH_SHORT + ).show() + } + }.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) { _, _ -> + viewLifecycleScope.launch { + val cookieJar = get() + cookieJar.clear() + Snackbar.make( + listView ?: return@launch, + R.string.cookies_cleared, + Snackbar.LENGTH_SHORT + ).show() + } + }.show() + } + + private fun bindShikimoriSummary() { + findPreference(AppSettings.KEY_SHIKIMORI)?.summary = if (shikimoriRepository.isAuthorized) { + getString(R.string.logged_in_as, shikimoriRepository.getCachedUser()?.nickname) + } else { + getString(R.string.disabled) + } + } + + private fun launchShikimoriAuth() { + runCatching { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(shikimoriRepository.oauthUrl) + startActivity(intent) + }.onFailure { + Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt index a75535bcc..ee15c796a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt @@ -1,13 +1,15 @@ package org.koitharu.kotatsu.settings +import android.content.Context import android.content.SharedPreferences import android.media.RingtoneManager import android.os.Bundle import android.view.View import androidx.preference.Preference +import org.koin.android.ext.android.get import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.settings.utils.RingtonePickContract class NotificationSettingsLegacyFragment : @@ -15,7 +17,7 @@ class NotificationSettingsLegacyFragment : SharedPreferences.OnSharedPreferenceChangeListener { private val ringtonePickContract = registerForActivityResult( - RingtonePickContract(R.string.notification_sound), + RingtonePickContract(get().getString(R.string.notification_sound)) ) { uri -> settings.notificationSound = uri ?: return@registerForActivityResult findPreference(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run { @@ -56,7 +58,6 @@ class NotificationSettingsLegacyFragment : ringtonePickContract.launch(settings.notificationSound) true } - else -> super.onPreferenceTreeClick(preference) } } @@ -65,4 +66,4 @@ class NotificationSettingsLegacyFragment : findPreference(AppSettings.KEY_NOTIFICATIONS_INFO) ?.isVisible = !settings.isTrackerNotificationsEnabled } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index a605cb80f..39f4de6c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -6,19 +6,15 @@ import android.view.View import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.model.ZoomMode 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.ReaderMode -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat -@AndroidEntryPoint class ReaderSettingsFragment : BasePreferenceFragment(R.string.reader_settings), SharedPreferences.OnSharedPreferenceChangeListener { @@ -26,22 +22,23 @@ class ReaderSettingsFragment : override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_reader) findPreference(AppSettings.KEY_READER_MODE)?.run { - entryValues = ReaderMode.entries.names() + entryValues = arrayOf( + ReaderMode.STANDARD, + ReaderMode.REVERSED, + ReaderMode.WEBTOON, + ).names() setDefaultValueCompat(ReaderMode.STANDARD.name) } - findPreference(AppSettings.KEY_READER_BACKGROUND)?.run { - entryValues = ReaderBackground.entries.names() - setDefaultValueCompat(ReaderBackground.DEFAULT.name) - } - findPreference(AppSettings.KEY_READER_ANIMATION)?.run { - entryValues = ReaderAnimation.entries.names() - setDefaultValueCompat(ReaderAnimation.DEFAULT.name) - } findPreference(AppSettings.KEY_READER_SWITCHERS)?.run { summaryProvider = MultiSummaryProvider(R.string.gestures_only) } findPreference(AppSettings.KEY_ZOOM_MODE)?.run { - entryValues = ZoomMode.entries.names() + entryValues = arrayOf( + ZoomMode.FIT_CENTER, + ZoomMode.FIT_HEIGHT, + ZoomMode.FIT_WIDTH, + ZoomMode.KEEP_START, + ).names() setDefaultValueCompat(ZoomMode.FIT_CENTER.name) } updateReaderModeDependency() @@ -68,4 +65,4 @@ class ReaderSettingsFragment : isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/RootSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/RootSettingsFragment.kt new file mode 100644 index 000000000..ad02acf64 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/RootSettingsFragment.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.settings + +import android.os.Bundle +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment + +class RootSettingsFragment : BasePreferenceFragment(R.string.settings) { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_root) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt similarity index 51% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index 82ba17efa..91f9c5987 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -3,12 +3,11 @@ package org.koitharu.kotatsu.settings import android.content.ComponentName 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.view.ViewGroup.MarginLayoutParams import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -17,58 +16,38 @@ import androidx.fragment.app.commit 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.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -import org.koitharu.kotatsu.core.util.ext.isScrolledToTop -import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.databinding.ActivitySettingsBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.settings.about.AboutSettingsFragment -import org.koitharu.kotatsu.settings.about.AppUpdateDialog -import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment -import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment -import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment -import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment -import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment - -@AndroidEntryPoint +import org.koitharu.kotatsu.scrobbling.shikimori.ui.ShikimoriSettingsFragment +import org.koitharu.kotatsu.utils.ext.isScrolledToTop + class SettingsActivity : BaseActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, AppBarOwner, FragmentManager.OnBackStackChangedListener { - val appUpdateDialog = AppUpdateDialog(this) - override val appBar: AppBarLayout - get() = viewBinding.appbar + get() = binding.appbar override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySettingsBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - val isMasterDetails = viewBinding.containerMaster != null - val fm = supportFragmentManager - val currentFragment = fm.findFragmentById(R.id.container) - if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) { + + if (supportFragmentManager.findFragmentById(R.id.container) == null) { openDefaultFragment() } - if (isMasterDetails && fm.findFragmentById(R.id.container_master) == null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - replace(R.id.container_master, RootSettingsFragment()) - } - } } override fun onTitleChanged(title: CharSequence?, color: Int) { super.onTitleChanged(title, color) - viewBinding.collapsingToolbarLayout?.title = title + binding.collapsingToolbarLayout?.title = title } override fun onStart() { @@ -82,9 +61,8 @@ class SettingsActivity : } override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.opt_settings, menu) - return true + return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { @@ -95,7 +73,6 @@ class SettingsActivity : startActivity(intent) true } - else -> super.onOptionsItemSelected(item) } @@ -103,93 +80,79 @@ class SettingsActivity : val fragment = supportFragmentManager.findFragmentById(R.id.container) as? RecyclerViewOwner ?: return val recyclerView = fragment.recyclerView recyclerView.post { - viewBinding.appbar.setExpanded(recyclerView.isScrolledToTop, false) + binding.appbar.setExpanded(recyclerView.isScrolledToTop, false) } } override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, - pref: Preference, + pref: Preference ): Boolean { val fm = supportFragmentManager val fragment = fm.fragmentFactory.instantiate(classLoader, pref.fragment ?: return false) fragment.arguments = pref.extras - openFragment(fragment, isFromRoot = caller is RootSettingsFragment) + fragment.setTargetFragment(caller, 0) + openFragment(fragment) return true } override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( + binding.appbar.updatePadding( + left = insets.left, + right = insets.right, + ) + binding.container.updatePadding( left = insets.left, right = insets.right, ) - viewBinding.cardDetails?.updateLayoutParams { - bottomMargin = marginStart + insets.bottom - } - } - - fun setSectionTitle(title: CharSequence?) { - viewBinding.textViewHeader?.apply { - textAndVisible = title - } ?: setTitle(title ?: getString(R.string.settings)) } - fun openFragment(fragment: Fragment, isFromRoot: Boolean) { - val hasFragment = supportFragmentManager.findFragmentById(R.id.container) != null - val isMasterDetail = viewBinding.containerMaster != null + fun openFragment(fragment: Fragment) { supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragment) - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_MATCH_ACTIVITY_OPEN) - if (!isMasterDetail || (hasFragment && !isFromRoot)) { - addToBackStack(null) - } + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) } } private fun openDefaultFragment() { - val hasMaster = viewBinding.containerMaster != null val fragment = when (intent?.action) { + Intent.ACTION_VIEW -> handleUri(intent.data) ?: return ACTION_READER -> ReaderSettingsFragment() ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() - ACTION_HISTORY -> UserDataSettingsFragment() + ACTION_SHIKIMORI -> ShikimoriSettingsFragment() ACTION_TRACKER -> TrackerSettingsFragment() - ACTION_SOURCES -> SourcesSettingsFragment() - ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() ACTION_SOURCE -> SourceSettingsFragment.newInstance( - intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL, + intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL ) - - ACTION_MANAGE_SOURCES -> SourcesManageFragment() - Intent.ACTION_VIEW -> { - when (intent.data?.host) { - HOST_ABOUT -> AboutSettingsFragment() - HOST_SYNC_SETTINGS -> SyncSettingsFragment() - else -> null - } - } - - else -> null - } ?: if (hasMaster) AppearanceSettingsFragment() else RootSettingsFragment() + else -> SettingsHeadersFragment() + } supportFragmentManager.commit { setReorderingAllowed(true) replace(R.id.container, fragment) } } + private fun handleUri(uri: Uri?): Fragment? { + when (uri?.host) { + HOST_SHIKIMORI_AUTH -> + return ShikimoriSettingsFragment.newInstance(authCode = uri.getQueryParameter("code")) + } + finishAfterTransition() + return null + } + 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_SHIKIMORI = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SHIKIMORI_SETTINGS" private const val EXTRA_SOURCE = "source" - private const val HOST_ABOUT = "about" - private const val HOST_SYNC_SETTINGS = "sync-settings" + + private const val HOST_SHIKIMORI_AUTH = "shikimori-auth" fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) @@ -197,6 +160,10 @@ class SettingsActivity : Intent(context, SettingsActivity::class.java) .setAction(ACTION_READER) + fun newShikimoriSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SHIKIMORI) + fun newSuggestionsSettingsIntent(context: Context) = Intent(context, SettingsActivity::class.java) .setAction(ACTION_SUGGESTIONS) @@ -205,25 +172,9 @@ class SettingsActivity : Intent(context, SettingsActivity::class.java) .setAction(ACTION_TRACKER) - 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(context, SettingsActivity::class.java) .setAction(ACTION_SOURCE) .putExtra(EXTRA_SOURCE, source) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt new file mode 100644 index 000000000..052aba070 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.settings + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceHeaderFragmentCompat +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import org.koitharu.kotatsu.R + +class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLayout.PanelSlideListener { + + private var currentTitle: CharSequence? = null + + override fun onCreatePreferenceHeader(): PreferenceFragmentCompat = RootSettingsFragment() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + slidingPaneLayout.addPanelSlideListener(this) + } + + override fun onPanelSlide(panel: View, slideOffset: Float) = Unit + + override fun onPanelOpened(panel: View) { + activity?.title = currentTitle ?: getString(R.string.settings) + } + + override fun onPanelClosed(panel: View) { + activity?.setTitle(R.string.settings) + } + + fun setTitle(title: CharSequence?) { + currentTitle = title + if (slidingPaneLayout.isOpen) { + activity?.title = title + } + } + + fun openFragment(fragment: Fragment) { + childFragmentManager.commit { + setReorderingAllowed(true) + replace(androidx.preference.R.id.preferences_detail, fragment) + setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + addToBackStack(null) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt new file mode 100644 index 000000000..ca3fd8a2d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.settings + +import android.net.Uri +import android.os.Build +import androidx.room.InvalidationTracker +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.backup.BackupObserver +import org.koitharu.kotatsu.settings.backup.BackupViewModel +import org.koitharu.kotatsu.settings.backup.RestoreViewModel +import org.koitharu.kotatsu.settings.newsources.NewSourcesViewModel +import org.koitharu.kotatsu.settings.onboard.OnboardViewModel +import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel +import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel + +val settingsModule + get() = module { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + single { BackupObserver(androidContext()) } + } + + factory { BackupRepository(get()) } + single(createdAtStart = true) { AppSettings(androidContext()) } + + viewModel { BackupViewModel(get(), androidContext()) } + viewModel { params -> + RestoreViewModel(params.getOrNull(Uri::class), get(), androidContext()) + } + viewModel { ProtectSetupViewModel(get()) } + viewModel { OnboardViewModel(get()) } + viewModel { SourcesSettingsViewModel(get()) } + viewModel { NewSourcesViewModel(get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt index f0c239c23..4776223d7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt @@ -1,18 +1,15 @@ -package org.koitharu.kotatsu.settings.sources +package org.koitharu.kotatsu.settings import android.view.inputmethod.EditorInfo import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider -import org.koitharu.kotatsu.settings.utils.validation.DomainValidator -import org.koitharu.kotatsu.settings.utils.validation.HeaderValidator fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) { val configKeys = repository.getConfigKeys() @@ -21,12 +18,10 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang val preference: Preference = when (key) { is ConfigKey.Domain -> { val presetValues = key.presetValues - if (presetValues.size <= 1) { + if (presetValues.isNullOrEmpty()) { EditTextPreference(requireContext()) } else { - AutoCompleteTextViewPreference(requireContext()).apply { - entries = presetValues.toStringArray() - } + AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues } }.apply { summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) setOnBindEditTextListener( @@ -34,41 +29,15 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, hint = key.defaultValue, validator = DomainValidator(), - ), + ) ) setTitle(R.string.domain) setDialogTitle(R.string.domain) } } - - is ConfigKey.UserAgent -> { - EditTextPreference(requireContext()).apply { - summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue) - setOnBindEditTextListener( - EditTextBindListener( - inputType = EditorInfo.TYPE_CLASS_TEXT, - hint = key.defaultValue, - validator = HeaderValidator(), - ), - ) - setTitle(R.string.user_agent) - setDialogTitle(R.string.user_agent) - } - } - - is ConfigKey.ShowSuspiciousContent -> { - SwitchPreferenceCompat(requireContext()).apply { - setDefaultValue(key.defaultValue) - setTitle(R.string.show_suspicious_content) - } - } } preference.isIconSpaceReserved = false preference.key = key.key screen.addPreference(preference) } -} - -private fun Array.toStringArray(): Array { - return Array(size) { i -> this[i] as? String ?: "" } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt new file mode 100644 index 000000000..3db0bb810 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.settings + +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.parsers.exception.AuthRequiredException +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity +import org.koitharu.kotatsu.utils.ext.* + +class SourceSettingsFragment : BasePreferenceFragment(0) { + + private val source by serializableArgument(EXTRA_SOURCE) + private var repository: RemoteMangaRepository? = null + private val exceptionResolver = ExceptionResolver(this) + + override fun onResume() { + super.onResume() + setTitle(source.title) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = source.name + val repo = MangaRepository(source) as? RemoteMangaRepository ?: return + repository = repo + addPreferencesFromResource(R.xml.pref_source) + addPreferencesFromRepository(repo) + + findPreference(KEY_AUTH)?.run { + val authProvider = repo.getAuthProvider() + isVisible = authProvider != null + isEnabled = authProvider?.isAuthorized == false + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(KEY_AUTH)?.run { + if (isVisible) { + loadUsername(this) + } + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_AUTH -> { + startActivity(SourceAuthActivity.newIntent(preference.context, source)) + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun loadUsername(preference: Preference) = viewLifecycleScope.launch { + runCatching { + preference.summary = null + withContext(Dispatchers.Default) { + requireNotNull(repository?.getAuthProvider()?.getUsername()) + } + }.onSuccess { username -> + preference.title = getString(R.string.logged_in_as, username) + }.onFailure { error -> + preference.isEnabled = error is AuthRequiredException + when { + error is AuthRequiredException -> Unit + ExceptionResolver.canResolve(error) -> { + ensureActive() + Snackbar.make( + listView ?: return@onFailure, + error.getDisplayMessage(preference.context.resources), + Snackbar.LENGTH_INDEFINITE, + ).setAction(ExceptionResolver.getResolveStringId(error)) { resolveError(error) } + .show() + } + else -> preference.summary = error.getDisplayMessage(preference.context.resources) + } + error.printStackTraceDebug() + } + } + + private fun resolveError(error: Throwable): Unit { + viewLifecycleScope.launch { + if (exceptionResolver.resolve(error)) { + val pref = findPreference(KEY_AUTH) ?: return@launch + loadUsername(pref) + } + } + } + + companion object { + + private const val KEY_AUTH = "auth" + + private const val EXTRA_SOURCE = "source" + + fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) { + putSerializable(EXTRA_SOURCE, source) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt similarity index 73% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt index ed6f8ed14..46aced8a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SuggestionsSettingsFragment.kt @@ -3,30 +3,21 @@ package org.koitharu.kotatsu.settings import android.content.SharedPreferences import android.os.Bundle import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference import org.koitharu.kotatsu.settings.utils.TagsAutoCompleteProvider import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker -import javax.inject.Inject -@AndroidEntryPoint -class SuggestionsSettingsFragment : - BasePreferenceFragment(R.string.suggestions), +class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions), SharedPreferences.OnSharedPreferenceChangeListener { - @Inject - lateinit var repository: SuggestionRepository - - @Inject - lateinit var tagsCompletionProvider: TagsAutoCompleteProvider - - @Inject - lateinit var suggestionsScheduler: SuggestionsWorker.Scheduler + private val repository by inject(mode = LazyThreadSafetyMode.NONE) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -37,7 +28,7 @@ class SuggestionsSettingsFragment : addPreferencesFromResource(R.xml.pref_suggestions) findPreference(AppSettings.KEY_SUGGESTIONS_EXCLUDE_TAGS)?.run { - autoCompleteProvider = tagsCompletionProvider + autoCompleteProvider = TagsAutoCompleteProvider(get()) summaryProvider = MultiAutoCompleteTextViewPreference.SimpleSummaryProvider(summary) } } @@ -56,8 +47,8 @@ class SuggestionsSettingsFragment : private fun onSuggestionsEnabled() { lifecycleScope.launch { if (repository.isEmpty()) { - suggestionsScheduler.startNow() + SuggestionsWorker.startNow(context ?: return@launch) } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt index c0ccbc413..d64b48b43 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt @@ -1,39 +1,42 @@ -package org.koitharu.kotatsu.settings.tracker +package org.koitharu.kotatsu.settings +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.PowerManager import android.provider.Settings import android.text.style.URLSpan import android.view.View +import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.text.inSpans -import androidx.fragment.app.viewModels import androidx.preference.MultiSelectListPreference import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment 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.settings.tracker.categories.TrackerCategoriesConfigSheet -import org.koitharu.kotatsu.settings.utils.DozeHelper +import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider +import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +private const val KEY_IGNORE_DOZE = "ignore_dose" -@AndroidEntryPoint class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_chapters), SharedPreferences.OnSharedPreferenceChangeListener { - private val viewModel by viewModels() - private val dozeHelper = DozeHelper(this) - - @Inject - lateinit var channels: TrackerNotificationChannels + private val repository by inject() + private val channels by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_tracker) @@ -50,19 +53,21 @@ class TrackerSettingsFragment : } } } - dozeHelper.updatePreference() updateCategoriesEnabled() } override fun onResume() { super.onResume() + findPreference(KEY_IGNORE_DOZE)?.run { + isVisible = isDozeIgnoreAvailable(context) + } + updateCategoriesSummary() updateNotificationsSummary() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) settings.subscribe(this) - viewModel.categoriesCount.observe(viewLifecycleOwner, ::onCategoriesCountChanged) } override fun onDestroyView() { @@ -84,30 +89,27 @@ class TrackerSettingsFragment : Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - startActivitySafe(intent) + startActivity(intent) true } - channels.areNotificationsDisabled -> { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", requireContext().packageName, null)) - startActivitySafe(intent) + startActivity(intent) true } - - else -> super.onPreferenceTreeClick(preference) + else -> { + super.onPreferenceTreeClick(preference) + } } - AppSettings.KEY_TRACK_CATEGORIES -> { - TrackerCategoriesConfigSheet.show(childFragmentManager) + startActivity(CategoriesActivity.newIntent(preference.context)) true } - - AppSettings.KEY_IGNORE_DOZE -> { - dozeHelper.startIgnoreDoseActivity() + KEY_IGNORE_DOZE -> { + startIgnoreDoseActivity(preference.context) true } - else -> super.onPreferenceTreeClick(preference) } } @@ -119,7 +121,7 @@ class TrackerSettingsFragment : channels.areNotificationsDisabled -> R.string.disabled channels.isNotificationGroupEnabled() -> R.string.show_notification_new_chapters_on else -> R.string.show_notification_new_chapters_off - }, + } ) } @@ -128,10 +130,41 @@ class TrackerSettingsFragment : pref.isEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources } - private fun onCategoriesCountChanged(count: IntArray?) { + private fun updateCategoriesSummary() { val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return - pref.summary = count?.let { - getString(R.string.enabled_d_of_d, count[0], count[1]) + viewLifecycleScope.launch { + val count = repository.getCategoriesCount() + pref.summary = getString(R.string.enabled_d_of_d, count[0], count[1]) + } + } + + @SuppressLint("BatteryLife") + private fun startIgnoreDoseActivity(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + return + } + val packageName = context.packageName + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + try { + val intent = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:$packageName".toUri(), + ) + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } + } + } + + private fun isDozeIgnoreAvailable(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false } + val packageName = context.packageName + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !powerManager.isIgnoringBatteryOptimizations(packageName) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt new file mode 100644 index 000000000..985ca0d1a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -0,0 +1,74 @@ +package org.koitharu.kotatsu.settings.about + +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import androidx.preference.Preference +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.AppUpdateChecker +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_about) + val isUpdateSupported = AppUpdateChecker.isUpdateSupported(requireContext()) + findPreference(AppSettings.KEY_APP_UPDATE_AUTO)?.run { + isVisible = isUpdateSupported + } + findPreference(AppSettings.KEY_APP_VERSION)?.run { + title = getString(R.string.app_version, BuildConfig.VERSION_NAME) + isEnabled = isUpdateSupported + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_APP_VERSION -> { + checkForUpdates() + true + } + AppSettings.KEY_APP_TRANSLATION -> { + openLink(getString(R.string.url_weblate), preference.title) + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun checkForUpdates() { + viewLifecycleScope.launch { + findPreference(AppSettings.KEY_APP_VERSION)?.run { + setSummary(R.string.checking_for_updates) + isSelectable = false + } + val result = AppUpdateChecker(activity ?: return@launch).checkNow() + findPreference(AppSettings.KEY_APP_VERSION)?.run { + setSummary( + when (result) { + true -> R.string.check_for_updates + false -> R.string.no_update_available + null -> R.string.update_check_failed + } + ) + isSelectable = true + } + } + } + + private fun openLink(url: String, title: CharSequence?) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUri() + startActivity( + if (title != null) { + Intent.createChooser(intent, title) + } else { + intent + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 2de76928c..27c9bbcb0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -13,12 +13,7 @@ import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.prefs.AppSettings -import java.io.File -import java.io.FileDescriptor -import java.io.FileInputStream -import java.io.InputStream -import java.io.OutputStream +import java.io.* class AppBackupAgent : BackupAgent() { @@ -36,8 +31,7 @@ class AppBackupAgent : BackupAgent() { override fun onFullBackup(data: FullBackupDataOutput) { super.onFullBackup(data) - val file = - createBackupFile(this, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext))) + val file = createBackupFile(this, BackupRepository(MangaDatabase(applicationContext))) try { fullBackupFile(file, data) } finally { @@ -54,11 +48,7 @@ class AppBackupAgent : BackupAgent() { mtime: Long ) { if (destination?.name?.endsWith(".bk.zip") == true) { - restoreBackupFile( - data.fileDescriptor, - size, - BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)), - ) + restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext))) destination.delete() } else { super.onRestoreFile(data, size, destination, type, mode, mtime) @@ -72,9 +62,6 @@ class AppBackupAgent : BackupAgent() { backup.put(repository.dumpHistory()) backup.put(repository.dumpCategories()) backup.put(repository.dumpFavourites()) - backup.put(repository.dumpBookmarks()) - backup.put(repository.dumpSources()) - backup.put(repository.dumpSettings()) backup.finish() backup.file } @@ -91,12 +78,9 @@ class AppBackupAgent : BackupAgent() { val backup = BackupZipInput(tempFile) try { runBlocking { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } - backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } - 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) } + repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY)) + repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES)) + repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES)) } } finally { backup.close() @@ -118,4 +102,4 @@ class AppBackupAgent : BackupAgent() { bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft)) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index b40157ab0..b309a588b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -3,33 +3,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.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 +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.tryLaunch +import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.DialogProgressBinding +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.progress.Progress import java.io.File import java.io.FileOutputStream -import kotlin.math.roundToInt -@AndroidEntryPoint class BackupDialogFragment : AlertDialogFragment() { - private val viewModel by viewModels() + private val viewModel by viewModel() private var backup: File? = null private val saveFileContract = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip"), + ActivityResultContracts.CreateDocument("*/*") ) { uri -> val file = backup if (uri != null && file != null) { @@ -39,24 +34,23 @@ class BackupDialogFragment : AlertDialogFragment() { } } - override fun onCreateViewBinding( + override fun onInflateView( inflater: LayoutInflater, container: ViewGroup?, ) = DialogProgressBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) binding.textViewTitle.setText(R.string.create_backup) binding.textViewSubtitle.setText(R.string.processing_) viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) - viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone) - viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) + viewModel.onBackupDone.observe(viewLifecycleOwner, this::onBackupDone) + viewModel.onError.observe(viewLifecycleOwner, this::onError) } - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setCancelable(false) + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setCancelable(false) .setNegativeButton(android.R.string.cancel, null) } @@ -69,23 +63,20 @@ class BackupDialogFragment : AlertDialogFragment() { dismiss() } - private fun onProgressChanged(value: Float) { - with(requireViewBinding().progressBar) { + private fun onProgressChanged(progress: Progress?) { + with(binding.progressBar) { + isIndeterminate = progress == null isVisible = true - val wasIndeterminate = isIndeterminate - isIndeterminate = value < 0 - if (value >= 0) { - setProgressCompat((value * max).roundToInt(), !wasIndeterminate) + if (progress != null) { + this.max = progress.total + this.progress = progress.value } } } private fun onBackupDone(file: File) { this.backup = file - if (!saveFileContract.tryLaunch(file.name)) { - Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show() - dismiss() - } + saveFileContract.launch(file.name) } private fun saveBackup(file: File, output: Uri) { @@ -95,10 +86,8 @@ class BackupDialogFragment : AlertDialogFragment() { it.write(file.readBytes()) } } - Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show() dismiss() - } catch (e: InterruptedException) { - throw e } catch (e: Exception) { onError(e) } @@ -106,10 +95,6 @@ class BackupDialogFragment : AlertDialogFragment() { companion object { - private const val TAG = "BackupDialogFragment" - - fun show(fm: FragmentManager) { - BackupDialogFragment().show(fm, TAG) - } + const val TAG = "BackupDialogFragment" } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupObserver.kt similarity index 66% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupObserver.kt index 595c27e88..807e63e56 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupObserver.kt @@ -2,22 +2,21 @@ package org.koitharu.kotatsu.settings.backup import android.app.backup.BackupManager import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi import androidx.room.InvalidationTracker -import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES import org.koitharu.kotatsu.core.db.TABLE_HISTORY -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class BackupObserver @Inject constructor( - @ApplicationContext context: Context, +@RequiresApi(Build.VERSION_CODES.M) +class BackupObserver( + context: Context, ) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) { private val backupManager = BackupManager(context) - override fun onInvalidated(tables: Set) { + override fun onInvalidated(tables: MutableSet) { backupManager.dataChanged() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt new file mode 100644 index 000000000..53ad51cbc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.ActivityNotFoundException +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug + +class BackupSettingsFragment : + BasePreferenceFragment(R.string.backup_restore), + ActivityResultCallback { + + private val backupSelectCall = registerForActivityResult( + ActivityResultContracts.OpenDocument(), + this + ) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_backup) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_BACKUP -> { + BackupDialogFragment().show(childFragmentManager, BackupDialogFragment.TAG) + true + } + AppSettings.KEY_RESTORE -> { + try { + backupSelectCall.launch(arrayOf("*/*")) + } catch (e: ActivityNotFoundException) { + e.printStackTraceDebug() + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT + ).show() + } + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + override fun onActivityResult(result: Uri?) { + RestoreDialogFragment.newInstance(result ?: return) + .show(childFragmentManager, BackupDialogFragment.TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt new file mode 100644 index 000000000..2532dc8d2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipOutput +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.progress.Progress +import java.io.File + +class BackupViewModel( + private val repository: BackupRepository, + context: Context +) : BaseViewModel() { + + val progress = MutableLiveData(null) + val onBackupDone = SingleLiveEvent() + + init { + launchLoadingJob { + val file = BackupZipOutput(context).use { backup -> + backup.put(repository.createIndex()) + + progress.value = Progress(0, 3) + backup.put(repository.dumpHistory()) + + progress.value = Progress(1, 3) + backup.put(repository.dumpCategories()) + + progress.value = Progress(2, 3) + backup.put(repository.dumpFavourites()) + + progress.value = Progress(3, 3) + backup.finish() + progress.value = null + backup.close() + backup.file + } + onBackupDone.call(file) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt new file mode 100644 index 000000000..9ccb5e1b8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -0,0 +1,94 @@ +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 androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.databinding.DialogProgressBinding +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.toUriOrNull +import org.koitharu.kotatsu.utils.ext.withArgs +import org.koitharu.kotatsu.utils.progress.Progress + +class RestoreDialogFragment : AlertDialogFragment() { + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup?, + ) = DialogProgressBinding.inflate(inflater, container, false) + + private val viewModel by viewModel { + parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull()) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.textViewTitle.setText(R.string.restore_backup) + binding.textViewSubtitle.setText(R.string.preparing_) + + viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) + viewModel.onRestoreDone.observe(viewLifecycleOwner, this::onRestoreDone) + viewModel.onError.observe(viewLifecycleOwner, this::onError) + } + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder.setCancelable(false) + } + + private fun onError(e: Throwable) { + MaterialAlertDialogBuilder(context ?: return) + .setNegativeButton(R.string.close, null) + .setTitle(R.string.error) + .setMessage(e.getDisplayMessage(resources)) + .show() + dismiss() + } + + private fun onProgressChanged(progress: Progress?) { + with(binding.progressBar) { + isVisible = true + isIndeterminate = progress == null + if (progress != null) { + this.max = progress.total + this.progress = progress.value + } + } + } + + private fun onRestoreDone(result: CompositeResult) { + val builder = MaterialAlertDialogBuilder(context ?: return) + when { + 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() + dismiss() + } + + companion object { + + const val ARG_FILE = "file" + const val TAG = "RestoreDialogFragment" + + fun newInstance(uri: Uri) = RestoreDialogFragment().withArgs(1) { + putString(ARG_FILE, uri.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt new file mode 100644 index 000000000..f63522140 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -0,0 +1,63 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.base.ui.BaseViewModel +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.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.progress.Progress +import java.io.File +import java.io.FileNotFoundException + +class RestoreViewModel( + uri: Uri?, + private val repository: BackupRepository, + context: Context +) : BaseViewModel() { + + val progress = MutableLiveData(null) + val onRestoreDone = SingleLiveEvent() + + init { + launchLoadingJob { + if (uri == null) { + throw FileNotFoundException() + } + val contentResolver = context.contentResolver + + val backup = runInterruptible(Dispatchers.IO) { + val tempFile = File.createTempFile("backup_", ".tmp") + (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BackupZipInput(tempFile) + } + try { + val result = CompositeResult() + + progress.value = Progress(0, 3) + result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY)) + + progress.value = Progress(1, 3) + result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES)) + + progress.value = Progress(2, 3) + result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES)) + + progress.value = Progress(3, 3) + onRestoreDone.call(result) + } finally { + backup.close() + backup.file.delete() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt index 455663d66..9718e5306 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt @@ -3,69 +3,61 @@ package org.koitharu.kotatsu.settings.newsources import android.content.DialogInterface 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 coil.ImageLoader +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.DialogOnboardBinding +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -import javax.inject.Inject -@AndroidEntryPoint class NewSourcesDialogFragment : AlertDialogFragment(), SourceConfigListener, DialogInterface.OnClickListener { - @Inject - lateinit var coil: ImageLoader + private val viewModel by viewModel() - private val viewModel by viewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ): DialogOnboardBinding { + override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding { return DialogOnboardBinding.inflate(inflater, container, false) } - override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = SourcesSelectAdapter(this, coil, viewLifecycleOwner) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = SourceConfigAdapter(this, get(), viewLifecycleOwner) binding.recyclerView.adapter = adapter binding.textViewTitle.setText(R.string.new_sources_text) - viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } + viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it } } - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder .setPositiveButton(R.string.done, this) .setCancelable(true) .setTitle(R.string.remote_sources) } override fun onClick(dialog: DialogInterface, which: Int) { + viewModel.apply() dialog.dismiss() } override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit - override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit - - override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) = Unit - override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { viewModel.onItemEnabledChanged(item, isEnabled) } - override fun onCloseTip(tip: SourceConfigItem.Tip) = Unit + override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) = Unit + + override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit companion object { @@ -73,4 +65,4 @@ class NewSourcesDialogFragment : fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG) } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt new file mode 100644 index 000000000..06567642b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.settings.newsources + +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.getLocaleTitle +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.mapToSet + +class NewSourcesViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + val sources = MutableLiveData>() + private val initialList = settings.newSources + + init { + buildList() + } + + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { + if (isEnabled) { + settings.hiddenSources -= item.source.name + } else { + settings.hiddenSources += item.source.name + } + } + + fun apply() { + settings.markKnownSources(initialList) + } + + private fun buildList() { + val locales = LocaleListCompat.getDefault().mapToSet { it.language } + val hidden = settings.hiddenSources + sources.value = initialList.map { + val locale = it.locale + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = it.name !in hidden && (locale == null || locale in locales), + isDraggable = false, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt new file mode 100644 index 000000000..4f695b154 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -0,0 +1,87 @@ +package org.koitharu.kotatsu.settings.onboard + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.AlertDialogFragment +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.DialogOnboardBinding +import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter +import org.koitharu.kotatsu.settings.onboard.model.SourceLocale +import org.koitharu.kotatsu.utils.ext.observeNotNull +import org.koitharu.kotatsu.utils.ext.showAllowStateLoss +import org.koitharu.kotatsu.utils.ext.withArgs + +class OnboardDialogFragment : + AlertDialogFragment(), + OnListItemClickListener, + DialogInterface.OnClickListener { + + private val viewModel by viewModel() + private var isWelcome: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.run { + isWelcome = getBoolean(ARG_WELCOME, false) + } + } + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup?, + ) = DialogOnboardBinding.inflate(inflater, container, false) + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder) { + builder + .setPositiveButton(R.string.done, this) + .setCancelable(true) + if (isWelcome) { + builder.setTitle(R.string.welcome) + } else { + builder + .setTitle(R.string.remote_sources) + .setNegativeButton(android.R.string.cancel, this) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val adapter = SourceLocalesAdapter(this) + binding.recyclerView.adapter = adapter + binding.textViewTitle.setText(R.string.onboard_text) + viewModel.list.observeNotNull(viewLifecycleOwner) { + adapter.items = it + } + } + + override fun onItemClick(item: SourceLocale, view: View) { + viewModel.setItemChecked(item.key, !item.isChecked) + } + + override fun onClick(dialog: DialogInterface?, which: Int) { + when (which) { + DialogInterface.BUTTON_POSITIVE -> viewModel.apply() + } + } + + companion object { + + private const val TAG = "OnboardDialog" + private const val ARG_WELCOME = "welcome" + + fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG) + + fun showWelcome(fm: FragmentManager) { + OnboardDialogFragment().withArgs(1) { + putBoolean(ARG_WELCOME, true) + }.showAllowStateLoss(fm, TAG) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt new file mode 100644 index 000000000..2f1495243 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -0,0 +1,95 @@ +package org.koitharu.kotatsu.settings.onboard + +import androidx.collection.ArraySet +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.settings.onboard.model.SourceLocale +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.mapToSet +import java.util.* + +class OnboardViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + private val allSources = settings.remoteMangaSources + + private val locales = allSources.mapTo(ArraySet()) { it.locale } + + private val selectedLocales = locales.toMutableSet() + + val list = MutableLiveData?>() + + init { + if (settings.isSourcesSelected) { + selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale }) + } else { + val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> + x.language + } + selectedLocales.retainAll(deviceLocales) + if (selectedLocales.isEmpty()) { + selectedLocales += "en" + } + } + rebuildList() + } + + fun setItemChecked(key: String?, isChecked: Boolean) { + val isModified = if (isChecked) { + selectedLocales.add(key) + } else { + selectedLocales.remove(key) + } + if (isModified) { + rebuildList() + } + } + + fun apply() { + settings.hiddenSources = allSources.filterNot { x -> + x.locale in selectedLocales + }.mapToSet { x -> x.name } + settings.markKnownSources(settings.newSources) + } + + private fun rebuildList() { + list.value = locales.map { key -> + val locale = if (key != null) { + Locale(key) + } else null + SourceLocale( + key = key, + title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), + isChecked = key in selectedLocales + ) + }.sortedWith(SourceLocaleComparator()) + } + + private class SourceLocaleComparator : Comparator { + + private val deviceLocales = LocaleListCompat.getAdjustedDefault() + .map { it.language } + + override fun compare(a: SourceLocale?, b: SourceLocale?): Int { + return when { + a === b -> 0 + a?.key == null -> 1 + b?.key == null -> -1 + else -> { + val index = deviceLocales.indexOf(a.key) + if (index == -1) { + compareValues(a.title, b.title) + } else { + -2 - index + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt new file mode 100644 index 000000000..d46bafd3d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.settings.onboard.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding +import org.koitharu.kotatsu.settings.onboard.model.SourceLocale + +fun sourceLocaleAD( + clickListener: OnListItemClickListener +) = adapterDelegateViewBinding( + { inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) } +) { + + binding.root.setOnClickListener { + clickListener.onItemClick(item, it) + } + + bind { + binding.root.text = item.title ?: getString(R.string.other) + binding.root.isChecked = item.isChecked + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt new file mode 100644 index 000000000..0d4112f31 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt @@ -0,0 +1,28 @@ +package org.koitharu.kotatsu.settings.onboard.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.settings.onboard.model.SourceLocale + +class SourceLocalesAdapter( + clickListener: OnListItemClickListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(sourceLocaleAD(clickListener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame( + oldItem: SourceLocale, + newItem: SourceLocale, + ): Boolean = oldItem.key == newItem.key + + override fun areContentsTheSame( + oldItem: SourceLocale, + newItem: SourceLocale, + ): Boolean = oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt new file mode 100644 index 000000000..c2bce3718 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.settings.onboard.model + +import java.util.* + +data class SourceLocale( + val key: String?, + val title: String?, + val isChecked: Boolean, +) : Comparable { + + override fun compareTo(other: SourceLocale): Int { + return when { + this === other -> 0 + key == Locale.getDefault().language -> -2 + key == null -> 1 + other.key == null -> -1 + else -> compareValues(title, other.title) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt similarity index 53% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index cc086c8cd..f88a8dad9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -11,60 +11,52 @@ import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.widget.CompoundButton import android.widget.TextView -import androidx.activity.viewModels import androidx.core.graphics.Insets import androidx.core.view.isGone import androidx.core.view.isVisible -import dagger.hilt.android.AndroidEntryPoint +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding private const val MIN_PASSWORD_LENGTH = 4 -@AndroidEntryPoint -class ProtectSetupActivity : - BaseActivity(), - TextWatcher, - View.OnClickListener, - TextView.OnEditorActionListener, - CompoundButton.OnCheckedChangeListener { +class ProtectSetupActivity : BaseActivity(), TextWatcher, + View.OnClickListener, TextView.OnEditorActionListener, CompoundButton.OnCheckedChangeListener { - private val viewModel by viewModels() + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) setContentView(ActivitySetupProtectBinding.inflate(layoutInflater)) - viewBinding.editPassword.addTextChangedListener(this) - viewBinding.editPassword.setOnEditorActionListener(this) - viewBinding.buttonNext.setOnClickListener(this) - viewBinding.buttonCancel.setOnClickListener(this) + binding.editPassword.addTextChangedListener(this) + binding.editPassword.setOnEditorActionListener(this) + binding.buttonNext.setOnClickListener(this) + binding.buttonCancel.setOnClickListener(this) - viewBinding.switchBiometric.isChecked = viewModel.isBiometricEnabled - viewBinding.switchBiometric.setOnCheckedChangeListener(this) + binding.switchBiometric.isChecked = viewModel.isBiometricEnabled + binding.switchBiometric.setOnCheckedChangeListener(this) viewModel.isSecondStep.observe(this, this::onStepChanged) - viewModel.onPasswordSet.observeEvent(this) { + viewModel.onPasswordSet.observe(this) { finishAfterTransition() } - viewModel.onPasswordMismatch.observeEvent(this) { - viewBinding.editPassword.error = getString(R.string.passwords_mismatch) + viewModel.onPasswordMismatch.observe(this) { + binding.editPassword.error = getString(R.string.passwords_mismatch) } - viewModel.onClearText.observeEvent(this) { - viewBinding.editPassword.text?.clear() + viewModel.onClearText.observe(this) { + binding.editPassword.text?.clear() } } override fun onWindowInsetsChanged(insets: Insets) { val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - viewBinding.root.setPadding( + binding.root.setPadding( basePadding + insets.left, basePadding + insets.top, basePadding + insets.right, - basePadding + insets.bottom, + basePadding + insets.bottom ) } @@ -72,7 +64,7 @@ class ProtectSetupActivity : when (v.id) { R.id.button_cancel -> finish() R.id.button_next -> viewModel.onNextClick( - password = viewBinding.editPassword.text?.toString() ?: return, + password = binding.editPassword.text?.toString() ?: return ) } } @@ -82,8 +74,8 @@ class ProtectSetupActivity : } override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean { - return if (actionId == EditorInfo.IME_ACTION_DONE && viewBinding.buttonNext.isEnabled) { - viewBinding.buttonNext.performClick() + return if (actionId == EditorInfo.IME_ACTION_DONE && binding.buttonNext.isEnabled) { + binding.buttonNext.performClick() true } else { false @@ -95,22 +87,22 @@ class ProtectSetupActivity : override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit override fun afterTextChanged(s: Editable?) { - viewBinding.editPassword.error = null + binding.editPassword.error = null val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH - viewBinding.buttonNext.isEnabled = isEnoughLength - viewBinding.layoutPassword.isHelperTextEnabled = + binding.buttonNext.isEnabled = isEnoughLength + binding.layoutPassword.isHelperTextEnabled = !isEnoughLength || viewModel.isSecondStep.value == true } private fun onStepChanged(isSecondStep: Boolean) { - viewBinding.buttonCancel.isGone = isSecondStep - viewBinding.switchBiometric.isVisible = isSecondStep && isBiometricAvailable() + binding.buttonCancel.isGone = isSecondStep + binding.switchBiometric.isVisible = isSecondStep && isBiometricAvailable() if (isSecondStep) { - viewBinding.layoutPassword.helperText = getString(R.string.repeat_password) - viewBinding.buttonNext.setText(R.string.confirm) + binding.layoutPassword.helperText = getString(R.string.repeat_password) + binding.buttonNext.setText(R.string.confirm) } else { - viewBinding.layoutPassword.helperText = getString(R.string.password_length_hint) - viewBinding.buttonNext.setText(R.string.next) + binding.layoutPassword.helperText = getString(R.string.password_length_hint) + binding.buttonNext.setText(R.string.next) } } @@ -118,4 +110,4 @@ class ProtectSetupActivity : return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt similarity index 55% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt index 73b5597b3..c9013d23d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupViewModel.kt @@ -1,33 +1,26 @@ package org.koitharu.kotatsu.settings.protect import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus +import org.koitharu.kotatsu.base.ui.BaseViewModel 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.parsers.util.md5 -import javax.inject.Inject +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -@HiltViewModel -class ProtectSetupViewModel @Inject constructor( - private val settings: AppSettings, +class ProtectSetupViewModel( + private val settings: AppSettings ) : BaseViewModel() { private val firstPassword = MutableStateFlow(null) val isSecondStep = firstPassword.map { it != null - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - val onPasswordSet = MutableEventFlow() - val onPasswordMismatch = MutableEventFlow() - val onClearText = MutableEventFlow() + }.asLiveDataDistinct(viewModelScope.coroutineContext) + val onPasswordSet = SingleLiveEvent() + val onPasswordMismatch = SingleLiveEvent() + val onClearText = SingleLiveEvent() val isBiometricEnabled get() = settings.isBiometricProtectionEnabled @@ -49,4 +42,4 @@ class ProtectSetupViewModel @Inject constructor( fun setBiometricEnabled(isEnabled: Boolean) { settings.isBiometricProtectionEnabled = isEnabled } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt new file mode 100644 index 000000000..10453c27e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -0,0 +1,162 @@ +package org.koitharu.kotatsu.settings.sources + +import android.os.Bundle +import android.view.* +import androidx.appcompat.widget.SearchView +import androidx.core.graphics.Insets +import androidx.core.view.MenuProvider +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner +import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding +import org.koitharu.kotatsu.main.ui.AppBarOwner +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.settings.SettingsHeadersFragment +import org.koitharu.kotatsu.settings.SourceSettingsFragment +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.addMenuProvider + +class SourcesSettingsFragment : + BaseFragment(), + SourceConfigListener, + RecyclerViewOwner { + + private var reorderHelper: ItemTouchHelper? = null + private val viewModel by viewModel() + + override val recyclerView: RecyclerView + get() = binding.recyclerView + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) + + override fun onResume() { + super.onResume() + activity?.setTitle(R.string.remote_sources) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val sourcesAdapter = SourceConfigAdapter(this, get(), viewLifecycleOwner) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = sourcesAdapter + reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also { + it.attachToRecyclerView(this) + } + } + viewModel.items.observe(viewLifecycleOwner) { + sourcesAdapter.items = it + } + addMenuProvider(SourcesMenuProvider()) + } + + override fun onDestroyView() { + reorderHelper = null + super.onDestroyView() + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.recyclerView.updatePadding( + bottom = insets.bottom, + left = insets.left, + right = insets.right + ) + } + + override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { + val fragment = SourceSettingsFragment.newInstance(item.source) + (parentFragment as? SettingsHeadersFragment)?.openFragment(fragment) + ?: (activity as? SettingsActivity)?.openFragment(fragment) + } + + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { + viewModel.setEnabled(item.source, isEnabled) + } + + override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) { + reorderHelper?.startDrag(holder) + } + + override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) { + viewModel.expandOrCollapse(header.localeId) + } + + private inner class SourcesMenuProvider : + MenuProvider, + MenuItem.OnActionExpandListener, + SearchView.OnQueryTextListener { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_sources, menu) + val searchMenuItem = menu.findItem(R.id.action_search) + searchMenuItem.setOnActionExpandListener(this) + val searchView = searchMenuItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + searchView.setIconifiedByDefault(false) + searchView.queryHint = searchMenuItem.title + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.action_disable_all -> { + viewModel.disableAll() + true + } + else -> false + } + + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + (item.actionView as SearchView).setQuery("", false) + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean = false + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.performSearch(newText) + return true + } + } + + private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.DOWN or ItemTouchHelper.UP, + 0, + ) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources( + viewHolder.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder( + current.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun isLongPressDragEnabled() = false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt new file mode 100644 index 000000000..3a5340fe8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt @@ -0,0 +1,174 @@ +package org.koitharu.kotatsu.settings.sources + +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.getLocaleTitle +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.move +import java.util.* + +private const val KEY_ENABLED = "!" + +class SourcesSettingsViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + val items = MutableLiveData>(emptyList()) + private val expandedGroups = HashSet() + private var searchQuery: String? = null + + init { + buildList() + } + + fun reorderSources(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + snapshot.move(oldPos, newPos) + settings.sourcesOrder = snapshot.mapNotNull { + (it as? SourceConfigItem.SourceItem)?.source?.name + } + buildList() + return true + } + + fun canReorder(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + return true + } + + fun setEnabled(source: MangaSource, isEnabled: Boolean) { + settings.hiddenSources = if (isEnabled) { + settings.hiddenSources - source.name + } else { + settings.hiddenSources + source.name + } + if (isEnabled) { + settings.markKnownSources(setOf(source)) + } + buildList() + } + + fun disableAll() { + settings.hiddenSources = settings.getMangaSources(includeHidden = true).mapToSet { + it.name + } + buildList() + } + + fun expandOrCollapse(headerId: String?) { + if (headerId in expandedGroups) { + expandedGroups.remove(headerId) + } else { + expandedGroups.add(headerId) + } + buildList() + } + + fun performSearch(query: String?) { + searchQuery = query?.trim() + buildList() + } + + private fun buildList() { + val sources = settings.getMangaSources(includeHidden = true) + val hiddenSources = settings.hiddenSources + val query = searchQuery + if (!query.isNullOrEmpty()) { + items.value = sources.mapNotNull { + if (!it.title.contains(query, ignoreCase = true)) { + return@mapNotNull null + } + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = it.name !in hiddenSources, + isDraggable = false, + ) + }.ifEmpty { + listOf(SourceConfigItem.EmptySearchResult) + } + return + } + val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { + if (it.name !in hiddenSources) { + KEY_ENABLED + } else { + it.locale + } + } + val result = ArrayList(sources.size + map.size + 1) + val enabledSources = map.remove(KEY_ENABLED) + if (!enabledSources.isNullOrEmpty()) { + result += SourceConfigItem.Header(R.string.enabled_sources) + enabledSources.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + summary = it.getLocaleTitle(), + isEnabled = true, + isDraggable = true, + ) + } + } + if (enabledSources?.size != sources.size) { + result += SourceConfigItem.Header(R.string.available_sources) + for ((key, list) in map) { + list.sortBy { it.ordinal } + val isExpanded = key in expandedGroups + result += SourceConfigItem.LocaleGroup( + localeId = key, + title = getLocaleTitle(key), + isExpanded = isExpanded, + ) + if (isExpanded) { + list.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + summary = null, + isEnabled = false, + isDraggable = false, + ) + } + } + } + } + items.value = result + } + + private fun getLocaleTitle(localeKey: String?): String? { + val locale = Locale(localeKey ?: return null) + return locale.getDisplayLanguage(locale).toTitleCase(locale) + } + + private class LocaleKeyComparator : Comparator { + + private val deviceLocales = LocaleListCompat.getAdjustedDefault() + .map { it.language } + + override fun compare(a: String?, b: String?): Int { + when { + a == b -> return 0 + a == null -> return 1 + b == null -> return -1 + } + val ai = deviceLocales.indexOf(a!!) + val bi = deviceLocales.indexOf(b!!) + return when { + ai < 0 && bi < 0 -> a.compareTo(b) + ai < 0 -> 1 + bi < 0 -> -1 + else -> ai.compareTo(bi) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt new file mode 100644 index 000000000..d580684be --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +class SourceConfigAdapter( + listener: SourceConfigListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) : AsyncListDifferDelegationAdapter( + SourceConfigDiffCallback(), + sourceConfigHeaderDelegate(), + sourceConfigGroupDelegate(listener), + sourceConfigItemDelegate(listener, coil, lifecycleOwner), + sourceConfigDraggableItemDelegate(listener), + sourceConfigEmptySearchDelegate(), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt new file mode 100644 index 000000000..946f84078 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -0,0 +1,118 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import android.widget.CompoundButton +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemExpandableBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding +import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding +import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest +import org.koitharu.kotatsu.utils.ext.textAndVisible + +fun sourceConfigHeaderDelegate() = + adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } + ) { + + bind { + binding.textViewTitle.setText(item.titleResId) + } + } + +fun sourceConfigGroupDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) } +) { + + binding.root.setOnClickListener { + listener.onHeaderClick(item) + } + + bind { + binding.root.text = item.title ?: getString(R.string.various_languages) + binding.root.isChecked = item.isExpanded + } +} + +fun sourceConfigItemDelegate( + listener: SourceConfigListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable } +) { + + binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> + listener.onItemEnabledChanged(item, isChecked) + } + + bind { + binding.textViewTitle.text = item.source.title + binding.switchToggle.isChecked = item.isEnabled + binding.textViewDescription.textAndVisible = item.summary + binding.imageViewIcon.newImageRequest(item.faviconUrl)?.run { + error(R.drawable.ic_favicon_fallback) + lifecycle(lifecycleOwner) + enqueueWith(coil) + } + } + + onViewRecycled { + binding.imageViewIcon.disposeImageRequest() + } +} + +@SuppressLint("ClickableViewAccessibility") +fun sourceConfigDraggableItemDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigDraggableBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable } +) { + + val eventListener = object : + View.OnClickListener, + View.OnTouchListener, + CompoundButton.OnCheckedChangeListener { + override fun onClick(v: View?) = listener.onItemSettingsClick(item) + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + return if (event.actionMasked == MotionEvent.ACTION_DOWN) { + listener.onDragHandleTouch(this@adapterDelegateViewBinding) + true + } else { + false + } + } + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + listener.onItemEnabledChanged(item, isChecked) + } + } + + binding.imageViewConfig.setOnClickListener(eventListener) + binding.switchToggle.setOnCheckedChangeListener(eventListener) + binding.imageViewHandle.setOnTouchListener(eventListener) + + bind { + binding.textViewTitle.text = item.source.title + binding.textViewDescription.text = item.summary ?: getString(R.string.various_languages) + binding.switchToggle.isChecked = item.isEnabled + } +} + +fun sourceConfigEmptySearchDelegate() = adapterDelegate( + R.layout.item_sources_empty +) { } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt new file mode 100644 index 000000000..8bab50c2a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.recyclerview.widget.DiffUtil +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem.* + +class SourceConfigDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is LocaleGroup && newItem is LocaleGroup -> { + oldItem.localeId == newItem.localeId + } + oldItem is SourceItem && newItem is SourceItem -> { + oldItem.source == newItem.source + } + oldItem is Header && newItem is Header -> { + oldItem.titleResId == newItem.titleResId + } + oldItem == EmptySearchResult && newItem == EmptySearchResult -> { + true + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt similarity index 51% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt index 2a09d6516..8bc03a213 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt @@ -1,15 +1,15 @@ package org.koitharu.kotatsu.settings.sources.adapter -import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener +import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -interface SourceConfigListener : OnTipCloseListener { +interface SourceConfigListener { fun onItemSettingsClick(item: SourceConfigItem.SourceItem) - fun onItemLiftClick(item: SourceConfigItem.SourceItem) + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) - fun onItemShortcutClick(item: SourceConfigItem.SourceItem) + fun onDragHandleTouch(holder: RecyclerView.ViewHolder) - fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) -} + fun onHeaderClick(header: SourceConfigItem.LocaleGroup) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt index 4239c03fb..41f4afa84 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -6,57 +6,43 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem -import android.webkit.CookieManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContract import androidx.core.graphics.Insets import androidx.core.view.isVisible import androidx.core.view.updatePadding -import dagger.hilt.android.AndroidEntryPoint +import com.google.android.material.R as materialR import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.browser.BrowserCallback import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.ProgressChromeClient -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback +import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability -import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.network.UserAgents -import javax.inject.Inject -import com.google.android.material.R as materialR +import org.koitharu.kotatsu.utils.TaggedActivityResult -@AndroidEntryPoint class SourceAuthActivity : BaseActivity(), BrowserCallback { - @Inject - lateinit var mangaRepositoryFactory: MangaRepository.Factory - - private lateinit var onBackPressedCallback: WebViewBackPressedCallback private lateinit var authProvider: MangaParserAuthProvider @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) { - return - } - val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource + setContentView(ActivityBrowserBinding.inflate(layoutInflater)) + val source = intent?.getSerializableExtra(EXTRA_SOURCE) as? MangaSource if (source == null) { finishAfterTransition() return } - val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository - authProvider = (repository)?.getAuthProvider() ?: run { + authProvider = (MangaRepository(source) as? RemoteMangaRepository)?.getAuthProvider() ?: run { Toast.makeText( this, getString(R.string.auth_not_supported_by, source.title), - Toast.LENGTH_SHORT, + Toast.LENGTH_SHORT ).show() finishAfterTransition() return @@ -65,66 +51,68 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) } - with(viewBinding.webView.settings) { + with(binding.webView.settings) { javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - userAgentString = UserAgents.CHROME_MOBILE + userAgentString = UserAgentInterceptor.userAgentChrome } - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - viewBinding.webView.webViewClient = BrowserClient(this) - viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) - onBackPressedDispatcher.addCallback(onBackPressedCallback) + binding.webView.webViewClient = BrowserClient(this) + binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar) if (savedInstanceState != null) { return } val url = authProvider.authUrl onTitleChanged( source.title, - getString(R.string.loading_), + getString(R.string.loading_) ) - viewBinding.webView.loadUrl(url) + binding.webView.loadUrl(url) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - viewBinding.webView.saveState(outState) + binding.webView.saveState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - viewBinding.webView.restoreState(savedInstanceState) + binding.webView.restoreState(savedInstanceState) } override fun onDestroy() { super.onDestroy() - viewBinding.webView.destroy() + binding.webView.destroy() } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { - viewBinding.webView.stopLoading() + binding.webView.stopLoading() setResult(Activity.RESULT_CANCELED) finishAfterTransition() true } - else -> super.onOptionsItemSelected(item) } + override fun onBackPressed() { + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + super.onBackPressed() + } + } + override fun onPause() { - viewBinding.webView.onPause() + binding.webView.onPause() super.onPause() } override fun onResume() { super.onResume() - viewBinding.webView.onResume() + binding.webView.onResume() } override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading + binding.progressBar.isVisible = isLoading if (!isLoading && authProvider.isAuthorized) { Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show() setResult(Activity.RESULT_OK) @@ -137,13 +125,9 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba supportActionBar?.subtitle = subtitle } - override fun onHistoryChanged() { - onBackPressedCallback.onHistoryChanged() - } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding(top = insets.top) - viewBinding.webView.updatePadding(bottom = insets.bottom) + binding.appbar.updatePadding(top = insets.top) + binding.webView.updatePadding(bottom = insets.bottom) } class Contract : ActivityResultContract() { @@ -166,4 +150,4 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba .putExtra(EXTRA_SOURCE, source) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt new file mode 100644 index 000000000..1aafbecd2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -0,0 +1,84 @@ +package org.koitharu.kotatsu.settings.sources.model + +import android.net.Uri +import androidx.annotation.StringRes +import org.koitharu.kotatsu.parsers.model.MangaSource + +sealed interface SourceConfigItem { + + class Header( + @StringRes val titleResId: Int, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Header + return titleResId == other.titleResId + } + + override fun hashCode(): Int = titleResId + } + + class LocaleGroup( + val localeId: String?, + val title: String?, + val isExpanded: Boolean, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocaleGroup + + if (localeId != other.localeId) return false + if (title != other.title) return false + if (isExpanded != other.isExpanded) return false + + return true + } + + override fun hashCode(): Int { + var result = localeId?.hashCode() ?: 0 + result = 31 * result + (title?.hashCode() ?: 0) + result = 31 * result + isExpanded.hashCode() + return result + } + } + + class SourceItem( + val source: MangaSource, + val isEnabled: Boolean, + val summary: String?, + val isDraggable: Boolean, + ) : SourceConfigItem { + + val faviconUrl: Uri + get() = Uri.fromParts("favicon", source.name, null) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SourceItem + + if (source != other.source) return false + if (summary != other.summary) return false + if (isEnabled != other.isEnabled) return false + if (isDraggable != other.isDraggable) return false + + return true + } + + override fun hashCode(): Int { + var result = source.hashCode() + result = 31 * result + summary.hashCode() + result = 31 * result + isEnabled.hashCode() + result = 31 * result + isDraggable.hashCode() + return result + } + } + + object EmptySearchResult : SourceConfigItem +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt similarity index 78% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt index ed9aabd94..42d7b70c9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt @@ -7,10 +7,8 @@ import android.util.AttributeSet import android.view.View import androidx.appcompat.widget.TooltipCompat import androidx.core.net.toUri -import androidx.core.view.forEach import androidx.preference.Preference import androidx.preference.PreferenceViewHolder -import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding @@ -29,7 +27,13 @@ class AboutLinksPreference @JvmOverloads constructor( super.onBindViewHolder(holder) val binding = PreferenceAboutLinksBinding.bind(holder.itemView) - binding.root.forEach { button -> + arrayOf( + binding.btn4pda, + binding.btnDiscord, + binding.btnGithub, + binding.btnReddit, + binding.btnTwitter, + ).forEach { button -> TooltipCompat.setTooltipText(button, button.contentDescription) button.setOnClickListener(this) } @@ -37,15 +41,17 @@ class AboutLinksPreference @JvmOverloads constructor( override fun onClick(v: View) { val urlResId = when (v.id) { + R.id.btn_4pda -> R.string.url_forpda R.id.btn_discord -> R.string.url_discord - R.id.btn_telegram -> R.string.url_telegram + R.id.btn_twitter -> R.string.url_twitter + R.id.btn_reddit -> R.string.url_reddit R.id.btn_github -> R.string.url_github else -> return } - openLink(v, v.context.getString(urlResId), v.contentDescription) + openLink(v.context.getString(urlResId), v.contentDescription) } - private fun openLink(v: View, url: String, title: CharSequence?) { + private fun openLink(url: String, title: CharSequence?) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) try { context.startActivity( @@ -53,10 +59,9 @@ class AboutLinksPreference @JvmOverloads constructor( Intent.createChooser(intent, title) } else { intent - }, + } ) } catch (_: ActivityNotFoundException) { - Snackbar.make(v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt similarity index 85% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt index 378133c58..fe1d3f15c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt @@ -10,7 +10,6 @@ import android.widget.EditText import androidx.annotation.ArrayRes import androidx.annotation.AttrRes import androidx.annotation.StyleRes -import androidx.core.content.withStyledAttributes import androidx.preference.EditTextPreference import org.koitharu.kotatsu.R @@ -26,12 +25,6 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor( init { super.setOnBindEditTextListener(autoCompleteBindListener) - context.withStyledAttributes(attrs, R.styleable.AutoCompleteTextViewPreference, defStyleAttr, defStyleRes) { - val entriesId = getResourceId(R.styleable.AutoCompleteTextViewPreference_android_entries, 0) - if (entriesId != 0) { - setEntries(entriesId) - } - } } fun setEntries(@ArrayRes arrayResId: Int) { @@ -62,4 +55,4 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor( } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt similarity index 83% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt index a65122501..50e93f24b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextBindListener.kt @@ -2,11 +2,11 @@ package org.koitharu.kotatsu.settings.utils import android.widget.EditText import androidx.preference.EditTextPreference -import org.koitharu.kotatsu.core.util.EditTextValidator +import org.koitharu.kotatsu.utils.EditTextValidator class EditTextBindListener( private val inputType: Int, - private val hint: String?, + private val hint: String, private val validator: EditTextValidator?, ) : EditTextPreference.OnBindEditTextListener { @@ -15,4 +15,4 @@ class EditTextBindListener( editText.hint = hint validator?.attachToEditText(editText) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt new file mode 100644 index 000000000..447092234 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.settings.utils + +import androidx.annotation.StringRes +import androidx.preference.EditTextPreference +import androidx.preference.Preference + +class EditTextSummaryProvider(@StringRes private val emptySummaryId: Int) : + Preference.SummaryProvider { + + override fun provideSummary(preference: EditTextPreference): CharSequence { + val text = preference.text + return if (text.isNullOrEmpty()) { + preference.context.getString(emptySummaryId) + } else { + text + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/LinksPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt similarity index 83% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/LinksPreference.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt index 2820a6ecb..4317f93ea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/LinksPreference.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/LinksPreference.kt @@ -1,9 +1,9 @@ package org.koitharu.kotatsu.settings.utils import android.content.Context +import android.text.method.LinkMovementMethod import android.util.AttributeSet import android.widget.TextView -import androidx.core.text.method.LinkMovementMethodCompat import androidx.preference.Preference import androidx.preference.PreferenceViewHolder @@ -13,9 +13,11 @@ class LinksPreference @JvmOverloads constructor( defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, defStyleRes: Int = 0, ) : Preference(context, attrs, defStyleAttr, defStyleRes) { + + override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val summaryView = holder.findViewById(android.R.id.summary) as TextView - summaryView.movementMethod = LinkMovementMethodCompat.getInstance() + summaryView.movementMethod = LinkMovementMethod.getInstance() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiAutoCompleteTextViewPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/MultiAutoCompleteTextViewPreference.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiAutoCompleteTextViewPreference.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/MultiAutoCompleteTextViewPreference.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt similarity index 79% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt index c7ce17e55..039dbbb37 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/MultiSummaryProvider.kt @@ -13,9 +13,8 @@ class MultiSummaryProvider(@StringRes private val emptySummaryId: Int) : return preference.context.getString(emptySummaryId) } else { values.joinToString(", ") { - preference.entries.getOrNull(preference.findIndexOfValue(it)) - ?: preference.context.getString(androidx.preference.R.string.not_set) + preference.entries[preference.findIndexOfValue(it)] } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt index 9e2151f6b..3920cb32c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/RingtonePickContract.kt @@ -6,31 +6,29 @@ import android.media.RingtoneManager import android.net.Uri import android.provider.Settings import androidx.activity.result.contract.ActivityResultContract -import androidx.annotation.StringRes -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -class RingtonePickContract(@StringRes private val titleResId: Int) : ActivityResultContract() { +class RingtonePickContract(private val title: String?) : ActivityResultContract() { override fun createIntent(context: Context, input: Uri?): Intent { val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) intent.putExtra( RingtoneManager.EXTRA_RINGTONE_TYPE, - RingtoneManager.TYPE_NOTIFICATION, + RingtoneManager.TYPE_NOTIFICATION ) intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true) intent.putExtra( RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, - Settings.System.DEFAULT_NOTIFICATION_URI, + Settings.System.DEFAULT_NOTIFICATION_URI ) - if (titleResId != 0) { - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, context.getString(titleResId)) + if (title != null) { + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title) } intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, input) return intent } override fun parseResult(resultCode: Int, intent: Intent?): Uri? { - return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + return intent?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt index fbf2fcc0c..0933fe1d0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/SliderPreference.kt @@ -5,13 +5,13 @@ import android.content.res.TypedArray import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet +import android.view.View import androidx.core.content.withStyledAttributes -import androidx.customview.view.AbsSavedState import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.setValueRounded +import org.koitharu.kotatsu.utils.ext.setValueRounded class SliderPreference @JvmOverloads constructor( context: Context, @@ -40,11 +40,11 @@ class SliderPreference @JvmOverloads constructor( attrs, R.styleable.SliderPreference, defStyleAttr, - defStyleRes, + defStyleRes ) { valueFrom = getFloat( R.styleable.SliderPreference_android_valueFrom, - valueFrom.toFloat(), + valueFrom.toFloat() ).toInt() valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() @@ -117,7 +117,7 @@ class SliderPreference @JvmOverloads constructor( } } - private class SavedState : AbsSavedState { + private class SavedState : View.BaseSavedState { val valueFrom: Int val valueTo: Int @@ -134,7 +134,7 @@ class SliderPreference @JvmOverloads constructor( this.currentValue = currentValue } - constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { + constructor(source: Parcel) : super(source) { valueFrom = source.readInt() valueTo = source.readInt() currentValue = source.readInt() @@ -148,13 +148,12 @@ class SliderPreference @JvmOverloads constructor( } companion object { - @Suppress("unused") @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) + override fun createFromParcel(`in`: Parcel) = SavedState(`in`) override fun newArray(size: Int): Array = arrayOfNulls(size) } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt similarity index 76% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt index 28e9c76a8..53998e48b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/TagsAutoCompleteProvider.kt @@ -1,9 +1,8 @@ package org.koitharu.kotatsu.settings.utils -import javax.inject.Inject import org.koitharu.kotatsu.core.db.MangaDatabase -class TagsAutoCompleteProvider @Inject constructor( +class TagsAutoCompleteProvider( private val db: MangaDatabase, ) : MultiAutoCompleteTextViewPreference.AutoCompleteProvider { @@ -11,7 +10,7 @@ class TagsAutoCompleteProvider @Inject constructor( if (query.isEmpty()) { return emptyList() } - val tags = db.getTagsDao().findTags(query = "$query%", limit = 6) + val tags = db.tagsDao.findTags(query = "$query%", limit = 6) val set = HashSet() val result = ArrayList(tags.size) for (tag in tags) { @@ -21,4 +20,4 @@ class TagsAutoCompleteProvider @Inject constructor( } return result } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt new file mode 100644 index 000000000..df0a2c870 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/SuggestionsModule.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.suggestions + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.suggestions.ui.SuggestionsViewModel + +val suggestionsModule + get() = module { + + factory { SuggestionRepository(get()) } + + viewModel { SuggestionsViewModel(get(), get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt similarity index 51% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt index a71ca9e9b..0f80321a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionDao.kt @@ -1,11 +1,6 @@ package org.koitharu.kotatsu.suggestions.data -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update +import androidx.room.* import kotlinx.coroutines.flow.Flow @Dao @@ -15,20 +10,9 @@ abstract class SuggestionDao { @Query("SELECT * FROM suggestions ORDER BY relevance DESC") abstract fun observeAll(): Flow> - @Transaction - @Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit") - abstract fun observeAll(limit: Int): Flow> - - @Transaction - @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1") - abstract suspend fun getRandom(): SuggestionWithManga? - @Query("SELECT COUNT(*) FROM suggestions") abstract suspend fun count(): Int - @Query("SELECT manga.title FROM suggestions LEFT JOIN manga ON suggestions.manga_id = manga.manga_id WHERE manga.title LIKE :query") - abstract suspend fun getTitles(query: String): List - @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: SuggestionEntity): Long @@ -44,4 +28,4 @@ abstract class SuggestionDao { insert(entity) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionEntity.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/data/SuggestionWithManga.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/domain/MangaSuggestion.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index 389fb96da..398a0a0f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -7,56 +7,43 @@ import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.suggestions.data.SuggestionEntity -import javax.inject.Inject +import org.koitharu.kotatsu.utils.ext.mapItems -class SuggestionRepository @Inject constructor( +class SuggestionRepository( private val db: MangaDatabase, ) { fun observeAll(): Flow> { - return db.getSuggestionDao().observeAll().mapItems { - it.manga.toManga(it.tags.toMangaTags()) - } - } - - fun observeAll(limit: Int): Flow> { - return db.getSuggestionDao().observeAll(limit).mapItems { - it.manga.toManga(it.tags.toMangaTags()) - } - } - - suspend fun getRandom(): Manga? { - return db.getSuggestionDao().getRandom()?.let { + return db.suggestionDao.observeAll().mapItems { it.manga.toManga(it.tags.toMangaTags()) } } suspend fun clear() { - db.getSuggestionDao().deleteAll() + db.suggestionDao.deleteAll() } suspend fun isEmpty(): Boolean { - return db.getSuggestionDao().count() == 0 + return db.suggestionDao.count() == 0 } suspend fun replace(suggestions: Iterable) { db.withTransaction { - db.getSuggestionDao().deleteAll() + db.suggestionDao.deleteAll() suggestions.forEach { (manga, relevance) -> val tags = manga.tags.toEntities() - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga.toEntity(), tags) - db.getSuggestionDao().upsert( + db.tagsDao.upsert(tags) + db.mangaDao.upsert(manga.toEntity(), tags) + db.suggestionDao.upsert( SuggestionEntity( mangaId = manga.id, relevance = relevance, createdAt = System.currentTimeMillis(), - ), + ) ) } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt similarity index 54% rename from app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt rename to app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt index 2aa161e98..f1af41bf3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -4,36 +4,31 @@ import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.View import androidx.appcompat.view.ActionMode import androidx.core.view.MenuProvider -import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -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 +import org.koitharu.kotatsu.utils.ext.addMenuProvider class SuggestionsFragment : MangaListFragment() { - override val viewModel by viewModels() + override val viewModel by viewModel() override val isSwipeRefreshEnabled = false - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) addMenuProvider(SuggestionMenuProvider()) } override fun onScrolledToEnd() = Unit - override fun onCreateActionMode( - controller: ListSelectionController, - mode: ActionMode, - menu: Menu, - ): Boolean { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) + return super.onCreateActionMode(mode, menu) } private inner class SuggestionMenuProvider : MenuProvider { @@ -42,40 +37,26 @@ class SuggestionsFragment : MangaListFragment() { menuInflater.inflate(R.menu.opt_suggestions, menu) } - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - menu.findItem(R.id.action_settings_suggestions)?.isVisible = - menu.findItem(R.id.action_settings) == null - } - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_update -> { - viewModel.updateSuggestions() + SuggestionsWorker.startNow(requireContext()) Snackbar.make( - requireViewBinding().recyclerView, + binding.recyclerView, R.string.feed_will_update_soon, Snackbar.LENGTH_LONG, ).show() true } - - R.id.action_settings_suggestions -> { + R.id.action_settings -> { startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext())) true } - else -> false } } companion object { - @Deprecated( - "", ReplaceWith( - "SuggestionsFragment()", - "org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment" - ) - ) fun newInstance() = SuggestionsFragment() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt new file mode 100644 index 000000000..42f0c093a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -0,0 +1,55 @@ +package org.koitharu.kotatsu.suggestions.ui + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.onFirst + +class SuggestionsViewModel( + repository: SuggestionRepository, + settings: AppSettings, +) : MangaListViewModel(settings) { + + private val headerModel = ListHeader(null, R.string.suggestions, null) + + override val content = combine( + repository.observeAll(), + createListModeFlow() + ) { list, mode -> + when { + list.isEmpty() -> listOf( + EmptyState( + icon = R.drawable.ic_empty_suggestions, + textPrimary = R.string.nothing_found, + textSecondary = R.string.text_suggestion_holder, + actionStringRes = 0, + ) + ) + else -> buildList(list.size + 1) { + add(headerModel) + list.toUi(this, mode) + } + } + }.onStart { + loadingCounter.increment() + }.onFirst { + loadingCounter.decrement() + }.catch { + it.toErrorState(canRetry = false) + }.asLiveDataDistinct( + viewModelScope.coroutineContext + Dispatchers.Default, + listOf(LoadingState) + ) + + override fun onRefresh() = Unit + + override fun onRetry() = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt new file mode 100644 index 000000000..7433e689b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -0,0 +1,176 @@ +package org.koitharu.kotatsu.suggestions.ui + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.FloatRange +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.history.domain.HistoryRepository +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion +import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository +import org.koitharu.kotatsu.utils.ext.asArrayList +import org.koitharu.kotatsu.utils.ext.trySetForeground +import java.util.concurrent.TimeUnit +import kotlin.math.pow + +class SuggestionsWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params), KoinComponent { + + private val suggestionRepository by inject() + private val historyRepository by inject() + private val appSettings by inject() + + override suspend fun doWork(): Result { + val count = doWorkImpl() + val outputData = workDataOf(DATA_COUNT to count) + return Result.success(outputData) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val title = applicationContext.getString(R.string.suggestions_updating) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + WORKER_CHANNEL_ID, + title, + NotificationManager.IMPORTANCE_LOW + ) + channel.setShowBadge(false) + channel.enableVibration(false) + channel.setSound(null, null) + channel.enableLights(false) + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) + .setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setDefaults(0) + .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark)) + .setSilent(true) + .setProgress(0, 0, true) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) + .setOngoing(true) + .build() + + return ForegroundInfo(WORKER_NOTIFICATION_ID, notification) + } + + private suspend fun doWorkImpl(): Int { + if (!appSettings.isSuggestionsEnabled) { + suggestionRepository.clear() + return 0 + } + val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex() + val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot { + blacklistTagRegex?.containsMatchIn(it.title) ?: false + } + if (allTags.isEmpty()) { + return 0 + } + if (TAG in tags) { // not expedited + trySetForeground() + } + val tagsBySources = allTags.groupBy { x -> x.source } + val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) + val rawResults = coroutineScope { + tagsBySources.flatMap { (source, tags) -> + val repo = MangaRepository(source) + tags.map { tag -> + async(dispatcher) { + repo.getList( + offset = 0, + sortOrder = SortOrder.UPDATED, + tags = setOf(tag), + ) + } + } + }.awaitAll().flatten().asArrayList() + } + if (appSettings.isSuggestionsExcludeNsfw) { + rawResults.removeAll { it.isNsfw } + } + if (blacklistTagRegex != null) { + rawResults.removeAll { + it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) } + } + } + if (rawResults.isEmpty()) { + return 0 + } + val suggestions = rawResults.distinctBy { manga -> + manga.id + }.map { manga -> + MangaSuggestion( + manga = manga, + relevance = computeRelevance(manga.tags, allTags) + ) + }.sortedBy { it.relevance }.take(LIMIT) + suggestionRepository.replace(suggestions) + return suggestions.size + } + + @FloatRange(from = 0.0, to = 1.0) + private fun computeRelevance(mangaTags: Set, allTags: List): Float { + val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 + val weight = mangaTags.sumOf { tag -> + val index = allTags.indexOf(tag) + if (index < 0) 0 else allTags.size - index + } + return (weight / maxWeight).pow(2.0).toFloat() + } + + companion object { + + private const val TAG = "suggestions" + private const val TAG_ONESHOT = "suggestions_oneshot" + private const val LIMIT = 140 + private const val TAGS_LIMIT = 20 + private const val MAX_PARALLELISM = 4 + private const val DATA_COUNT = "count" + private const val WORKER_CHANNEL_ID = "suggestion_worker" + private const val WORKER_NOTIFICATION_ID = 36 + + fun setup(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints(constraints) + .addTag(TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun startNow(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG_ONESHOT) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(context) + .enqueue(request) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt new file mode 100644 index 000000000..e15791927 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.tracker + +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.tracker.domain.Tracker +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.tracker.ui.FeedViewModel +import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels + +val trackerModule + get() = module { + + factory { TrackingRepository(get()) } + factory { TrackerNotificationChannels(androidContext(), get()) } + + factory { Tracker(get(), get(), get()) } + + viewModel { FeedViewModel(get()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt new file mode 100644 index 000000000..452f60f8c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.tracker.data + +import java.util.* +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.db.entity.toMangaTags +import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem + +fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem( + id = trackLog.id, + chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }, + manga = manga.toManga(tags.toMangaTags()), + createdAt = Date(trackLog.createdAt) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt index eb8228d13..52f7b8d18 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt @@ -13,18 +13,18 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity entity = MangaEntity::class, parentColumns = ["manga_id"], childColumns = ["manga_id"], - onDelete = ForeignKey.CASCADE, - ), - ], + onDelete = ForeignKey.CASCADE + ) + ] ) class TrackEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, - @get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING) + @get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR) @ColumnInfo(name = "chapters_total") val totalChapters: Int, @ColumnInfo(name = "last_chapter_id") val lastChapterId: Long, @ColumnInfo(name = "chapters_new") val newChapters: Int, @ColumnInfo(name = "last_check") val lastCheck: Long, - @get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING) + @get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR) @ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long -) +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt similarity index 100% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt new file mode 100644 index 000000000..2fed9de12 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.tracker.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class TracksDao { + + @Query("SELECT * FROM tracks") + abstract suspend fun findAll(): List + + @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)") + abstract suspend fun findAll(ids: Collection): List + + @Query("SELECT * FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun find(mangaId: Long): TrackEntity? + + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun findNewChapters(mangaId: Long): Int? + + @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") + abstract fun observeNewChapters(mangaId: Long): Flow + + @Query("DELETE FROM tracks") + abstract suspend fun clear() + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insert(entity: TrackEntity): Long + + @Update + abstract suspend fun update(entity: TrackEntity): Int + + @Query("DELETE FROM tracks WHERE manga_id = :mangaId") + abstract suspend fun delete(mangaId: Long) + + @Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)") + abstract suspend fun gc() + + @Transaction + open suspend fun upsert(entity: TrackEntity) { + if (update(entity) == 0) { + insert(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt index 115fc4dfb..e93902485 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/Tracker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt @@ -1,28 +1,18 @@ package org.koitharu.kotatsu.tracker.domain import androidx.annotation.VisibleForTesting -import coil.request.CachePolicy -import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.CompositeMutex2 -import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackingItem -import javax.inject.Inject -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract -class Tracker @Inject constructor( +class Tracker( private val settings: AppSettings, private val repository: TrackingRepository, - private val historyRepository: HistoryRepository, private val channels: TrackerNotificationChannels, - private val mangaRepositoryFactory: MangaRepository.Factory, ) { suspend fun getAllTracks(): List { @@ -30,7 +20,7 @@ class Tracker @Inject constructor( if (sources.isEmpty()) { return emptyList() } - val knownManga = HashSet() + val knownIds = HashSet() val result = ArrayList() // Favourites if (AppSettings.TRACK_FAVOURITES in sources) { @@ -47,7 +37,7 @@ class Tracker @Inject constructor( null } for (track in categoryTracks) { - if (knownManga.add(track.manga.id)) { + if (knownIds.add(track.manga)) { result.add(TrackingItem(track, channelId)) } } @@ -63,7 +53,7 @@ class Tracker @Inject constructor( null } for (track in historyTracks) { - if (knownManga.add(track.manga.id)) { + if (knownIds.add(track.manga)) { result.add(TrackingItem(track, channelId)) } } @@ -72,22 +62,13 @@ class Tracker @Inject constructor( return result } - suspend fun getTracks(ids: Set): List { - return getAllTracks().filterTo(ArrayList(ids.size)) { x -> x.tracking.manga.id in ids } - } - suspend fun gc() { repository.gc() } - suspend fun fetchUpdates( - track: MangaTracking, - commit: Boolean - ): MangaUpdates.Success = withMangaLock(track.manga.id) { - val repo = mangaRepositoryFactory.create(track.manga.source) - require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" } - val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY) - val updates = compare(track, manga, getBranch(manga)) + suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates { + val manga = MangaRepository(track.manga.source).getDetails(track.manga) + val updates = compare(track, manga) if (commit) { repository.saveUpdates(updates) } @@ -95,9 +76,9 @@ class Tracker @Inject constructor( } @VisibleForTesting - suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates.Success { + suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates { val track = repository.getTrack(manga) - val updates = compare(track, manga, getBranch(manga)) + val updates = compare(track, manga) if (commit) { repository.saveUpdates(updates) } @@ -105,59 +86,30 @@ class Tracker @Inject constructor( } @VisibleForTesting - suspend fun deleteTrack(mangaId: Long) = withMangaLock(mangaId) { + suspend fun deleteTrack(mangaId: Long) { repository.deleteTrack(mangaId) } - private suspend fun getBranch(manga: Manga): String? { - val history = historyRepository.getOne(manga) - return manga.getPreferredBranch(history) - } - /** * The main functionality of tracker: check new chapters in [manga] comparing to the [track] */ - private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates.Success { + private fun compare(track: MangaTracking, manga: Manga): MangaUpdates { if (track.isEmpty()) { // first check or manga was empty on last check - return MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null) + return MangaUpdates(manga, emptyList(), isValid = false) } - val chapters = requireNotNull(manga.getChapters(branch)) + val chapters = requireNotNull(manga.chapters) val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId } return when { newChapters.isEmpty() -> { - MangaUpdates.Success( - manga = manga, - newChapters = emptyList(), - isValid = chapters.lastOrNull()?.id == track.lastChapterId, - channelId = null, - ) + return MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId) } - newChapters.size == chapters.size -> { - MangaUpdates.Success(manga, emptyList(), isValid = false, channelId = null) + return MangaUpdates(manga, emptyList(), isValid = false) } - else -> { - MangaUpdates.Success(manga, newChapters, isValid = true, channelId = null) - } - } - } - - private companion object { - - private val mangaMutex = CompositeMutex2() - - suspend inline fun withMangaLock(id: Long, action: () -> T): T { - contract { - callsInPlace(action, InvocationKind.EXACTLY_ONCE) - } - mangaMutex.lock(id) - try { - return action() - } finally { - mangaMutex.unlock(id) + return MangaUpdates(manga, newChapters, isValid = true) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt new file mode 100644 index 000000000..7586a7f50 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -0,0 +1,169 @@ +package org.koitharu.kotatsu.tracker.domain + +import androidx.annotation.VisibleForTesting +import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.toManga +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.favourites.data.toFavouriteCategory +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.tracker.data.TrackEntity +import org.koitharu.kotatsu.tracker.data.TrackLogEntity +import org.koitharu.kotatsu.tracker.data.toTrackingLogItem +import org.koitharu.kotatsu.tracker.domain.model.MangaTracking +import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates +import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem +import java.util.* + +private const val NO_ID = 0L + +class TrackingRepository( + private val db: MangaDatabase, +) { + + suspend fun getNewChaptersCount(mangaId: Long): Int { + return db.tracksDao.findNewChapters(mangaId) ?: 0 + } + + fun observeNewChaptersCount(mangaId: Long): Flow { + return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 } + } + + suspend fun getTracks(mangaList: Collection): List { + val ids = mangaList.mapToSet { it.id } + val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId } + val idSet = HashSet() + val result = ArrayList(mangaList.size) + for (item in mangaList) { + if (item.source == MangaSource.LOCAL || !idSet.add(item.id)) { + continue + } + val track = tracks[item.id]?.lastOrNull() + result += MangaTracking( + manga = item, + lastChapterId = track?.lastChapterId ?: NO_ID, + lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date) + ) + } + return result + } + + @VisibleForTesting + suspend fun getTrack(manga: Manga): MangaTracking { + val track = db.tracksDao.find(manga.id) + return MangaTracking( + manga = manga, + lastChapterId = track?.lastChapterId ?: NO_ID, + lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date) + ) + } + + @VisibleForTesting + suspend fun deleteTrack(mangaId: Long) { + db.tracksDao.delete(mangaId) + } + + suspend fun getTrackingLog(offset: Int, limit: Int): List { + return db.trackLogsDao.findAll(offset, limit).map { x -> + x.toTrackingLogItem() + } + } + + suspend fun getLogsCount() = db.trackLogsDao.count() + + suspend fun clearLogs() = db.trackLogsDao.clear() + + suspend fun gc() { + db.withTransaction { + db.tracksDao.gc() + db.trackLogsDao.gc() + } + } + + suspend fun saveUpdates(updates: MangaUpdates) { + db.withTransaction { + val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) + db.tracksDao.upsert(track) + if (updates.isValid && updates.newChapters.isNotEmpty()) { + val logEntity = TrackLogEntity( + mangaId = updates.manga.id, + chapters = updates.newChapters.joinToString("\n") { x -> x.name }, + createdAt = System.currentTimeMillis(), + ) + db.trackLogsDao.insert(logEntity) + } + } + } + + suspend fun syncWithHistory(manga: Manga, chapterId: Long) { + val chapters = manga.chapters ?: return + val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } + val track = getOrCreateTrack(manga.id) + val lastNewChapterIndex = chapters.size - track.newChapters + val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID + val entity = TrackEntity( + mangaId = manga.id, + totalChapters = chapters.size, + lastChapterId = lastChapterId, + newChapters = when { + track.newChapters == 0 -> 0 + chapterIndex < 0 -> track.newChapters + chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex + else -> track.newChapters + }, + lastCheck = System.currentTimeMillis(), + lastNotifiedChapterId = lastChapterId, + ) + db.tracksDao.upsert(entity) + } + + suspend fun getCategoriesCount(): IntArray { + val categories = db.favouriteCategoriesDao.findAll() + return intArrayOf( + categories.count { it.track }, + categories.size, + ) + } + + suspend fun getAllFavouritesManga(): Map> { + val categories = db.favouriteCategoriesDao.findAll() + return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity -> + categoryEntity.toFavouriteCategory() to + db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList() + } + } + + suspend fun getAllHistoryManga(): List { + return db.historyDao.findAllManga().toMangaList() + } + + private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { + return db.tracksDao.find(mangaId) ?: TrackEntity( + mangaId = mangaId, + totalChapters = 0, + lastChapterId = 0L, + newChapters = 0, + lastCheck = 0L, + lastNotifiedChapterId = 0L, + ) + } + + private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity { + val chapters = updates.manga.chapters.orEmpty() + return TrackEntity( + mangaId = mangaId, + totalChapters = chapters.size, + lastChapterId = chapters.lastOrNull()?.id ?: NO_ID, + newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, + lastCheck = System.currentTimeMillis(), + lastNotifiedChapterId = NO_ID, + ) + } + + private fun Collection.toMangaList() = map { it.toManga(emptySet()) } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt new file mode 100644 index 000000000..74c964ec8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.tracker.domain.model + +import java.util.* +import org.koitharu.kotatsu.parsers.model.Manga + +class MangaTracking( + val manga: Manga, + val lastChapterId: Long, + val lastCheck: Date?, +) { + + fun isEmpty(): Boolean { + return lastChapterId == 0L + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MangaTracking + + if (manga != other.manga) return false + if (lastChapterId != other.lastChapterId) return false + if (lastCheck != other.lastCheck) return false + + return true + } + + override fun hashCode(): Int { + var result = manga.hashCode() + result = 31 * result + lastChapterId.hashCode() + result = 31 * result + (lastCheck?.hashCode() ?: 0) + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt new file mode 100644 index 000000000..937f06808 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.tracker.domain.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter + +class MangaUpdates( + val manga: Manga, + val newChapters: List, + val isValid: Boolean, +) { + + fun isNotEmpty() = newChapters.isNotEmpty() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt similarity index 72% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt index c6d36b70c..c5021eaf3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt @@ -1,12 +1,11 @@ package org.koitharu.kotatsu.tracker.domain.model +import java.util.* import org.koitharu.kotatsu.parsers.model.Manga -import java.time.Instant data class TrackingLogItem( val id: Long, val manga: Manga, val chapters: List, - val createdAt: Instant, - val isNew: Boolean, -) + val createdAt: Date +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt new file mode 100644 index 000000000..3bcc46ea7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -0,0 +1,132 @@ +package org.koitharu.kotatsu.tracker.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.view.updatePadding +import com.google.android.material.snackbar.Snackbar +import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener +import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration +import org.koitharu.kotatsu.databinding.FragmentFeedBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.list.ui.adapter.MangaListListener +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.main.ui.AppBarOwner +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter +import org.koitharu.kotatsu.tracker.work.TrackWorker +import org.koitharu.kotatsu.utils.ext.addMenuProvider +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight + +class FeedFragment : + BaseFragment(), + PaginationScrollListener.Callback, + MangaListListener { + + private val viewModel by viewModel() + + private var feedAdapter: FeedAdapter? = null + private var paddingVertical = 0 + private var paddingHorizontal = 0 + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentFeedBinding.inflate(inflater, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + feedAdapter = FeedAdapter(get(), viewLifecycleOwner, this) + with(binding.recyclerView) { + adapter = feedAdapter + setHasFixedSize(true) + addOnScrollListener(PaginationScrollListener(4, this@FeedFragment)) + val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) + paddingHorizontal = spacing + paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) + val decoration = TypedSpacingItemDecoration( + FeedAdapter.ITEM_TYPE_FEED to 0, + fallbackSpacing = spacing + ) + addItemDecoration(decoration) + } + binding.swipeRefreshLayout.isEnabled = false + addMenuProvider(FeedMenuProvider(binding.recyclerView, viewModel)) + + viewModel.content.observe(viewLifecycleOwner, this::onListChanged) + viewModel.onError.observe(viewLifecycleOwner, this::onError) + viewModel.onFeedCleared.observe(viewLifecycleOwner) { + onFeedCleared() + } + TrackWorker.getIsRunningLiveData(view.context.applicationContext) + .observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) + } + + override fun onDestroyView() { + feedAdapter = null + super.onDestroyView() + } + + override fun onWindowInsetsChanged(insets: Insets) { + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top + binding.recyclerView.updatePadding( + top = headerHeight + paddingVertical, + left = insets.left + paddingHorizontal, + right = insets.right + paddingHorizontal, + bottom = insets.bottom + paddingVertical, + ) + } + + override fun onRetryClick(error: Throwable) = Unit + + override fun onTagRemoveClick(tag: MangaTag) = Unit + + override fun onFilterClick() = Unit + + override fun onEmptyActionClick() = Unit + + private fun onListChanged(list: List) { + feedAdapter?.items = list + } + + private fun onFeedCleared() { + Snackbar.make( + binding.recyclerView, + R.string.updates_feed_cleared, + Snackbar.LENGTH_LONG + ).show() + } + + private fun onError(e: Throwable) { + Snackbar.make( + binding.recyclerView, + e.getDisplayMessage(resources), + Snackbar.LENGTH_SHORT + ).show() + } + + private fun onIsTrackerRunningChanged(isRunning: Boolean) { + binding.swipeRefreshLayout.isRefreshing = isRunning + } + + override fun onScrolledToEnd() { + viewModel.loadList(append = true) + } + + override fun onItemClick(item: Manga, view: View) { + startActivity(DetailsActivity.newIntent(context ?: return, item)) + } + + companion object { + + fun newInstance() = FeedFragment() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedMenuProvider.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt index 9ec9c545b..655f02f8b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedMenuProvider.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.tracker.ui.feed +package org.koitharu.kotatsu.tracker.ui import android.content.Context import android.view.Menu @@ -6,9 +6,11 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.view.MenuProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.dialog.CheckBoxAlertDialog -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.tracker.work.TrackWorker class FeedMenuProvider( private val snackbarHost: View, @@ -24,28 +26,29 @@ class FeedMenuProvider( override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_update -> { - viewModel.update() + TrackWorker.startNow(context) + Snackbar.make( + snackbarHost, + R.string.feed_will_update_soon, + Snackbar.LENGTH_LONG, + ).show() true } - R.id.action_clear_feed -> { - CheckBoxAlertDialog.Builder(context) + MaterialAlertDialogBuilder(context) .setTitle(R.string.clear_updates_feed) .setMessage(R.string.text_clear_updates_feed_prompt) .setNegativeButton(android.R.string.cancel, null) - .setCheckBoxChecked(true) - .setCheckBoxText(R.string.clear_new_chapters_counters) - .setPositiveButton(R.string.clear) { _, isChecked -> - viewModel.clearFeed(isChecked) - }.create().show() + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearFeed() + }.show() true } - - R.id.action_updated -> { - context.startActivity(UpdatesActivity.newIntent(context)) + R.id.action_settings -> { + val intent = SettingsActivity.newTrackerSettingsIntent(context) + context.startActivity(intent) true } - else -> false } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt new file mode 100644 index 000000000..11ae7f6fa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt @@ -0,0 +1,117 @@ +package org.koitharu.kotatsu.tracker.ui + +import androidx.lifecycle.viewModelScope +import java.util.* +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.model.* +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem +import org.koitharu.kotatsu.tracker.ui.model.toFeedItem +import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.daysDiff + +class FeedViewModel( + private val repository: TrackingRepository +) : BaseViewModel() { + + private val logList = MutableStateFlow?>(null) + private val hasNextPage = MutableStateFlow(false) + private var loadingJob: Job? = null + private val header = ListHeader(null, R.string.updates, null) + + val onFeedCleared = SingleLiveEvent() + val content = combine( + logList.filterNotNull(), + hasNextPage + ) { list, isHasNextPage -> + buildList(list.size + 2) { + if (list.isEmpty()) { + add(header) + add( + EmptyState( + icon = R.drawable.ic_empty_feed, + textPrimary = R.string.text_empty_holder_primary, + textSecondary = R.string.text_feed_holder, + actionStringRes = 0, + ) + ) + } else { + list.mapListTo(this) + if (isHasNextPage) { + add(LoadingFooter) + } + } + } + }.asLiveDataDistinct( + viewModelScope.coroutineContext + Dispatchers.Default, + listOf(header, LoadingState) + ) + + init { + loadList(append = false) + } + + fun loadList(append: Boolean) { + if (loadingJob?.isActive == true) { + return + } + if (append && !hasNextPage.value) { + return + } + loadingJob = launchLoadingJob(Dispatchers.Default) { + val offset = if (append) logList.value?.size ?: 0 else 0 + val list = repository.getTrackingLog(offset, 20) + if (!append) { + logList.value = list + } else if (list.isNotEmpty()) { + logList.value = logList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } + } + + fun clearFeed() { + val lastJob = loadingJob + loadingJob = launchLoadingJob(Dispatchers.Default) { + lastJob?.cancelAndJoin() + repository.clearLogs() + logList.value = emptyList() + onFeedCleared.postCall(Unit) + } + } + + private fun List.mapListTo(destination: MutableList) { + var prevDate: DateTimeAgo? = null + for (item in this) { + val date = timeAgo(item.createdAt) + if (prevDate != date) { + destination += date + } + prevDate = date + destination += item.toFeedItem() + } + } + + private fun timeAgo(date: Date): DateTimeAgo { + val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L) + val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt() + val diffDays = -date.daysDiff(System.currentTimeMillis()) + return when { + diffMinutes < 3 -> DateTimeAgo.JustNow + diffDays < 1 -> DateTimeAgo.Today + diffDays == 1 -> DateTimeAgo.Yesterday + diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays) + else -> DateTimeAgo.Absolute(date) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt new file mode 100644 index 000000000..bebf7baae --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.tracker.ui.adapter + +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.adapter.* +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.tracker.ui.model.FeedItem +import kotlin.jvm.internal.Intrinsics + +class FeedAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, + listener: MangaListListener, +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager + .addDelegate(ITEM_TYPE_FEED, feedItemAD(coil, lifecycleOwner, listener)) + .addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD()) + .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) + .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) + .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) + .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener)) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) + .addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD()) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { + oldItem is FeedItem && newItem is FeedItem -> { + oldItem.id == newItem.id + } + oldItem is DateTimeAgo && newItem is DateTimeAgo -> { + oldItem == newItem + } + else -> oldItem.javaClass == newItem.javaClass + } + + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } + + companion object { + + const val ITEM_TYPE_FEED = 0 + const val ITEM_TYPE_LOADING_FOOTER = 1 + const val ITEM_TYPE_LOADING_STATE = 2 + const val ITEM_TYPE_ERROR_STATE = 3 + const val ITEM_TYPE_ERROR_FOOTER = 4 + const val ITEM_TYPE_EMPTY = 5 + const val ITEM_TYPE_HEADER = 6 + const val ITEM_TYPE_DATE_HEADER = 7 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt similarity index 58% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt index c10211c9b..410017043 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt @@ -1,39 +1,37 @@ -package org.koitharu.kotatsu.tracker.ui.feed.adapter +package org.koitharu.kotatsu.tracker.ui.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemFeedBinding import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem +import org.koitharu.kotatsu.tracker.ui.model.FeedItem +import org.koitharu.kotatsu.utils.ext.disposeImageRequest +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.newImageRequest fun feedItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, + clickListener: OnListItemClickListener ) = adapterDelegateViewBinding( - { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) } ) { + itemView.setOnClickListener { clickListener.onItemClick(item.manga, it) } bind { - val alpha = if (item.isNew) 1f else 0.5f - binding.textViewTitle.alpha = alpha - binding.textViewSummary.alpha = alpha - binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run { + binding.imageViewCover.newImageRequest(item.imageUrl)?.run { placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) + error(R.drawable.ic_placeholder) allowRgb565(true) - source(item.manga.source) + lifecycle(lifecycleOwner) enqueueWith(coil) } binding.textViewTitle.text = item.title @@ -43,4 +41,8 @@ fun feedItemAD( item.count, ) } -} + + onViewRecycled { + binding.imageViewCover.disposeImageRequest() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/FeedItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt similarity index 52% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/FeedItem.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt index 7cc085d4b..1cdce1869 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/FeedItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.tracker.ui.feed.model +package org.koitharu.kotatsu.tracker.ui.model import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga @@ -9,9 +9,4 @@ data class FeedItem( val title: String, val manga: Manga, val count: Int, - val isNew: Boolean, -) : ListModel { - override fun areItemsTheSame(other: ListModel): Boolean { - return other is FeedItem && other.id == id - } -} +) : ListModel \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt similarity index 75% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/ListModelConversionExt.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt index f99997c82..b12c4ecff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.tracker.ui.feed.model +package org.koitharu.kotatsu.tracker.ui.model import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem @@ -8,5 +8,4 @@ fun TrackingLogItem.toFeedItem() = FeedItem( title = manga.title, count = chapters.size, manga = manga, - isNew = isNew, -) +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt new file mode 100644 index 000000000..51aedc866 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -0,0 +1,220 @@ +package org.koitharu.kotatsu.tracker.work + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC +import androidx.core.app.NotificationCompat.VISIBILITY_SECRET +import androidx.core.content.ContextCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.map +import androidx.work.* +import coil.ImageLoader +import coil.request.ImageRequest +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +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.domain.Tracker +import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates +import org.koitharu.kotatsu.utils.PendingIntentCompat +import org.koitharu.kotatsu.utils.ext.referer +import org.koitharu.kotatsu.utils.ext.toBitmapOrNull +import org.koitharu.kotatsu.utils.ext.trySetForeground + +class TrackWorker(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams), + KoinComponent { + + private val notificationManager by lazy { + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private val coil by inject() + private val settings by inject() + private val tracker by inject() + + override suspend fun doWork(): Result { + if (!settings.isTrackerEnabled) { + return Result.success(workDataOf(0, 0)) + } + if (TAG in tags) { // not expedited + trySetForeground() + } + val tracks = tracker.getAllTracks() + if (tracks.isEmpty()) { + return Result.success(workDataOf(0, 0)) + } + + val updates = checkUpdatesAsync(tracks) + val results = updates.awaitAll() + tracker.gc() + + var success = 0 + var failed = 0 + results.forEach { x -> + if (x == null) { + failed++ + } else { + success++ + } + } + val resultData = workDataOf(success, failed) + return if (success == 0 && failed != 0) { + Result.failure(resultData) + } else { + Result.success(resultData) + } + } + + private suspend fun checkUpdatesAsync(tracks: List): List> { + val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) + val deferredList = coroutineScope { + tracks.map { (track, channelId) -> + async(dispatcher) { + runCatching { + tracker.fetchUpdates(track, commit = true) + }.onSuccess { updates -> + if (updates.isValid && updates.isNotEmpty()) { + showNotification( + manga = updates.manga, + channelId = channelId, + newChapters = updates.newChapters, + ) + } + }.getOrNull() + } + } + } + return deferredList + } + + private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List) { + if (newChapters.isEmpty() || channelId == null) { + return + } + val id = manga.url.hashCode() + val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary) + val builder = NotificationCompat.Builder(applicationContext, channelId) + val summary = applicationContext.resources.getQuantityString( + R.plurals.new_chapters, newChapters.size, newChapters.size + ) + with(builder) { + setContentText(summary) + setContentTitle(manga.title) + setNumber(newChapters.size) + setLargeIcon( + coil.execute( + ImageRequest.Builder(applicationContext).data(manga.coverUrl).referer(manga.publicUrl).build() + ).toBitmapOrNull() + ) + setSmallIcon(R.drawable.ic_stat_book_plus) + val style = NotificationCompat.InboxStyle(this) + for (chapter in newChapters) { + style.addLine(chapter.name) + } + style.setSummaryText(manga.title) + style.setBigContentTitle(summary) + setStyle(style) + val intent = DetailsActivity.newIntent(applicationContext, manga) + setContentIntent( + PendingIntent.getActivity( + applicationContext, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + ) + setAutoCancel(true) + setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) + color = colorPrimary + setShortcutId(manga.id.toString()) + priority = NotificationCompat.PRIORITY_DEFAULT + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setSound(settings.notificationSound) + var defaults = if (settings.notificationLight) { + setLights(colorPrimary, 1000, 5000) + NotificationCompat.DEFAULT_LIGHTS + } else 0 + if (settings.notificationVibrate) { + builder.setVibrate(longArrayOf(500, 500, 500, 500)) + defaults = defaults or NotificationCompat.DEFAULT_VIBRATE + } + builder.setDefaults(defaults) + } + } + withContext(Dispatchers.Main) { + notificationManager.notify(TAG, id, builder.build()) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val title = applicationContext.getString(R.string.check_for_new_chapters) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + WORKER_CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW + ) + channel.setShowBadge(false) + channel.enableVibration(false) + channel.setSound(null, null) + channel.enableLights(false) + notificationManager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID).setContentTitle(title) + .setPriority(NotificationCompat.PRIORITY_MIN).setDefaults(0) + .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark)).setSilent(true) + .setProgress(0, 0, true).setSmallIcon(android.R.drawable.stat_notify_sync) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED).setOngoing(true).build() + + return ForegroundInfo(WORKER_NOTIFICATION_ID, notification) + } + + private fun workDataOf(success: Int, failed: Int): Data { + return Data.Builder() + .putInt(DATA_KEY_SUCCESS, success) + .putInt(DATA_KEY_FAILED, failed) + .build() + } + + companion object { + + private const val WORKER_CHANNEL_ID = "track_worker" + private const val WORKER_NOTIFICATION_ID = 35 + private const val TAG = "tracking" + private const val TAG_ONESHOT = "tracking_oneshot" + private const val MAX_PARALLELISM = 4 + private const val DATA_KEY_SUCCESS = "success" + private const val DATA_KEY_FAILED = "failed" + + fun setup(context: Context) { + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = + PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints(constraints).addTag(TAG) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES).build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun startNow(context: Context) { + val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + val request = OneTimeWorkRequestBuilder().setConstraints(constraints).addTag(TAG_ONESHOT) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).build() + WorkManager.getInstance(context).enqueue(request) + } + + fun getIsRunningLiveData(context: Context): LiveData { + val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() + return WorkManager.getInstance(context).getWorkInfosLiveData(query).map { works -> + works.any { x -> x.state == WorkInfo.State.RUNNING } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt similarity index 63% rename from app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt rename to app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt index 4525aa9b3..81fcb73d5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt @@ -1,19 +1,18 @@ package org.koitharu.kotatsu.tracker.work +import android.app.NotificationChannel +import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.os.Build -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationChannelGroupCompat +import androidx.annotation.RequiresApi import androidx.core.app.NotificationManagerCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings -class TrackerNotificationChannels @Inject constructor( - @ApplicationContext private val context: Context, +class TrackerNotificationChannels( + private val context: Context, private val settings: AppSettings, ) { @@ -23,6 +22,9 @@ class TrackerNotificationChannels @Inject constructor( get() = !manager.areNotificationsEnabled() fun updateChannels(categories: Collection) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } manager.deleteNotificationChannel(OLD_CHANNEL_ID) val group = createGroup() val existingChannels = group.channels.associateByTo(HashMap()) { it.id } @@ -31,10 +33,8 @@ class TrackerNotificationChannels @Inject constructor( if (existingChannels.remove(id)?.name == category.title) { continue } - val channel = NotificationChannelCompat.Builder(id, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(category.title) - .setGroup(GROUP_ID) - .build() + val channel = NotificationChannel(id, category.title, NotificationManager.IMPORTANCE_DEFAULT) + channel.group = GROUP_ID manager.createNotificationChannel(channel) } existingChannels.remove(CHANNEL_ID_HISTORY) @@ -45,15 +45,23 @@ class TrackerNotificationChannels @Inject constructor( } fun createChannel(category: FavouriteCategory) { - val id = getFavouritesChannelId(category.id) - val channel = NotificationChannelCompat.Builder(id, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(category.title) - .setGroup(createGroup().id) - .build() + renameChannel(category.id, category.title) + } + + fun renameChannel(categoryId: Long, name: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val id = getFavouritesChannelId(categoryId) + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) + channel.group = createGroup().id manager.createNotificationChannel(channel) } fun deleteChannel(categoryId: Long) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } manager.deleteNotificationChannel(getFavouritesChannelId(categoryId)) } @@ -87,8 +95,11 @@ class TrackerNotificationChannels @Inject constructor( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return settings.isTrackerNotificationsEnabled } - val group = manager.getNotificationChannelGroupCompat(GROUP_ID) ?: return true - return !group.isBlocked && group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE } + val group = manager.getNotificationChannelGroup(GROUP_ID) ?: return true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && group.isBlocked) { + return false + } + return group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE } } fun getFavouritesChannelId(categoryId: Long): String { @@ -99,21 +110,26 @@ class TrackerNotificationChannels @Inject constructor( return CHANNEL_ID_HISTORY } - private fun createGroup(): NotificationChannelGroupCompat { - return manager.getNotificationChannelGroupCompat(GROUP_ID) ?: run { - val group = NotificationChannelGroupCompat.Builder(GROUP_ID) - .setName(context.getString(R.string.new_chapters)) - .build() - manager.createNotificationChannelGroup(group) - group + @RequiresApi(Build.VERSION_CODES.O) + private fun createGroup(): NotificationChannelGroup { + manager.getNotificationChannelGroup(GROUP_ID)?.let { + return it } + val group = NotificationChannelGroup(GROUP_ID, context.getString(R.string.new_chapters)) + manager.createNotificationChannelGroup(group) + return group } private fun createHistoryChannel() { - val channel = NotificationChannelCompat.Builder(CHANNEL_ID_HISTORY, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(context.getString(R.string.history)) - .setGroup(GROUP_ID) - .build() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel( + CHANNEL_ID_HISTORY, + context.getString(R.string.history), + NotificationManager.IMPORTANCE_DEFAULT, + ) + channel.group = GROUP_ID manager.createNotificationChannel(channel) } @@ -124,4 +140,4 @@ class TrackerNotificationChannels @Inject constructor( private const val CHANNEL_ID_HISTORY = "track_history" private const val OLD_CHANNEL_ID = "tracking" } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt new file mode 100644 index 000000000..1b945618a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.tracker.work + +import org.koitharu.kotatsu.tracker.domain.model.MangaTracking + +class TrackingItem( + val tracking: MangaTracking, + val channelId: String?, +) { + + operator fun component1() = tracking + + operator fun component2() = channelId + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrackingItem + + if (tracking != other.tracking) return false + if (channelId != other.channelId) return false + + return true + } + + override fun hashCode(): Int { + var result = tracking.hashCode() + result = 31 * result + channelId.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AlphanumComparator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/AlphanumComparator.kt similarity index 97% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/AlphanumComparator.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/AlphanumComparator.kt index 46867633e..cee0626c0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AlphanumComparator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/AlphanumComparator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils class AlphanumComparator : Comparator { @@ -60,4 +60,4 @@ class AlphanumComparator : Comparator { } return chunk.toString() } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt new file mode 100644 index 000000000..ae40b41f6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.utils + +import android.view.View +import androidx.appcompat.widget.Toolbar +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.R as materialR + +open class BottomSheetToolbarController( + protected val toolbar: Toolbar, +) : BottomSheetBehavior.BottomSheetCallback() { + + override fun onStateChanged(bottomSheet: View, newState: Int) { + val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top <= 0 + if (isExpanded) { + toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material) + } else { + toolbar.navigationIcon = null + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/BufferedObserver.kt b/app/src/main/java/org/koitharu/kotatsu/utils/BufferedObserver.kt new file mode 100644 index 000000000..bc806ec7a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/BufferedObserver.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.utils + +fun interface BufferedObserver { + + fun onChanged(t: T, previous: T?) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt new file mode 100644 index 000000000..6433ca44e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt @@ -0,0 +1,70 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.* +import kotlin.coroutines.resume + +class CompositeMutex : Set { + + private val data = HashMap>>() + private val mutex = Mutex() + + override val size: Int + get() = data.size + + override fun contains(element: T): Boolean { + return data.containsKey(element) + } + + override fun containsAll(elements: Collection): Boolean { + return elements.all { x -> data.containsKey(x) } + } + + override fun isEmpty(): Boolean { + return data.isEmpty() + } + + override fun iterator(): Iterator { + return data.keys.iterator() + } + + suspend fun lock(element: T) { + while (currentCoroutineContext().isActive) { + waitForRemoval(element) + mutex.withLock { + if (data[element] == null) { + data[element] = LinkedList>() + return + } + } + } + } + + suspend fun unlock(element: T) { + val continuations = mutex.withLock { + checkNotNull(data.remove(element)) { + "CompositeMutex is not locked for $element" + } + } + continuations.forEach { c -> + if (c.isActive) { + c.resume(Unit) + } + } + } + + private suspend fun waitForRemoval(element: T) { + val list = data[element] ?: return + suspendCancellableCoroutine { continuation -> + list.add(continuation) + continuation.invokeOnCancellation { + list.remove(continuation) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/EditTextValidator.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/EditTextValidator.kt index 3554fc194..37ca77618 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/EditTextValidator.kt @@ -1,11 +1,11 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils 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.util.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.getDisplayMessage import java.lang.ref.WeakReference abstract class EditTextValidator : TextWatcher { @@ -51,4 +51,4 @@ abstract class EditTextValidator : TextWatcher { class Failed(val message: CharSequence) : ValidationResult() } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileSize.kt b/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileSize.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt index 6325c3dec..cb558edfe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/FileSize.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils import android.content.Context import org.koitharu.kotatsu.R @@ -22,8 +22,8 @@ enum class FileSize(private val multiplier: Int) { return buildString { append( DecimalFormat("#,##0.#").format( - bytes / 1024.0.pow(digitGroups.toDouble()), - ), + bytes / 1024.0.pow(digitGroups.toDouble()) + ) ) val unit = units.getOrNull(digitGroups) if (unit != null) { @@ -32,4 +32,4 @@ enum class FileSize(private val multiplier: Int) { } } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/GridTouchHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/GridTouchHelper.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt index 6608c719e..13ccd3fa7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/GridTouchHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils import android.content.Context import android.view.GestureDetector @@ -7,7 +7,7 @@ import kotlin.math.roundToInt class GridTouchHelper( context: Context, - private val listener: OnGridTouchListener, + private val listener: OnGridTouchListener ) : GestureDetector.SimpleOnGestureListener() { private val detector = GestureDetector(context, this) @@ -16,7 +16,7 @@ class GridTouchHelper( private var isDispatching = false init { - detector.setIsLongpressEnabled(true) + detector.setIsLongpressEnabled(false) detector.setOnDoubleTapListener(this) } @@ -44,10 +44,9 @@ class GridTouchHelper( else -> return false } } - 2 -> AREA_RIGHT else -> return false - }, + } ) return true } @@ -67,4 +66,4 @@ class GridTouchHelper( fun onProcessTouch(rawX: Int, rawY: Int): Boolean } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt new file mode 100644 index 000000000..cedc875fa --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt @@ -0,0 +1,45 @@ +package org.koitharu.kotatsu.utils + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class LifecycleAwareServiceConnection( + private val host: Activity, +) : ServiceConnection, DefaultLifecycleObserver { + + private val serviceStateFlow = MutableStateFlow(null) + + val service: StateFlow + get() = serviceStateFlow + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + serviceStateFlow.value = service + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceStateFlow.value = null + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + host.unbindService(this) + } +} + +fun Activity.bindServiceWithLifecycle( + owner: LifecycleOwner, + service: Intent, + flags: Int +): LifecycleAwareServiceConnection { + val connection = LifecycleAwareServiceConnection(this) + bindService(service, connection, flags) + owner.lifecycle.addObserver(connection) + return connection +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt new file mode 100644 index 000000000..57eb100a4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.utils + +import androidx.annotation.MainThread +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable + +class PausingDispatcher( + private val dispatcher: CoroutineDispatcher, +) : CoroutineDispatcher() { + + @Volatile + private var isPaused = false + private val queue = ConcurrentLinkedQueue() + + override fun isDispatchNeeded(context: CoroutineContext): Boolean { + return isPaused || super.isDispatchNeeded(context) + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (isPaused) { + queue.add(Task(context, block)) + } else { + dispatcher.dispatch(context, block) + } + } + + @MainThread + fun pause() { + isPaused = true + } + + @MainThread + fun resume() { + if (!isPaused) { + return + } + isPaused = false + while (true) { + val task = queue.poll() ?: break + dispatcher.dispatch(task.context, task.block) + } + } + + private class Task( + val context: CoroutineContext, + val block: Runnable, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt new file mode 100644 index 000000000..9bdd10971 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PendingIntentCompat.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.utils + +import android.app.PendingIntent +import android.os.Build + +object PendingIntentCompat { + + @JvmField + val FLAG_IMMUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + + @JvmField + val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt new file mode 100644 index 000000000..edece17d7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/PreferenceIconTarget.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.utils + +import android.graphics.drawable.Drawable +import androidx.preference.Preference +import coil.target.Target + +class PreferenceIconTarget( + private val preference: Preference, +) : Target { + + override fun onError(error: Drawable?) { + preference.icon = error + } + + override fun onStart(placeholder: Drawable?) { + preference.icon = placeholder + } + + override fun onSuccess(result: Drawable) { + preference.icon = result + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RecyclerViewScrollCallback.kt b/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/RecyclerViewScrollCallback.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt index f8815ae6e..075126db2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RecyclerViewScrollCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/RecyclerViewScrollCallback.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils import androidx.annotation.Px import androidx.recyclerview.widget.LinearLayoutManager @@ -23,4 +23,4 @@ class RecyclerViewScrollCallback( lm.scrollToPosition(position) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt similarity index 70% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt index 79a071842..4cecbd2a8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ScreenOrientationHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils import android.app.Activity import android.content.pm.ActivityInfo @@ -6,22 +6,19 @@ import android.content.res.Configuration import android.database.ContentObserver import android.os.Handler import android.provider.Settings -import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart -import javax.inject.Inject -@ActivityScoped -class ScreenOrientationHelper @Inject constructor(private val activity: Activity) { +class ScreenOrientationHelper(private val activity: Activity) { val isAutoRotationEnabled: Boolean get() = Settings.System.getInt( activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION, - 0, + 0 ) == 1 var isLandscape: Boolean @@ -34,15 +31,9 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity } } - var isLocked: Boolean - get() = activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LOCKED - set(value) { - activity.requestedOrientation = if (value) { - ActivityInfo.SCREEN_ORIENTATION_LOCKED - } else { - ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - } + fun toggleOrientation() { + isLandscape = !isLandscape + } fun observeAutoOrientation() = callbackFlow { val observer = object : ContentObserver(Handler(activity.mainLooper)) { @@ -51,7 +42,7 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity } } activity.contentResolver.registerContentObserver( - Settings.System.CONTENT_URI, true, observer, + Settings.System.CONTENT_URI, true, observer ) awaitClose { activity.contentResolver.unregisterContentObserver(observer) @@ -59,4 +50,4 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity }.onStart { emit(isAutoRotationEnabled) }.distinctUntilChanged() -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index c16502090..cdd15ab86 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -1,16 +1,13 @@ -package org.koitharu.kotatsu.core.util +package org.koitharu.kotatsu.utils import android.content.Context import android.net.Uri -import android.widget.Toast import androidx.core.app.ShareCompat import androidx.core.content.FileProvider +import java.io.File import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.logs.FileLogger -import org.koitharu.kotatsu.core.model.appUrl import org.koitharu.kotatsu.parsers.model.Manga -import java.io.File private const val TYPE_TEXT = "text/plain" private const val TYPE_IMAGE = "image/*" @@ -23,8 +20,6 @@ class ShareHelper(private val context: Context) { append(manga.title) append("\n \n") append(manga.publicUrl) - append("\n \n") - append(manga.appUrl) } ShareCompat.IntentBuilder(context) .setText(text) @@ -84,25 +79,4 @@ class ShareHelper(private val context: Context) { .setChooserTitle(R.string.share) .startChooser() } - - fun shareLogs(loggers: Collection) { - val intentBuilder = ShareCompat.IntentBuilder(context) - .setType(TYPE_TEXT) - var hasLogs = false - for (logger in loggers) { - val logFile = logger.file - if (!logFile.exists()) { - continue - } - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile) - intentBuilder.addStream(uri) - hasLogs = true - } - if (hasLogs) { - intentBuilder.setChooserTitle(R.string.share_logs) - intentBuilder.startChooser() - } else { - Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show() - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt b/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt new file mode 100644 index 000000000..b8f982f33 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/SingleLiveEvent.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.utils + +import androidx.annotation.AnyThread +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + +class SingleLiveEvent : LiveData() { + + private val pending = AtomicBoolean(false) + + override fun observe(owner: LifecycleOwner, observer: Observer) { + super.observe(owner) { + if (pending.compareAndSet(true, false)) { + observer.onChanged(it) + } + } + } + + override fun setValue(value: T) { + pending.set(true) + super.setValue(value) + } + + @MainThread + fun call(newValue: T) { + setValue(newValue) + } + + @AnyThread + fun postCall(newValue: T) { + postValue(newValue) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/TaggedActivityResult.kt b/app/src/main/java/org/koitharu/kotatsu/utils/TaggedActivityResult.kt new file mode 100644 index 000000000..ee84cffb2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/TaggedActivityResult.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.utils + +import android.app.Activity + +class TaggedActivityResult( + val tag: String, + val result: Int, +) + +val TaggedActivityResult.isSuccess: Boolean + get() = this.result == Activity.RESULT_OK \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/VoiceInputContract.kt b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/os/VoiceInputContract.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt index 15a6de48d..e95e0fb96 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/VoiceInputContract.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt @@ -1,20 +1,16 @@ -package org.koitharu.kotatsu.core.os +package org.koitharu.kotatsu.utils import android.app.Activity import android.content.Context import android.content.Intent import android.speech.RecognizerIntent import androidx.activity.result.contract.ActivityResultContract -import androidx.core.os.ConfigurationCompat -import java.util.Locale class VoiceInputContract : ActivityResultContract() { override fun createIntent(context: Context, input: String?): Intent { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - val locale = ConfigurationCompat.getLocales(context.resources.configuration).get(0) ?: Locale.getDefault() - intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale.toLanguageTag()) intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input) return intent } @@ -27,4 +23,4 @@ class VoiceInputContract : ActivityResultContract() { null } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt new file mode 100644 index 000000000..a7320ad43 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -0,0 +1,95 @@ +package org.koitharu.kotatsu.utils.ext + +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.ResolveInfo +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkRequest +import android.net.Uri +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.core.app.ActivityOptionsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import androidx.work.CoroutineWorker +import kotlin.coroutines.resume +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +val Context.connectivityManager: ConnectivityManager + get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + +suspend fun ConnectivityManager.waitForNetwork(): Network { + val request = NetworkRequest.Builder().build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // fast path + activeNetwork?.let { return it } + } + return suspendCancellableCoroutine { cont -> + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + unregisterNetworkCallback(this) + if (cont.isActive) { + cont.resume(network) + } + } + } + registerNetworkCallback(request, callback) + cont.invokeOnCancellation { + unregisterNetworkCallback(callback) + } + } +} + +fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) + +suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { + val info = getForegroundInfo() + setForeground(info) +}.isSuccess + +fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { + val pm = context.packageManager + val intent = contract.createIntent(context, input) + return pm.resolveActivity(intent, 0) +} + +fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean { + return runCatching { + launch(input, options) + }.isSuccess +} + +fun SharedPreferences.observe() = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + trySendBlocking(key) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } +} + +fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { + emit(valueProducer()) + observe().collect { upstreamKey -> + if (upstreamKey == key) { + emit(valueProducer()) + } + } +}.distinctUntilChanged() + +fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) { + coroutineScope.launch { + delay(delay) + runnable.run() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt new file mode 100644 index 000000000..2fc05e365 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.utils.ext + +import android.widget.ImageView +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import coil.request.ImageResult +import coil.request.SuccessResult +import coil.util.CoilUtils +import com.google.android.material.progressindicator.BaseProgressIndicator +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener + +fun ImageView.newImageRequest(url: Any?): ImageRequest.Builder? { + val current = CoilUtils.result(this) + if (current != null && current.request.data == url) { + return null + } + return ImageRequest.Builder(context) + .data(url) + .crossfade(true) + .target(this) +} + +fun ImageView.disposeImageRequest() { + CoilUtils.dispose(this) + setImageDrawable(null) +} + +fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) + +fun ImageResult.requireBitmap() = when (this) { + is SuccessResult -> drawable.toBitmap() + is ErrorResult -> throw throwable +} + +fun ImageResult.toBitmapOrNull() = when (this) { + is SuccessResult -> try { + drawable.toBitmap() + } catch (_: Throwable) { + null + } + is ErrorResult -> null +} + +fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { + return setHeader(CommonHeaders.REFERER, referer) +} + +fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { + return listener(ImageRequestIndicatorListener(indicator)) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt new file mode 100644 index 000000000..611bbd442 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.utils.ext + +import androidx.collection.ArraySet +import java.util.* + +fun MutableList.move(sourceIndex: Int, targetIndex: Int) { + if (sourceIndex <= targetIndex) { + Collections.rotate(subList(sourceIndex, targetIndex + 1), -1) + } else { + Collections.rotate(subList(targetIndex, sourceIndex + 1), 1) + } +} + +@Suppress("FunctionName") +inline fun MutableSet(size: Int, init: (index: Int) -> T): MutableSet { + val set = ArraySet(size) + repeat(size) { index -> set.add(init(index)) } + return set +} + +@Suppress("FunctionName") +inline fun Set(size: Int, init: (index: Int) -> T): Set = when (size) { + 0 -> emptySet() + 1 -> Collections.singleton(init(0)) + else -> MutableSet(size, init) +} + +fun List.asArrayList(): ArrayList = if (this is ArrayList<*>) { + this as ArrayList +} else { + ArrayList(this) +} + +fun Map.findKeyByValue(value: V): K? { + for ((k, v) in entries) { + if (v == value) { + return k + } + } + return null +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt new file mode 100644 index 000000000..dd4907134 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.utils.ext + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope + +val processLifecycleScope: LifecycleCoroutineScope + inline get() = ProcessLifecycleOwner.get().lifecycleScope \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt new file mode 100644 index 000000000..0cc08fd55 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.utils.ext + +import android.annotation.SuppressLint +import android.text.format.DateUtils +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +@SuppressLint("SimpleDateFormat") +fun Date.format(pattern: String): String = SimpleDateFormat(pattern).format(this) + +fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelativeTimeSpanString( + time, System.currentTimeMillis(), minResolution +) + +fun Date.daysDiff(other: Long): Int { + val thisDay = time / TimeUnit.DAYS.toMillis(1L) + val otherDay = other / TimeUnit.DAYS.toMillis(1L) + return (thisDay - otherDay).toInt() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt new file mode 100644 index 000000000..205b98968 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DisplayExt.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.utils.ext + +import android.content.Context +import android.os.Build +import android.view.Display +import android.view.WindowManager +import androidx.core.content.getSystemService + +val Context.displayCompat: Display? + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + display + } else { + @Suppress("DEPRECATION") + getSystemService()?.defaultDisplay + } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt similarity index 61% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index 77afae296..cab41519f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.ext +package org.koitharu.kotatsu.utils.ext import android.content.ContentResolver import android.content.Context @@ -7,20 +7,15 @@ import android.os.Build import android.os.Environment import android.os.storage.StorageManager import android.provider.OpenableColumns +import androidx.annotation.WorkerThread import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.fs.FileSequence import java.io.File -import java.io.FileFilter -import java.nio.file.attribute.BasicFileAttributes import java.util.zip.ZipEntry import java.util.zip.ZipFile -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.readAttributes -import kotlin.io.path.walk fun File.subdir(name: String) = File(this, name).also { if (!it.exists()) it.mkdirs() @@ -28,10 +23,6 @@ fun File.subdir(name: String) = File(this, name).also { fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } -fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } - -fun File.isNotEmpty() = length() != 0L - fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { it.readText() } @@ -50,10 +41,10 @@ fun File.getStorageName(context: Context): String = runCatching { } }.getOrNull() ?: context.getString(R.string.other_storage) -fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null +fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { - delete() || deleteRecursively() + delete() } fun ContentResolver.resolveName(uri: Uri): String? { @@ -72,25 +63,15 @@ fun ContentResolver.resolveName(uri: Uri): String? { } suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { - walkCompat().sumOf { it.length() } + computeSizeInternal(this) } -fun File.children() = FileSequence(this) - -fun Sequence.filterWith(filter: FileFilter): Sequence = filter { f -> filter.accept(f) } - -val File.creationTime - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - toPath().readAttributes().creationTime().toMillis() +@WorkerThread +private fun computeSizeInternal(file: File): Long { + if (file.isDirectory) { + val files = file.listFiles() ?: return 0L + return files.sumOf { computeSizeInternal(it) } } else { - lastModified() + return file.length() } - -@OptIn(ExperimentalPathApi::class) -fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Use lazy loading on Android 8.0 and later - toPath().walk().map { it.toFile() } -} else { - // Directories are excluded by default in Path.walk(), so do it here as well - walk().filter { it.isFile } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt new file mode 100644 index 000000000..ac4dc90b9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.utils.ext + +import android.os.SystemClock +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transformLatest + +fun Flow.onFirst(action: suspend (T) -> Unit): Flow { + var isFirstCall = true + return onEach { + if (isFirstCall) { + action(it) + isFirstCall = false + } + } +} + +inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { + return map { list -> list.map(transform) } +} + +fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { + var lastEmittedAt = 0L + return transformLatest { value -> + val delay = timeoutMillis(value) + val now = SystemClock.elapsedRealtime() + if (delay > 0L) { + if (lastEmittedAt + delay < now) { + delay(lastEmittedAt + delay - now) + } + } + emit(value) + lastEmittedAt = now + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt new file mode 100644 index 000000000..d37b579e7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.utils.ext + +import android.os.Bundle +import android.os.Parcelable +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 +import java.io.Serializable + +inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { + val b = Bundle(size) + b.block() + this.arguments = b + return this +} + +val Fragment.viewLifecycleScope + inline get() = viewLifecycleOwner.lifecycle.coroutineScope + +fun Fragment.parcelableArgument(name: String): Lazy { + return lazy(LazyThreadSafetyMode.NONE) { + requireNotNull(arguments?.getParcelable(name)) { + "No argument $name passed into ${javaClass.simpleName}" + } + } +} + +fun Fragment.serializableArgument(name: String): Lazy { + return lazy(LazyThreadSafetyMode.NONE) { + @Suppress("UNCHECKED_CAST") + requireNotNull(arguments?.getSerializable(name)) { + "No argument $name passed into ${javaClass.simpleName}" + } as T + } +} + +fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) { + arguments?.getString(name) +} + +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) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt new file mode 100644 index 000000000..058ca4ea6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt @@ -0,0 +1,10 @@ + +package org.koitharu.kotatsu.utils.ext + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +private val TYPE_JSON = "application/json".toMediaType() + +fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt new file mode 100644 index 000000000..c4172000f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt @@ -0,0 +1,74 @@ +package org.koitharu.kotatsu.utils.ext + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.liveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.utils.BufferedObserver +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer) { + this.observe(owner) { + if (it != null) { + observer.onChanged(it) + } + } +} + +fun LiveData.requireValue(): T = checkNotNull(value) { + "LiveData value is null" +} + +fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { + var previous: T? = null + this.observe(owner) { + observer.onChanged(it, previous) + previous = it + } +} + +@Deprecated("Use variant with default value") +fun Flow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext +): LiveData = liveData(context) { + collect { + if (it != latestValue) { + emit(it) + } + } +} + +fun StateFlow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext +): LiveData = asLiveDataDistinct(context, value) + +fun Flow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext, + defaultValue: T +): LiveData = liveData(context, 0L) { + if (latestValue == null) { + emit(defaultValue) + } + collect { + if (it != latestValue) { + emit(it) + } + } +} + +fun Flow.asLiveDataDistinct( + context: CoroutineContext = EmptyCoroutineContext, + defaultValue: suspend () -> T +): LiveData = liveData(context) { + if (latestValue == null) { + emit(defaultValue()) + } + collect { + if (it != latestValue) { + emit(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleListExt.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleListExt.kt index 7d817c6c1..b6ae2535c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleListExt.kt @@ -1,10 +1,7 @@ -package org.koitharu.kotatsu.core.util.ext +package org.koitharu.kotatsu.utils.ext -import android.content.Context import androidx.core.os.LocaleListCompat -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.parsers.util.toTitleCase -import java.util.Locale +import java.util.* operator fun LocaleListCompat.iterator(): ListIterator = LocaleListCompatIterator(this) @@ -20,15 +17,6 @@ inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() -fun String.toLocale() = Locale(this) - -fun Locale?.getDisplayName(context: Context): String { - if (this == null) { - return context.getString(R.string.various_languages) - } - return getDisplayLanguage(this).toTitleCase(this) -} - private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { private var index = 0 @@ -44,4 +32,4 @@ private class LocaleListCompatIterator(private val list: LocaleListCompat) : Lis override fun previous() = list.get(--index) ?: throw NoSuchElementException() override fun previousIndex() = index - 1 -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt index 72b7fc3bd..d20c1eca6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.ext +package org.koitharu.kotatsu.utils.ext import android.content.SharedPreferences import androidx.preference.ListPreference @@ -22,4 +22,4 @@ fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) { putString(key, value?.name) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt new file mode 100644 index 000000000..a8359f5a1 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.utils.ext + +inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt new file mode 100644 index 000000000..db73cb967 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ProgressBarExt.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.utils.ext + +import android.os.Build +import android.widget.ProgressBar +import androidx.core.view.isVisible +import androidx.core.widget.ContentLoadingProgressBar +import com.google.android.material.progressindicator.BaseProgressIndicator + +fun ProgressBar.setProgressCompat(progress: Int, animate: Boolean) = when { + this is BaseProgressIndicator<*> -> setProgressCompat(progress, animate) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> setProgress(progress, animate) + else -> setProgress(progress) +} + +fun ProgressBar.showCompat() = when (this) { + is BaseProgressIndicator<*> -> show() + is ContentLoadingProgressBar -> show() + else -> isVisible = true +} + +fun ProgressBar.hideCompat() = when (this) { + is BaseProgressIndicator<*> -> hide() + is ContentLoadingProgressBar -> hide() + else -> isVisible = false +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ResourcesExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ResourcesExt.kt new file mode 100644 index 000000000..6b5cd4157 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ResourcesExt.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.utils.ext + +import android.content.res.Resources +import androidx.annotation.Px +import kotlin.math.roundToInt + +@Px +fun Resources.resolveDp(dp: Int) = (dp * displayMetrics.density).roundToInt() + +@Px +fun Resources.resolveDp(dp: Float) = dp * displayMetrics.density \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt new file mode 100644 index 000000000..badf5ae7c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.utils.ext + +inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { + return if (this.isNullOrEmpty()) defaultValue() else this +} + +fun String.longHashCode(): Long { + var h = 1125899906842597L + val len: Int = this.length + for (i in 0 until len) { + h = 31 * h + this[i].code + } + return h +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt new file mode 100644 index 000000000..bca90a845 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.utils.ext + +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.annotation.StringRes +import androidx.core.view.isGone + +var TextView.textAndVisible: CharSequence? + get() = text?.takeIf { visibility == View.VISIBLE } + set(value) { + text = value + isGone = value.isNullOrEmpty() + } + +var TextView.drawableStart: Drawable? + inline get() = compoundDrawablesRelative[0] + set(value) { + val dr = compoundDrawablesRelative + setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3]) + } + +var TextView.drawableEnd: Drawable? + inline get() = compoundDrawablesRelative[2] + set(value) { + val dr = compoundDrawablesRelative + setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], dr[1], value, dr[3]) + } + +fun TextView.setTextAndVisible(@StringRes textResId: Int) { + if (textResId == 0) { + text = null + isGone = true + } else { + setText(textResId) + isGone = text.isNullOrEmpty() + } +} + +fun TextView.setTextColorAttr(@AttrRes attrResId: Int) { + setTextColor(context.getThemeColorStateList(attrResId)) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt new file mode 100644 index 000000000..85ee5ea39 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.utils.ext + +import android.content.Context +import android.graphics.Color +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.core.content.res.use + +fun Context.getThemeDrawable( + @AttrRes resId: Int, +) = obtainStyledAttributes(intArrayOf(resId)).use { + it.getDrawable(0) +} + +@ColorInt +fun Context.getThemeColor( + @AttrRes resId: Int, + @ColorInt default: Int = Color.TRANSPARENT +) = obtainStyledAttributes(intArrayOf(resId)).use { + it.getColor(0, default) +} + +fun Context.getThemeColorStateList( + @AttrRes resId: Int, +) = obtainStyledAttributes(intArrayOf(resId)).use { + it.getColorStateList(0) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt new file mode 100644 index 000000000..88d60f45c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThrowableExt.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.utils.ext + +import android.content.ActivityNotFoundException +import android.content.res.Resources +import okio.FileNotFoundException +import org.acra.ktx.sendWithAcra +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.* +import org.koitharu.kotatsu.parsers.exception.AuthRequiredException +import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException +import org.koitharu.kotatsu.parsers.exception.NotFoundException +import org.koitharu.kotatsu.parsers.exception.ParseException +import java.net.SocketTimeoutException + +fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { + is AuthRequiredException -> resources.getString(R.string.auth_required) + is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) + is ActivityNotFoundException, + is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported) + is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) + is FileNotFoundException -> resources.getString(R.string.file_not_found) + is EmptyHistoryException -> resources.getString(R.string.history_is_empty) + is ContentUnavailableException -> message + is ParseException -> shortMessage + is SocketTimeoutException -> resources.getString(R.string.network_error) + is WrongPasswordException -> resources.getString(R.string.wrong_password) + is NotFoundException -> resources.getString(R.string.not_found_404) + else -> localizedMessage +} ?: resources.getString(R.string.error_occurred) + +fun Throwable.isReportable(): Boolean { + if (this !is Exception) { + return true + } + return this is ParseException || this is IllegalArgumentException || + this is IllegalStateException || this.javaClass == RuntimeException::class.java +} + +fun Throwable.report(message: String?) { + CaughtException(this, message).sendWithAcra() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt new file mode 100644 index 000000000..bfd3959a9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -0,0 +1,141 @@ +package org.koitharu.kotatsu.utils.ext + +import android.app.Activity +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.children +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.slider.Slider +import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder +import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewHolder +import kotlin.math.roundToInt + +fun View.hideKeyboard() { + val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(this.windowToken, 0) +} + +fun View.showKeyboard() { + val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(this, 0) +} + +fun RecyclerView.clearItemDecorations() { + suppressLayout(true) + while (itemDecorationCount > 0) { + removeItemDecorationAt(0) + } + suppressLayout(false) +} + +var RecyclerView.firstVisibleItemPosition: Int + get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() + ?: RecyclerView.NO_POSITION + set(value) { + if (value != RecyclerView.NO_POSITION) { + (layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(value, 0) + } + } + +fun View.hasGlobalPoint(x: Int, y: Int): Boolean { + if (visibility != View.VISIBLE) { + return false + } + val rect = Rect() + getGlobalVisibleRect(rect) + return rect.contains(x, y) +} + +fun View.measureHeight(): Int { + val vh = height + return if (vh == 0) { + measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + measuredHeight + } else vh +} + +fun View.measureWidth(): Int { + val vw = width + return if (vw == 0) { + measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + measuredWidth + } else vw +} + +inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) { + registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + callback(position) + } + }) +} + +val ViewPager2.recyclerView: RecyclerView? + get() = children.firstNotNullOfOrNull { it as? RecyclerView } + +fun View.resetTransformations() { + alpha = 1f + translationX = 0f + translationY = 0f + translationZ = 0f + scaleX = 1f + scaleY = 1f + rotation = 0f + rotationX = 0f + rotationY = 0f +} + +fun RecyclerView.findCenterViewPosition(): Int { + val centerX = width / 2f + val centerY = height / 2f + val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION + return getChildAdapterPosition(view) +} + +fun RecyclerView.ViewHolder.getItem(clazz: Class): T? { + val rawItem = when (this) { + is AdapterDelegateViewBindingViewHolder<*, *> -> item + is AdapterDelegateViewHolder<*> -> item + else -> null + } ?: return null + return if (clazz.isAssignableFrom(rawItem.javaClass)) { + clazz.cast(rawItem) + } else { + null + } +} + +fun Slider.setValueRounded(newValue: Float) { + val step = stepSize + value = (newValue / step).roundToInt() * step +} + +val RecyclerView.isScrolledToTop: Boolean + get() { + if (childCount == 0) { + return true + } + val holder = findViewHolderForAdapterPosition(0) + return holder != null && holder.itemView.top >= 0 + } + +fun ViewGroup.findViewsByType(clazz: Class): Sequence { + if (childCount == 0) { + return emptySequence() + } + return sequence { + for (view in children) { + if (clazz.isInstance(view)) { + yield(clazz.cast(view)!!) + } else if (view is ViewGroup && view.childCount != 0) { + yieldAll(view.findViewsByType(clazz)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt index 7c5a5467f..f88929b23 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoilImageGetter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/CoilImageGetter.kt @@ -1,29 +1,25 @@ -package org.koitharu.kotatsu.core.ui.image +package org.koitharu.kotatsu.utils.image import android.content.Context import android.graphics.drawable.Drawable import android.text.Html -import androidx.annotation.WorkerThread import coil.ImageLoader import coil.executeBlocking import coil.request.ImageRequest -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -class CoilImageGetter @Inject constructor( - @ApplicationContext private val context: Context, +class CoilImageGetter( + private val context: Context, private val coil: ImageLoader, ) : Html.ImageGetter { - @WorkerThread override fun getDrawable(source: String?): Drawable? { return coil.executeBlocking( ImageRequest.Builder(context) .data(source) .allowHardware(false) - .build(), + .build() ).drawable?.apply { setBounds(0, 0, intrinsicHeight, intrinsicHeight) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/TrimTransformation.kt similarity index 59% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/image/TrimTransformation.kt index 88dda77b5..2640060b7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TrimTransformation.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/TrimTransformation.kt @@ -1,21 +1,13 @@ -package org.koitharu.kotatsu.core.ui.image +package org.koitharu.kotatsu.utils.image import android.graphics.Bitmap -import androidx.annotation.ColorInt -import androidx.core.graphics.alpha -import androidx.core.graphics.blue import androidx.core.graphics.get -import androidx.core.graphics.green -import androidx.core.graphics.red import coil.size.Size import coil.transform.Transformation -import kotlin.math.abs -class TrimTransformation( - private val tolerance: Int = 20, -) : Transformation { +class TrimTransformation : Transformation { - override val cacheKey: String = "${javaClass.name}-$tolerance" + override val cacheKey: String = javaClass.name override suspend fun transform(input: Bitmap, size: Size): Bitmap { var left = 0 @@ -28,7 +20,7 @@ class TrimTransformation( var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (input[x, y] != prevColor) { isColBlank = false break } @@ -47,7 +39,7 @@ class TrimTransformation( var isColBlank = true val prevColor = input[x, 0] for (y in 1 until input.height) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (input[x, y] != prevColor) { isColBlank = false break } @@ -63,7 +55,7 @@ class TrimTransformation( var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (input[x, y] != prevColor) { isRowBlank = false break } @@ -79,7 +71,7 @@ class TrimTransformation( var isRowBlank = true val prevColor = input[0, y] for (x in 1 until input.width) { - if (!isColorTheSame(input[x, y], prevColor)) { + if (input[x, y] != prevColor) { isRowBlank = false break } @@ -98,18 +90,7 @@ class TrimTransformation( } } - private fun isColorTheSame(@ColorInt a: Int, @ColorInt b: Int): Boolean { - return abs(a.red - b.red) <= tolerance && - abs(a.green - b.green) <= tolerance && - abs(a.blue - b.blue) <= tolerance && - abs(a.alpha - b.alpha) <= tolerance - } - - override fun equals(other: Any?): Boolean { - return this === other || (other is TrimTransformation && other.tolerance == tolerance) - } + override fun equals(other: Any?) = other is TrimTransformation - override fun hashCode(): Int { - return tolerance - } -} + override fun hashCode() = javaClass.hashCode() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt index 5b2d5bee8..317c77c5f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ImageRequestIndicatorListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.progress +package org.koitharu.kotatsu.utils.progress import coil.request.ErrorResult import coil.request.ImageRequest @@ -6,22 +6,14 @@ import coil.request.SuccessResult import com.google.android.material.progressindicator.BaseProgressIndicator class ImageRequestIndicatorListener( - private val indicators: Collection>, + private val indicator: BaseProgressIndicator<*>, ) : ImageRequest.Listener { - override fun onCancel(request: ImageRequest) = hide() + override fun onCancel(request: ImageRequest) = indicator.hide() - override fun onError(request: ImageRequest, result: ErrorResult) = hide() + override fun onError(request: ImageRequest, result: ErrorResult) = indicator.hide() - override fun onStart(request: ImageRequest) = show() + override fun onStart(request: ImageRequest) = indicator.show() - override fun onSuccess(request: ImageRequest, result: SuccessResult) = hide() - - private fun hide() { - indicators.forEach { it.hide() } - } - - private fun show() { - indicators.forEach { it.show() } - } -} + override fun onSuccess(request: ImageRequest, result: SuccessResult) = indicator.hide() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/IntPercentLabelFormatter.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt similarity index 87% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/IntPercentLabelFormatter.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt index e9882986d..5456ae5b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/IntPercentLabelFormatter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.progress +package org.koitharu.kotatsu.utils.progress import android.content.Context import com.google.android.material.slider.LabelFormatter @@ -9,4 +9,4 @@ class IntPercentLabelFormatter(context: Context) : LabelFormatter { private val pattern = context.getString(R.string.percent_string_pattern) override fun getFormattedValue(value: Float) = pattern.format(value.toInt().toString()) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt new file mode 100644 index 000000000..5723cae17 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.utils.progress + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Progress( + val value: Int, + val total: Int, +) : Parcelable, Comparable { + + override fun compareTo(other: Progress): Int { + return if (this.total == other.total) { + this.value.compareTo(other.value) + } else { + this.part().compareTo(other.part()) + } + } + + val isIndeterminate: Boolean + get() = total <= 0 + + private fun part() = if (isIndeterminate) -1.0 else value / total.toDouble() +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressDeferred.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt similarity index 87% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressDeferred.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt index c1bad74c6..7fd1a9357 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressDeferred.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressDeferred.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.progress +package org.koitharu.kotatsu.utils.progress import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.Flow @@ -13,4 +13,4 @@ class ProgressDeferred( get() = progress.value fun progressAsFlow(): Flow

= progress -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt new file mode 100644 index 000000000..d401fc83a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ProgressJob.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.utils.progress + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +class ProgressJob

( + private val job: Job, + private val progress: StateFlow

, +) : Job by job { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt similarity index 76% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt rename to app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt index e83507ef1..f998a5119 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/TimeLeftEstimator.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.util.progress +package org.koitharu.kotatsu.utils.progress import android.os.SystemClock import java.util.concurrent.TimeUnit @@ -19,9 +19,6 @@ class TimeLeftEstimator { emptyTick() return } - if (lastTick?.value == value) { - return - } val tick = Tick(value, total, SystemClock.elapsedRealtime()) lastTick?.let { val ticksCount = value - it.value @@ -45,14 +42,9 @@ class TimeLeftEstimator { return if (eta < tooLargeTime) eta else NO_TIME } - fun getEta(): Long { - val etl = getEstimatedTimeLeft() - return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl - } - private class Tick( - @JvmField val value: Int, - @JvmField val total: Int, - @JvmField val time: Long, + val value: Int, + val total: Int, + val time: Long, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt new file mode 100644 index 000000000..fda8aba02 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.widget + +import androidx.room.InvalidationTracker +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import org.koitharu.kotatsu.widget.shelf.ShelfConfigViewModel + +val appWidgetModule + get() = module { + + single { WidgetUpdater(androidContext()) } + + viewModel { ShelfConfigViewModel(get()) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/WidgetUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/WidgetUpdater.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt index c5fd21e71..185d4d5b2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/WidgetUpdater.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt @@ -5,20 +5,14 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import androidx.room.InvalidationTracker -import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class WidgetUpdater @Inject constructor( - @ApplicationContext private val context: Context, -) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) { +class WidgetUpdater(private val context: Context) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) { - override fun onInvalidated(tables: Set) { + override fun onInvalidated(tables: MutableSet) { if (TABLE_HISTORY in tables) { updateWidgets(RecentWidgetProvider::class.java) } @@ -35,4 +29,4 @@ class WidgetUpdater @Inject constructor( intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) context.sendBroadcast(intent) } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt similarity index 76% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt index 9dd997814..04c25f382 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt @@ -4,29 +4,28 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService -import androidx.core.graphics.drawable.toBitmap import coil.ImageLoader import coil.executeBlocking import coil.request.ImageRequest +import coil.size.Scale import coil.size.Size import coil.transform.RoundedCornersTransformation import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow -import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.base.domain.MangaIntent +import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.replaceWith +import org.koitharu.kotatsu.utils.ext.requireBitmap class RecentListFactory( private val context: Context, private val historyRepository: HistoryRepository, - private val coil: ImageLoader, + private val coil: ImageLoader ) : RemoteViewsService.RemoteViewsFactory { private val dataSet = ArrayList() private val transformation = RoundedCornersTransformation( - context.resources.getDimension(R.dimen.appwidget_corner_radius_inner), + context.resources.getDimension(R.dimen.appwidget_corner_radius_inner) ) private val coverSize = Size( context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), @@ -37,28 +36,28 @@ class RecentListFactory( override fun getLoadingView() = null - override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L + override fun getItemId(position: Int) = dataSet[position].id override fun onDataSetChanged() { + dataSet.clear() val data = runBlocking { historyRepository.getList(0, 10) } - dataSet.replaceWith(data) + dataSet.addAll(data) } override fun hasStableIds() = true override fun getViewAt(position: Int): RemoteViews { val views = RemoteViews(context.packageName, R.layout.item_recent) - val item = dataSet.getOrNull(position) ?: return views + val item = dataSet[position] runCatching { coil.executeBlocking( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) - .tag(item.source) - .tag(item) + .scale(Scale.FILL) .transformations(transformation) - .build(), - ).getDrawableOrThrow().toBitmap() + .build() + ).requireBitmap() }.onSuccess { cover -> views.setImageViewBitmap(R.id.imageView_cover, cover) }.onFailure { @@ -75,4 +74,4 @@ class RecentListFactory( override fun getViewTypeCount() = 1 override fun onDestroy() = Unit -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt new file mode 100644 index 000000000..a5c6bb748 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.widget.recent + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.utils.PendingIntentCompat + +class RecentWidgetProvider : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { id -> + val views = RemoteViews(context.packageName, R.layout.widget_recent) + val adapter = Intent(context, RecentWidgetService::class.java) + adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) + 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 + views.setPendingIntentTemplate( + R.id.stackView, PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE + ) + ) + views.setEmptyView(R.id.stackView, R.id.textView_holder) + appWidgetManager.updateAppWidget(id, views) + } + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.stackView) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt new file mode 100644 index 000000000..ccad1811d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.widget.recent + +import android.content.Intent +import android.widget.RemoteViewsService +import org.koin.android.ext.android.get + +class RecentWidgetService : RemoteViewsService() { + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return RecentListFactory(applicationContext, get(), get()) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt similarity index 62% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index aee292134..44a2cc632 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -6,69 +6,65 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewGroup -import androidx.activity.viewModels import androidx.core.graphics.Insets +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import dagger.hilt.android.AndroidEntryPoint +import com.google.android.material.snackbar.Snackbar +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.prefs.AppWidgetConfig -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.databinding.ActivityAppwidgetShelfBinding +import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding +import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem import com.google.android.material.R as materialR -@AndroidEntryPoint -class ShelfWidgetConfigActivity : - BaseActivity(), - OnListItemClickListener, - View.OnClickListener { +class ShelfConfigActivity : BaseActivity(), + OnListItemClickListener, View.OnClickListener { - private val viewModel by viewModels() + private val viewModel by viewModel() private lateinit var adapter: CategorySelectAdapter private lateinit var config: AppWidgetConfig override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivityAppwidgetShelfBinding.inflate(layoutInflater)) + setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) } adapter = CategorySelectAdapter(this) - viewBinding.recyclerView.adapter = adapter - viewBinding.buttonDone.setOnClickListener(this) + binding.recyclerView.adapter = adapter + binding.buttonDone.isVisible = true + binding.buttonDone.setOnClickListener(this) + binding.fabAdd.hide() val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID ) ?: AppWidgetManager.INVALID_APPWIDGET_ID if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finishAfterTransition() return } - config = AppWidgetConfig(this, ShelfWidgetProvider::class.java, appWidgetId) + config = AppWidgetConfig(this, appWidgetId) viewModel.checkedId = config.categoryId - viewBinding.switchBackground.isChecked = config.hasBackground viewModel.content.observe(this, this::onContentChanged) - viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) + viewModel.onError.observe(this, this::onError) } override fun onClick(v: View) { when (v.id) { R.id.button_done -> { config.categoryId = viewModel.checkedId - config.hasBackground = viewBinding.switchBackground.isChecked updateWidget() setResult( Activity.RESULT_OK, - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) ) finish() } @@ -80,15 +76,20 @@ class ShelfWidgetConfigActivity : } override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.recyclerView.updatePadding( + binding.fabAdd.updateLayoutParams { + rightMargin = topMargin + insets.right + leftMargin = topMargin + insets.left + bottomMargin = topMargin + insets.bottom + } + binding.recyclerView.updatePadding( left = insets.left, right = insets.right, - bottom = insets.bottom, + bottom = insets.bottom ) - with(viewBinding.toolbar) { + with(binding.toolbar) { updatePadding( left = insets.left, - right = insets.right, + right = insets.right ) updateLayoutParams { topMargin = insets.top @@ -100,6 +101,11 @@ class ShelfWidgetConfigActivity : adapter.items = categories } + private fun onError(e: Throwable) { + Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) + .show() + } + private fun updateWidget() { val intent = Intent(this, ShelfWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt similarity index 50% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt index 3a1b4deca..a9d5c09f6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigViewModel.kt @@ -1,29 +1,24 @@ package org.koitharu.kotatsu.widget.shelf import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel 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.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.widget.shelf.model.CategoryItem -import javax.inject.Inject +import java.util.* -@HiltViewModel -class ShelfConfigViewModel @Inject constructor( - favouritesRepository: FavouritesRepository, +class ShelfConfigViewModel( + favouritesRepository: FavouritesRepository ) : BaseViewModel() { private val selectedCategoryId = MutableStateFlow(0L) - val content: StateFlow> = combine( + val content = combine( favouritesRepository.observeCategories(), - selectedCategoryId, + selectedCategoryId ) { categories, selectedId -> val list = ArrayList(categories.size + 1) list += CategoryItem(0L, null, selectedId == 0L) @@ -31,11 +26,7 @@ class ShelfConfigViewModel @Inject constructor( CategoryItem(it.id, it.title, selectedId == it.id) } list - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) - var checkedId: Long - get() = selectedCategoryId.value - set(value) { - selectedCategoryId.value = value - } -} + var checkedId: Long by selectedCategoryId::value +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt similarity index 76% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt index 4f66f82b1..1676e5a49 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt @@ -4,21 +4,19 @@ import android.content.Context import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService -import androidx.core.graphics.drawable.toBitmap import coil.ImageLoader import coil.executeBlocking import coil.request.ImageRequest +import coil.size.Scale import coil.size.Size import coil.transform.RoundedCornersTransformation import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig -import org.koitharu.kotatsu.core.ui.image.TrimTransformation -import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.replaceWith +import org.koitharu.kotatsu.utils.ext.requireBitmap class ShelfListFactory( private val context: Context, @@ -28,9 +26,9 @@ class ShelfListFactory( ) : RemoteViewsService.RemoteViewsFactory { private val dataSet = ArrayList() - private val config = AppWidgetConfig(context, ShelfWidgetProvider::class.java, widgetId) + private val config = AppWidgetConfig(context, widgetId) private val transformation = RoundedCornersTransformation( - context.resources.getDimension(R.dimen.appwidget_corner_radius_inner), + context.resources.getDimension(R.dimen.appwidget_corner_radius_inner) ) private val coverSize = Size( context.resources.getDimensionPixelSize(R.dimen.widget_cover_width), @@ -41,9 +39,10 @@ class ShelfListFactory( override fun getLoadingView() = null - override fun getItemId(position: Int) = dataSet.getOrNull(position)?.id ?: 0L + override fun getItemId(position: Int) = dataSet[position].id override fun onDataSetChanged() { + dataSet.clear() val data = runBlocking { val category = config.categoryId if (category == 0L) { @@ -52,25 +51,24 @@ class ShelfListFactory( favouritesRepository.getManga(category) } } - dataSet.replaceWith(data) + dataSet.addAll(data) } override fun hasStableIds() = true override fun getViewAt(position: Int): RemoteViews { val views = RemoteViews(context.packageName, R.layout.item_shelf) - val item = dataSet.getOrNull(position) ?: return views + val item = dataSet[position] views.setTextViewText(R.id.textView_title, item.title) runCatching { coil.executeBlocking( ImageRequest.Builder(context) .data(item.coverUrl) .size(coverSize) - .tag(item.source) - .tag(item) - .transformations(transformation, TrimTransformation()) - .build(), - ).getDrawableOrThrow().toBitmap() + .scale(Scale.FILL) + .transformations(transformation) + .build() + ).requireBitmap() }.onSuccess { cover -> views.setImageViewBitmap(R.id.imageView_cover, cover) }.onFailure { diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt new file mode 100644 index 000000000..7b3ba2059 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.widget.shelf + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.utils.PendingIntentCompat + +class ShelfWidgetProvider : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { id -> + val views = RemoteViews(context.packageName, R.layout.widget_shelf) + val adapter = Intent(context, ShelfWidgetService::class.java) + adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id) + 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 + views.setPendingIntentTemplate( + R.id.gridView, PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_MUTABLE + ) + ) + views.setEmptyView(R.id.gridView, R.id.textView_holder) + appWidgetManager.updateAppWidget(id, views) + } + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.gridView) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt new file mode 100644 index 000000000..89d0a8862 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.widget.shelf + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.widget.RemoteViewsService +import org.koin.android.ext.android.get + +class ShelfWidgetService : RemoteViewsService() { + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + val widgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + return ShelfListFactory(applicationContext, get(), get(), widgetId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt new file mode 100644 index 000000000..39f90c22e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.widget.shelf.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.widget.shelf.model.CategoryItem + +class CategorySelectAdapter( + clickListener: OnListItemClickListener +) : AsyncListDifferDelegationAdapter(DiffCallback()) { + + init { + delegatesManager.addDelegate(categorySelectItemAD(clickListener)) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: CategoryItem, newItem: CategoryItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CategoryItem, newItem: CategoryItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: CategoryItem, newItem: CategoryItem): Any? { + if (oldItem.isSelected != newItem.isSelected) { + return newItem.isSelected + } + return super.getChangePayload(oldItem, newItem) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt similarity index 88% rename from app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt rename to app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt index 9de2ab3b8..ce4ded99d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectItemAD.kt @@ -2,14 +2,14 @@ package org.koitharu.kotatsu.widget.shelf.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.databinding.ItemCategoryCheckableSingleBinding import org.koitharu.kotatsu.widget.shelf.model.CategoryItem fun categorySelectItemAD( clickListener: OnListItemClickListener ) = adapterDelegateViewBinding( - { inflater, parent -> ItemCategoryCheckableSingleBinding.inflate(inflater, parent, false) }, + { inflater, parent -> ItemCategoryCheckableSingleBinding.inflate(inflater, parent, false) } ) { itemView.setOnClickListener { @@ -22,4 +22,4 @@ fun categorySelectItemAD( isChecked = item.isSelected } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt new file mode 100644 index 000000000..0407dd18a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.widget.shelf.model + +data class CategoryItem( + val id: Long, + val name: String?, + val isSelected: Boolean +) \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt deleted file mode 100644 index d38ee4f71..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.data - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Upsert -import kotlinx.coroutines.flow.Flow -import org.koitharu.kotatsu.core.db.entity.MangaWithTags - -@Dao -abstract class BookmarksDao { - - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") - abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity? - - @Query("SELECT * FROM bookmarks WHERE page_id = :pageId") - abstract suspend fun find(pageId: Long): BookmarkEntity? - - @Transaction - @Query( - "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", - ) - abstract suspend fun findAll(): Map> - - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent") - abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow - - @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent") - abstract fun observe(mangaId: Long): Flow> - - @Transaction - @Query( - "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", - ) - abstract fun observe(): Flow>> - - @Insert - abstract suspend fun insert(entity: BookmarkEntity) - - @Delete - abstract suspend fun delete(entity: BookmarkEntity) - - @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") - abstract suspend fun delete(mangaId: Long, pageId: Long): Int - - @Query("DELETE FROM bookmarks WHERE page_id = :pageId") - abstract suspend fun delete(pageId: Long): Int - - @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") - abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int - - @Upsert - abstract suspend fun upsert(bookmarks: Collection) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt deleted file mode 100644 index ad092b746..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.domain - -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.local.data.hasImageExtension -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import java.time.Instant - -data class Bookmark( - val manga: Manga, - val pageId: Long, - val chapterId: Long, - val page: Int, - val scroll: Int, - val imageUrl: String, - val createdAt: Instant, - val percent: Float, -) : ListModel { - - val directImageUrl: String? - get() = if (isImageUrlDirect()) imageUrl else null - - val imageLoadData: Any - get() = if (isImageUrlDirect()) imageUrl else toMangaPage() - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Bookmark && - manga.id == other.manga.id && - chapterId == other.chapterId && - page == other.page - } - - fun toMangaPage() = MangaPage( - id = pageId, - url = imageUrl, - preview = null, - source = manga.source, - ) - - private fun isImageUrlDirect(): Boolean { - return hasImageExtension(imageUrl) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt deleted file mode 100644 index 768fd4e5a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.domain - -import android.database.SQLException -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity -import org.koitharu.kotatsu.bookmarks.data.toBookmark -import org.koitharu.kotatsu.bookmarks.data.toBookmarks -import org.koitharu.kotatsu.bookmarks.data.toEntity -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.toEntities -import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -@Reusable -class BookmarksRepository @Inject constructor( - private val db: MangaDatabase, -) { - - fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow { - return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } - } - - fun observeBookmarks(manga: Manga): Flow> { - return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) } - } - - fun observeBookmarks(): Flow>> { - return db.getBookmarksDao().observe().map { map -> - val res = LinkedHashMap>(map.size) - for ((k, v) in map) { - val manga = k.toManga() - res[manga] = v.toBookmarks(manga) - } - res - } - } - - suspend fun addBookmark(bookmark: Bookmark) { - db.withTransaction { - val tags = bookmark.manga.tags.toEntities() - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(bookmark.manga.toEntity(), tags) - db.getBookmarksDao().insert(bookmark.toEntity()) - } - } - - suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) { - val entity = bookmark.toEntity().copy( - imageUrl = imageUrl, - ) - db.getBookmarksDao().upsert(listOf(entity)) - } - - suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { - check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) { - "Bookmark not found" - } - } - - suspend fun removeBookmark(bookmark: Bookmark) { - removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page) - } - - suspend fun removeBookmarks(ids: Set): ReversibleHandle { - val entities = ArrayList(ids.size) - db.withTransaction { - val dao = db.getBookmarksDao() - for (pageId in ids) { - val e = dao.find(pageId) - if (e != null) { - entities.add(e) - } - dao.delete(pageId) - } - } - return BookmarksRestorer(entities) - } - - private inner class BookmarksRestorer( - private val entities: Collection, - ) : ReversibleHandle { - - override suspend fun reverse() { - db.withTransaction { - for (e in entities) { - try { - db.getBookmarksDao().insert(e) - } catch (e: SQLException) { - e.printStackTraceDebug() - } - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt deleted file mode 100644 index 444ad0485..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner - -@AndroidEntryPoint -class BookmarksActivity : - BaseActivity(), - AppBarOwner, - SnackbarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override val snackbarHost: CoordinatorLayout - get() = viewBinding.root - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, BookmarksFragment::class.java, null) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt deleted file mode 100644 index 51cf7e009..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt +++ /dev/null @@ -1,216 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.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.MangaListSpanResolver -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.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 -class BookmarksFragment : - BaseFragment(), - ListStateHolderListener, - OnListItemClickListener, - ListSelectionController.Callback2, - FastScroller.FastScrollListener, ListHeaderClickListener { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - private val viewModel by viewModels() - private var bookmarksAdapter: BookmarksAdapter? = null - private var selectionController: ListSelectionController? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ): FragmentListSimpleBinding { - return FragmentListSimpleBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated( - binding: FragmentListSimpleBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - selectionController = ListSelectionController( - activity = requireActivity(), - decoration = BookmarksSelectionDecoration(binding.root.context), - registryOwner = this, - callback = this, - ) - bookmarksAdapter = BookmarksAdapter( - lifecycleOwner = viewLifecycleOwner, - coil = coil, - clickListener = this, - headerClickListener = this, - ) - val spanSizeLookup = SpanSizeLookup() - with(binding.recyclerView) { - setHasFixedSize(true) - val spanResolver = MangaListSpanResolver(resources) - addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = bookmarksAdapter - addOnLayoutChangeListener(spanResolver) - spanResolver.setGridSize(settings.gridSize / 100f, this) - val lm = GridLayoutManager(context, spanResolver.spanCount) - lm.spanSizeLookup = spanSizeLookup - layoutManager = lm - selectionController?.attachToRecyclerView(this) - } - viewModel.content.observe(viewLifecycleOwner) { - bookmarksAdapter?.setItems(it, spanSizeLookup) - } - viewModel.onError.observeEvent( - viewLifecycleOwner, - SnackbarErrorObserver(binding.recyclerView, this) - ) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - } - - override fun onDestroyView() { - super.onDestroyView() - bookmarksAdapter = null - selectionController = null - } - - override fun onItemClick(item: Bookmark, view: View) { - if (selectionController?.onItemClick(item.pageId) != true) { - val intent = ReaderActivity.IntentBuilder(view.context) - .bookmark(item) - .incognito(true) - .build() - startActivity(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)) - } - - override fun onItemLongClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemLongClick(item.pageId) ?: false - } - - override fun onRetryClick(error: Throwable) = Unit - - override fun onEmptyActionClick() = Unit - - override fun onFastScrollStart(fastScroller: FastScroller) { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - } - - override fun onFastScrollStop(fastScroller: FastScroller) = Unit - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - requireViewBinding().recyclerView.invalidateItemDecorations() - } - - override fun onCreateActionMode( - controller: ListSelectionController, - mode: ActionMode, - menu: Menu, - ): Boolean { - mode.menuInflater.inflate(R.menu.mode_bookmarks, menu) - return true - } - - override fun onActionItemClicked( - controller: ListSelectionController, - mode: ActionMode, - item: MenuItem, - ): Boolean { - return when (item.itemId) { - R.id.action_remove -> { - val ids = selectionController?.snapshot() ?: return false - viewModel.removeBookmarks(ids) - mode.finish() - true - } - - else -> false - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - rv.fastScroller.updateLayoutParams { - bottomMargin = insets.bottom - } - } - - private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable { - - init { - isSpanIndexCacheEnabled = true - isSpanGroupIndexCacheEnabled = true - } - - override fun getSpanSize(position: Int): Int { - val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount - ?: return 1 - return when (bookmarksAdapter?.getItemViewType(position)) { - ListItemType.PAGE_THUMB.ordinal -> 1 - else -> total - } - } - - override fun run() { - invalidateSpanGroupIndexCache() - invalidateSpanIndexCache() - } - } - - companion object { - - @Deprecated( - "", ReplaceWith( - "BookmarksFragment()", - "org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment" - ) - ) - fun newInstance() = BookmarksFragment() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt deleted file mode 100644 index 85886eed8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui - -import android.content.Context -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.util.ext.getItem -import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration - -class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) { - - override fun getItemId(parent: RecyclerView, child: View): Long { - val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID - val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID - return item.pageId - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt deleted file mode 100644 index f661ce4b2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository -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.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -@HiltViewModel -class BookmarksViewModel @Inject constructor( - private val repository: BookmarksRepository, -) : BaseViewModel() { - - val onActionDone = MutableEventFlow() - - val content: StateFlow> = repository.observeBookmarks() - .map { list -> - if (list.isEmpty()) { - listOf( - EmptyState( - icon = R.drawable.ic_empty_favourites, - textPrimary = R.string.no_bookmarks_yet, - textSecondary = R.string.no_bookmarks_summary, - actionStringRes = 0, - ), - ) - } else { - mapList(list) - } - } - .catch { e -> emit(listOf(e.toErrorState(canRetry = false))) } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - fun removeBookmarks(ids: Set) { - launchJob(Dispatchers.Default) { - val handle = repository.removeBookmarks(ids) - onActionDone.call(ReversibleAction(R.string.bookmarks_removed, handle)) - } - } - - private fun mapList(data: Map>): List { - val result = ArrayList(data.values.sumOf { it.size + 1 }) - for ((manga, bookmarks) in data) { - result.add(ListHeader(manga.title, R.string.more, manga)) - result.addAll(bookmarks) - } - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt deleted file mode 100644 index 9bec3962d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType - -class BookmarksAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt deleted file mode 100644 index 0cfc4122a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarkLargeAD.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.sheet - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.decodeRegion -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun bookmarkLargeAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) }, -) { - val listener = AdapterDelegateClickListenerAdapter(this, clickListener) - - binding.root.setOnClickListener(listener) - binding.root.setOnLongClickListener(listener) - - bind { - binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { - size(CoverSizeResolver(binding.imageViewThumb)) - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - tag(item) - decodeRegion(item.scroll) - source(item.manga.source) - enqueueWith(coil) - } - binding.progressView.percent = item.percent - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt deleted file mode 100644 index 93447eb35..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksAdapter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.sheet - -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD -import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD -import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD -import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel - -class BookmarksAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, - headerClickListener: ListHeaderClickListener?, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener)) - addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener)) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null)) - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - val list = items - for (i in (0..position).reversed()) { - val item = list.getOrNull(i) ?: continue - if (item is ListHeader) { - return item.getText(context) - } - } - return null - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt deleted file mode 100644 index 4fd65ebcd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheet.kt +++ /dev/null @@ -1,169 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.sheet - -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 androidx.recyclerview.widget.GridLayoutManager -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.prefs.AppSettings -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.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.plus -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.SheetPagesBinding -import org.koitharu.kotatsu.list.ui.MangaListSpanResolver -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.parsers.model.Manga -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener -import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail -import javax.inject.Inject -import kotlin.math.roundToInt - -@AndroidEntryPoint -class BookmarksSheet : - BaseAdaptiveSheet(), - AdaptiveSheetCallback, - OnListItemClickListener { - - private val viewModel by viewModels() - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - private var bookmarksAdapter: BookmarksAdapter? = null - private var spanResolver: MangaListSpanResolver? = null - - private val spanSizeLookup = SpanSizeLookup() - private val listCommitCallback = Runnable { - spanSizeLookup.invalidateCache() - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { - return SheetPagesBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - addSheetCallback(this) - spanResolver = MangaListSpanResolver(binding.root.resources) - bookmarksAdapter = BookmarksAdapter( - coil = coil, - lifecycleOwner = viewLifecycleOwner, - clickListener = this@BookmarksSheet, - headerClickListener = null, - ) - viewBinding?.headerBar?.setTitle(R.string.bookmarks) - with(binding.recyclerView) { - addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = bookmarksAdapter - addOnLayoutChangeListener(spanResolver) - spanResolver?.setGridSize(settings.gridSize / 100f, this) - (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup - } - viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged) - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - } - - override fun onDestroyView() { - spanResolver = null - bookmarksAdapter = null - spanSizeLookup.invalidateCache() - super.onDestroyView() - } - - override fun onItemClick(item: Bookmark, view: View) { - val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener) - if (listener != null) { - listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId)) - } else { - val intent = IntentBuilder(view.context) - .manga(viewModel.manga) - .bookmark(item) - .incognito(true) - .build() - startActivity(intent) - } - dismiss() - } - - override fun onStateChanged(sheet: View, newState: Int) { - viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED - } - - private fun onThumbnailsChanged(list: List) { - val adapter = bookmarksAdapter ?: return - if (adapter.itemCount == 0) { - var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } - if (position > 0) { - val spanCount = spanResolver?.spanCount ?: 0 - val offset = if (position > spanCount + 1) { - (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() - } else { - position = 0 - 0 - } - val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) - adapter.setItems(list, listCommitCallback + scrollCallback) - } else { - adapter.setItems(list, listCommitCallback) - } - } else { - adapter.setItems(list, listCommitCallback) - } - } - - private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { - - init { - isSpanIndexCacheEnabled = true - isSpanGroupIndexCacheEnabled = true - } - - override fun getSpanSize(position: Int): Int { - val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 - return when (bookmarksAdapter?.getItemViewType(position)) { - ListItemType.PAGE_THUMB.ordinal -> 1 - else -> total - } - } - - fun invalidateCache() { - invalidateSpanGroupIndexCache() - invalidateSpanIndexCache() - } - } - - companion object { - - const val ARG_MANGA = "manga" - - private const val TAG = "BookmarksSheet" - - fun show(fm: FragmentManager, manga: Manga) { - BookmarksSheet().withArgs(1) { - putParcelable(ARG_MANGA, ParcelableManga(manga)) - }.showDistinct(fm, TAG) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt deleted file mode 100644 index 5959bd7cf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/sheet/BookmarksSheetViewModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.sheet - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.parsers.util.SuspendLazy -import javax.inject.Inject - -@HiltViewModel -class BookmarksSheetViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - bookmarksRepository: BookmarksRepository, -) : BaseViewModel() { - - val manga = savedStateHandle.require(BookmarksSheet.ARG_MANGA).manga - private val chaptersLazy = SuspendLazy { - requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters) - } - - val content: StateFlow> = bookmarksRepository.observeBookmarks(manga) - .map { mapList(it) } - .withErrorHandling() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter())) - - private suspend fun mapList(bookmarks: List): List { - val chapters = chaptersLazy.get() - val bookmarksMap = bookmarks.groupBy { it.chapterId } - val result = ArrayList(bookmarks.size + bookmarksMap.size) - for (chapter in chapters) { - val b = bookmarksMap[chapter.id] - if (b.isNullOrEmpty()) { - continue - } - result += ListHeader(chapter.name) - result.addAll(b) - } - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/OnHistoryChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/OnHistoryChangedListener.kt deleted file mode 100644 index da23f6931..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/OnHistoryChangedListener.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.browser - -fun interface OnHistoryChangedListener { - - fun onHistoryChanged() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/WebViewBackPressedCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/WebViewBackPressedCallback.kt deleted file mode 100644 index dfe049040..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/WebViewBackPressedCallback.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.browser - -import android.webkit.WebView -import androidx.activity.OnBackPressedCallback - -class WebViewBackPressedCallback( - private val webView: WebView, -) : OnBackPressedCallback(false), OnHistoryChangedListener { - - init { - onHistoryChanged() - } - - override fun handleOnBackPressed() { - webView.goBack() - } - - override fun onHistoryChanged() { - isEnabled = webView.canGoBack() - } -} 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 deleted file mode 100644 index 2a61414e3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import android.content.Context -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.net.toUri -import coil.EventListener -import coil.request.ErrorResult -import coil.request.ImageRequest -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource - -class CaptchaNotifier( - private val context: Context, -) : EventListener { - - fun notify(exception: CloudFlareProtectedException) { - if (!context.checkNotificationPermission()) { - return - } - val manager = NotificationManagerCompat.from(context) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(context.getString(R.string.captcha_required)) - .setShowBadge(true) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - manager.createNotificationChannel(channel) - - val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers) - .setData(exception.url.toUri()) - val notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setContentTitle(channel.name) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setDefaults(NotificationCompat.DEFAULT_SOUND) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setAutoCancel(true) - .setVisibility( - if (exception.source?.contentType == ContentType.HENTAI) { - NotificationCompat.VISIBILITY_SECRET - } else { - NotificationCompat.VISIBILITY_PUBLIC - }, - ) - .setContentText( - context.getString( - R.string.captcha_required_summary, - exception.source?.title ?: context.getString(R.string.app_name), - ), - ) - .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) - .build() - manager.notify(TAG, exception.source.hashCode(), notification) - } - - fun dismiss(source: MangaSource) { - NotificationManagerCompat.from(context).cancel(TAG, source.hashCode()) - } - - override fun onError(request: ImageRequest, result: ErrorResult) { - super.onError(request, result) - val e = result.throwable - if (e is CloudFlareProtectedException && request.parameters.value(PARAM_IGNORE_CAPTCHA) != true) { - notify(e) - } - } - - companion object { - - fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter( - key = PARAM_IGNORE_CAPTCHA, - value = true, - memoryCacheKey = null, - ) - - private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha" - private const val CHANNEL_ID = "captcha" - private const val TAG = CHANNEL_ID - } -} 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 deleted file mode 100644 index caf926f30..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ /dev/null @@ -1,214 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.webkit.CookieManager -import androidx.activity.result.contract.ActivityResultContract -import androidx.core.graphics.Insets -import androidx.core.net.toUri -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.yield -import okhttp3.Headers -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.TaggedActivityResult -import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability -import org.koitharu.kotatsu.databinding.ActivityBrowserBinding -import org.koitharu.kotatsu.parsers.network.UserAgents -import javax.inject.Inject -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class CloudFlareActivity : BaseActivity(), CloudFlareCallback { - - private var pendingResult = RESULT_CANCELED - - @Inject - lateinit var cookieJar: MutableCookieJar - - private var onBackPressedCallback: WebViewBackPressedCallback? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (!catchingWebViewUnavailability { - setContentView( - ActivityBrowserBinding.inflate( - layoutInflater, - ), - ) - }) { - return - } - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } - val url = intent?.dataString.orEmpty() - with(viewBinding.webView.settings) { - javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE - } - viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url) - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { - onBackPressedDispatcher.addCallback(it) - } - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - if (savedInstanceState != null) { - return - } - if (url.isEmpty()) { - finishAfterTransition() - } else { - onTitleChanged(getString(R.string.loading_), url) - viewBinding.webView.loadUrl(url) - } - } - - override fun onDestroy() { - runCatching { - viewBinding.webView - }.onSuccess { - it.stopLoading() - it.destroy() - } - super.onDestroy() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - viewBinding.webView.saveState(outState) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - viewBinding.webView.restoreState(savedInstanceState) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.opt_captcha, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding( - top = insets.top, - ) - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - ) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> { - viewBinding.webView.stopLoading() - finishAfterTransition() - true - } - - R.id.action_retry -> { - lifecycleScope.launch { - viewBinding.webView.stopLoading() - yield() - val targetUrl = intent?.dataString?.toHttpUrlOrNull() - if (targetUrl != null) { - clearCfCookies(targetUrl) - viewBinding.webView.loadUrl(targetUrl.toString()) - } - } - true - } - - else -> super.onOptionsItemSelected(item) - } - - override fun onResume() { - super.onResume() - viewBinding.webView.onResume() - } - - override fun onPause() { - viewBinding.webView.onPause() - super.onPause() - } - - override fun finish() { - setResult(pendingResult) - super.finish() - } - - override fun onPageLoaded() { - viewBinding.progressBar.isInvisible = true - } - - override fun onCheckPassed() { - pendingResult = RESULT_OK - finishAfterTransition() - } - - override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading - } - - override fun onHistoryChanged() { - onBackPressedCallback?.onHistoryChanged() - } - - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { - setTitle(title) - supportActionBar?.subtitle = - subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle - } - - private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) { - cookieJar.removeCookies(url) { cookie -> - val name = cookie.name - name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") - } - } - - class Contract : ActivityResultContract, TaggedActivityResult>() { - override fun createIntent(context: Context, input: Pair): Intent { - return newIntent(context, input.first, input.second) - } - - override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult { - return TaggedActivityResult(TAG, resultCode) - } - } - - companion object { - - const val TAG = "CloudFlareActivity" - private const val ARG_UA = "ua" - - fun newIntent( - context: Context, - url: String, - headers: Headers?, - ) = Intent(context, CloudFlareActivity::class.java).apply { - data = url.toUri() - headers?.get(CommonHeaders.USER_AGENT)?.let { - putExtra(ARG_UA, it) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt deleted file mode 100644 index 978858604..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.browser.cloudflare - -import org.koitharu.kotatsu.browser.BrowserCallback - -interface CloudFlareCallback : BrowserCallback { - - override fun onLoadingStateChanged(isLoading: Boolean) = Unit - - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit - - fun onPageLoaded() - - fun onCheckPassed() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt deleted file mode 100644 index 8e1eaff43..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ /dev/null @@ -1,187 +0,0 @@ -package org.koitharu.kotatsu.core - -import android.app.Application -import android.content.Context -import android.provider.SearchRecentSuggestions -import android.text.Html -import androidx.collection.arraySetOf -import androidx.room.InvalidationTracker -import androidx.work.WorkManager -import coil.ComponentRegistry -import coil.ImageLoader -import coil.decode.SvgDecoder -import coil.disk.DiskCache -import coil.util.DebugLogger -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.ElementsIntoSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import okhttp3.OkHttpClient -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.cache.MemoryContentCache -import org.koitharu.kotatsu.core.cache.StubContentCache -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.network.ImageProxyInterceptor -import org.koitharu.kotatsu.core.network.MangaHttpClient -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher -import org.koitharu.kotatsu.core.ui.image.CoilImageGetter -import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle -import org.koitharu.kotatsu.core.util.AcraScreenLogger -import org.koitharu.kotatsu.core.util.IncognitoModeIndicator -import org.koitharu.kotatsu.core.util.ext.connectivityManager -import org.koitharu.kotatsu.core.util.ext.isLowRamDevice -import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.local.data.CbzFetcher -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor -import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher -import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider -import org.koitharu.kotatsu.settings.backup.BackupObserver -import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.widget.WidgetUpdater -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -interface AppModule { - - @Binds - fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext - - @Binds - fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter - - companion object { - - @Provides - @Singleton - fun provideNetworkState( - @ApplicationContext context: Context - ) = NetworkState(context.connectivityManager) - - @Provides - @Singleton - fun provideMangaDatabase( - @ApplicationContext context: Context, - ): MangaDatabase { - return MangaDatabase(context) - } - - @Provides - @Singleton - fun provideCoil( - @ApplicationContext context: Context, - @MangaHttpClient okHttpClient: OkHttpClient, - mangaRepositoryFactory: MangaRepository.Factory, - imageProxyInterceptor: ImageProxyInterceptor, - pageFetcherFactory: MangaPageFetcher.Factory, - coverRestoreInterceptor: CoverRestoreInterceptor, - ): ImageLoader { - val diskCacheFactory = { - val rootDir = context.externalCacheDir ?: context.cacheDir - DiskCache.Builder() - .directory(rootDir.resolve(CacheDir.THUMBS.dir)) - .build() - } - return ImageLoader.Builder(context) - .okHttpClient(okHttpClient.newBuilder().cache(null).build()) - .interceptorDispatcher(Dispatchers.Default) - .fetcherDispatcher(Dispatchers.IO) - .decoderDispatcher(Dispatchers.Default) - .transformationDispatcher(Dispatchers.Default) - .diskCache(diskCacheFactory) - .logger(if (BuildConfig.DEBUG) DebugLogger() else null) - .allowRgb565(context.isLowRamDevice()) - .eventListener(CaptchaNotifier(context)) - .components( - ComponentRegistry.Builder() - .add(SvgDecoder.Factory()) - .add(CbzFetcher.Factory()) - .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) - .add(pageFetcherFactory) - .add(imageProxyInterceptor) - .add(coverRestoreInterceptor) - .build(), - ).build() - } - - @Provides - fun provideSearchSuggestions( - @ApplicationContext context: Context, - ): SearchRecentSuggestions { - return MangaSuggestionsProvider.createSuggestions(context) - } - - @Provides - @ElementsIntoSet - fun provideDatabaseObservers( - widgetUpdater: WidgetUpdater, - appShortcutManager: AppShortcutManager, - backupObserver: BackupObserver, - syncController: SyncController, - ): Set<@JvmSuppressWildcards InvalidationTracker.Observer> = arraySetOf( - widgetUpdater, - appShortcutManager, - backupObserver, - syncController, - ) - - @Provides - @ElementsIntoSet - fun provideActivityLifecycleCallbacks( - appProtectHelper: AppProtectHelper, - activityRecreationHandle: ActivityRecreationHandle, - incognitoModeIndicator: IncognitoModeIndicator, - acraScreenLogger: AcraScreenLogger, - ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( - appProtectHelper, - activityRecreationHandle, - incognitoModeIndicator, - acraScreenLogger, - ) - - @Provides - @Singleton - fun provideContentCache( - application: Application, - ): ContentCache { - return if (application.isLowRamDevice()) { - StubContentCache() - } else { - MemoryContentCache(application) - } - } - - @Provides - @Singleton - @LocalStorageChanges - fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow() - - @Provides - @LocalStorageChanges - fun provideLocalStorageChangesFlow( - @LocalStorageChanges flow: MutableSharedFlow, - ): SharedFlow = flow.asSharedFlow() - - @Provides - fun provideWorkManager( - @ApplicationContext context: Context, - ): WorkManager = WorkManager.getInstance(context) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt deleted file mode 100644 index 4082d125a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.koitharu.kotatsu.core - -import android.app.Application -import android.content.Context -import androidx.annotation.WorkerThread -import androidx.appcompat.app.AppCompatDelegate -import androidx.hilt.work.HiltWorkerFactory -import androidx.room.InvalidationTracker -import androidx.work.Configuration -import androidx.work.WorkManager -import dagger.hilt.android.HiltAndroidApp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.acra.ACRA -import org.acra.ReportField -import org.acra.config.dialog -import org.acra.config.httpSender -import org.acra.data.StringFormat -import org.acra.ktx.initAcra -import org.acra.sender.HttpSender -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.prefs.AppSettings -import org.koitharu.kotatsu.core.util.WorkServiceStopHelper -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.settings.work.WorkScheduleManager -import javax.inject.Inject -import javax.inject.Provider - -@HiltAndroidApp -open class BaseApp : Application(), Configuration.Provider { - - @Inject - lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer> - - @Inject - lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> - - @Inject - lateinit var database: Provider - - @Inject - lateinit var settings: AppSettings - - @Inject - lateinit var workerFactory: HiltWorkerFactory - - @Inject - lateinit var appValidator: AppValidator - - @Inject - lateinit var workScheduleManager: Provider - - @Inject - lateinit var workManagerProvider: Provider - - override val workManagerConfiguration: Configuration - get() = Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - - override fun onCreate() { - super.onCreate() - AppCompatDelegate.setDefaultNightMode(settings.theme) - AppCompatDelegate.setApplicationLocales(settings.appLocales) - setupActivityLifecycleCallbacks() - processLifecycleScope.launch { - val isOriginalApp = withContext(Dispatchers.Default) { - appValidator.isOriginalApp - } - ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString()) - } - processLifecycleScope.launch(Dispatchers.Default) { - setupDatabaseObservers() - } - workScheduleManager.get().init() - WorkServiceStopHelper(workManagerProvider).setup() - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - initAcra { - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - httpSender { - uri = getString(R.string.url_error_report) - basicAuthLogin = getString(R.string.acra_login) - basicAuthPassword = getString(R.string.acra_password) - httpMethod = HttpSender.Method.POST - } - reportContent = listOf( - ReportField.PACKAGE_NAME, - ReportField.INSTALLATION_ID, - ReportField.APP_VERSION_CODE, - ReportField.APP_VERSION_NAME, - ReportField.ANDROID_VERSION, - ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ReportField.CRASH_CONFIGURATION, - ReportField.CUSTOM_DATA, - ) - - dialog { - text = getString(R.string.crash_text) - title = getString(R.string.error_occurred) - positiveButtonText = getString(R.string.send) - resIcon = R.drawable.ic_alert_outline - resTheme = android.R.style.Theme_Material_Light_Dialog_Alert - } - } - } - - @WorkerThread - private fun setupDatabaseObservers() { - val tracker = database.get().invalidationTracker - databaseObservers.forEach { - tracker.addObserver(it) - } - } - - private fun setupActivityLifecycleCallbacks() { - activityLifecycleCallbacks.forEach { - registerActivityLifecycleCallbacks(it) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt deleted file mode 100644 index 7d190422e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.core - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.core.app.PendingIntentCompat -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -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 - 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 { - val intent = Intent(context, ErrorReporterReceiver::class.java) - intent.setAction(ACTION_REPORT) - intent.setData(Uri.parse("err://${e.hashCode()}")) - intent.putExtra(EXTRA_ERROR, e) - return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReportingAdmin.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReportingAdmin.kt deleted file mode 100644 index e088254f8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReportingAdmin.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.core - -import android.content.Context -import com.google.auto.service.AutoService -import org.acra.builder.ReportBuilder -import org.acra.config.CoreConfiguration -import org.acra.config.ReportingAdministrator - -@AutoService(ReportingAdministrator::class) -class ErrorReportingAdmin : ReportingAdministrator { - - override fun shouldStartCollecting( - context: Context, - config: CoreConfiguration, - reportBuilder: ReportBuilder - ): Boolean { - return reportBuilder.exception?.isDeadOs() != true - } - - private fun Throwable.isDeadOs(): Boolean { - val className = javaClass.simpleName - return className == "DeadSystemException" || className == "DeadSystemRuntimeException" || cause?.isDeadOs() == true - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt deleted file mode 100644 index ae92bff4e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import org.json.JSONArray - -class BackupEntry( - val name: Name, - val data: JSONArray -) { - - enum class Name( - val key: String, - ) { - - INDEX("index"), - HISTORY("history"), - CATEGORIES("categories"), - FAVOURITES("favourites"), - SETTINGS("settings"), - BOOKMARKS("bookmarks"), - SOURCES("sources"), - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt deleted file mode 100644 index 2071ae6d9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ /dev/null @@ -1,224 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import androidx.room.withTransaction -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.parsers.util.json.JSONIterator -import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.Date -import javax.inject.Inject - -private const val PAGE_SIZE = 10 - -class BackupRepository @Inject constructor( - private val db: MangaDatabase, - private val settings: AppSettings, -) { - - suspend fun dumpHistory(): BackupEntry { - var offset = 0 - val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) - while (true) { - val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) - if (history.isEmpty()) { - break - } - offset += history.size - for (item in history) { - val manga = JsonSerializer(item.manga).toJson() - val tags = JSONArray() - item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - manga.put("tags", tags) - val json = JsonSerializer(item.history).toJson() - json.put("manga", manga) - entry.data.put(json) - } - } - return entry - } - - suspend fun dumpCategories(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) - val categories = db.getFavouriteCategoriesDao().findAll() - for (item in categories) { - entry.data.put(JsonSerializer(item).toJson()) - } - return entry - } - - suspend fun dumpFavourites(): BackupEntry { - var offset = 0 - val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) - while (true) { - val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE) - if (favourites.isEmpty()) { - break - } - offset += favourites.size - for (item in favourites) { - val manga = JsonSerializer(item.manga).toJson() - val tags = JSONArray() - item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - manga.put("tags", tags) - val json = JsonSerializer(item.favourite).toJson() - json.put("manga", manga) - entry.data.put(json) - } - } - return entry - } - - suspend fun dumpBookmarks(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) - val all = db.getBookmarksDao().findAll() - for ((m, b) in all) { - val json = JSONObject() - val manga = JsonSerializer(m.manga).toJson() - json.put("manga", manga) - val tags = JSONArray() - m.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - json.put("tags", tags) - val bookmarks = JSONArray() - b.forEach { bookmarks.put(JsonSerializer(it).toJson()) } - json.put("bookmarks", bookmarks) - entry.data.put(json) - } - return entry - } - - fun dumpSettings(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray()) - val settingsDump = settings.getAllValues().toMutableMap() - settingsDump.remove(AppSettings.KEY_APP_PASSWORD) - settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) - settingsDump.remove(AppSettings.KEY_PROXY_LOGIN) - settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE) - val json = JsonSerializer(settingsDump).toJson() - entry.data.put(json) - return entry - } - - suspend fun dumpSources(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) - val all = db.getSourcesDao().findAll() - for (source in all) { - val json = JsonSerializer(source).toJson() - entry.data.put(json) - } - return entry - } - - fun createIndex(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) - val json = JSONObject() - json.put("app_id", BuildConfig.APPLICATION_ID) - json.put("app_version", BuildConfig.VERSION_CODE) - json.put("created_at", System.currentTimeMillis()) - entry.data.put(json) - return entry - } - - fun getBackupDate(entry: BackupEntry?): Date? { - val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0 - return if (timestamp == 0L) null else Date(timestamp) - } - - suspend fun restoreHistory(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = mangaJson.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val history = JsonDeserializer(item).toHistoryEntity() - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getHistoryDao().upsert(history) - } - } - } - return result - } - - suspend fun restoreCategories(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - val category = JsonDeserializer(item).toFavouriteCategoryEntity() - result += runCatchingCancellable { - db.getFavouriteCategoriesDao().upsert(category) - } - } - return result - } - - suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = mangaJson.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val favourite = JsonDeserializer(item).toFavouriteEntity() - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getFavouritesDao().upsert(favourite) - } - } - } - return result - } - - suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = item.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val bookmarks = item.getJSONArray("bookmarks").mapJSON { - JsonDeserializer(it).toBookmarkEntity() - } - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getBookmarksDao().upsert(bookmarks) - } - } - } - return result - } - - suspend fun restoreSources(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - val source = JsonDeserializer(item).toMangaSourceEntity() - result += runCatchingCancellable { - db.getSourcesDao().upsert(source) - } - } - return result - } - - fun restoreSettings(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.JSONIterator()) { - result += runCatchingCancellable { - settings.upsertAll(JsonDeserializer(item).toMap()) - } - } - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt deleted file mode 100644 index 61da0c264..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible -import okio.Closeable -import org.json.JSONArray -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import java.io.File -import java.util.EnumSet -import java.util.zip.ZipFile - -class BackupZipInput(val file: File) : Closeable { - - private val zipFile = ZipFile(file) - - suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { - val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null - val json = zipFile.getInputStream(entry).use { - JSONArray(it.bufferedReader().readText()) - } - BackupEntry(name, json) - } - - suspend fun entries(): Set = runInterruptible(Dispatchers.IO) { - zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze -> - BackupEntry.Name.entries.find { it.key == ze.name } - } - } - - override fun close() { - zipFile.close() - } - - fun cleanupAsync() { - processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { - runCatching { - close() - file.delete() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt deleted file mode 100644 index 0e42ddde2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource - -interface ContentCache { - - val isCachingEnabled: Boolean - - suspend fun getDetails(source: MangaSource, url: String): Manga? - - fun putDetails(source: MangaSource, url: String, details: SafeDeferred) - - suspend fun getPages(source: MangaSource, url: String): List? - - fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) - - suspend fun getRelatedManga(source: MangaSource, url: String): List? - - fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) - - data class Key( - val source: MangaSource, - val url: String, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt deleted file mode 100644 index aa9465c32..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import androidx.collection.LruCache -import java.util.concurrent.TimeUnit - -class ExpiringLruCache( - val maxSize: Int, - private val lifetime: Long, - private val timeUnit: TimeUnit, -) { - - private val cache = LruCache>(maxSize) - - operator fun get(key: ContentCache.Key): T? { - val value = cache[key] ?: return null - if (value.isExpired) { - cache.remove(key) - } - return value.get() - } - - operator fun set(key: ContentCache.Key, value: T) { - cache.put(key, ExpiringValue(value, lifetime, timeUnit)) - } - - fun clear() { - cache.evictAll() - } - - fun trimToSize(size: Int) { - cache.trimToSize(size) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt deleted file mode 100644 index 2d561bb0c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringValue.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import android.os.SystemClock -import java.util.concurrent.TimeUnit - -class ExpiringValue( - private val value: T, - lifetime: Long, - timeUnit: TimeUnit, -) { - - private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime) - - val isExpired: Boolean - get() = SystemClock.elapsedRealtime() >= expiresAt - - fun get(): T? = if (isExpired) null else value - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ExpiringValue<*> - - if (value != other.value) return false - return expiresAt == other.expiresAt - } - - override fun hashCode(): Int { - var result = value?.hashCode() ?: 0 - result = 31 * result + expiresAt.hashCode() - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt deleted file mode 100644 index 986b61947..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import android.app.Application -import android.content.ComponentCallbacks2 -import android.content.res.Configuration -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import java.util.concurrent.TimeUnit - -class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { - - init { - application.registerComponentCallbacks(this) - } - - private val detailsCache = ExpiringLruCache>(4, 5, TimeUnit.MINUTES) - private val pagesCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) - private val relatedMangaCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) - - override val isCachingEnabled: Boolean = true - - override suspend fun getDetails(source: MangaSource, url: String): Manga? { - return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull() - } - - override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) { - detailsCache[ContentCache.Key(source, url)] = details - } - - override suspend fun getPages(source: MangaSource, url: String): List? { - return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() - } - - override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) { - pagesCache[ContentCache.Key(source, url)] = pages - } - - override suspend fun getRelatedManga(source: MangaSource, url: String): List? { - return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull() - } - - override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) { - relatedMangaCache[ContentCache.Key(source, url)] = related - } - - override fun onConfigurationChanged(newConfig: Configuration) = Unit - - override fun onLowMemory() = Unit - - override fun onTrimMemory(level: Int) { - trimCache(detailsCache, level) - trimCache(pagesCache, level) - trimCache(relatedMangaCache, level) - } - - private fun trimCache(cache: ExpiringLruCache<*>, level: Int) { - when (level) { - ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, - ComponentCallbacks2.TRIM_MEMORY_COMPLETE, - ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear() - - ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, - ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, - ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) - - else -> cache.trimToSize(cache.maxSize / 2) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt deleted file mode 100644 index 24b2c48d4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/SafeDeferred.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import kotlinx.coroutines.Deferred - -class SafeDeferred( - private val delegate: Deferred>, -) { - - suspend fun await(): T { - return delegate.await().getOrThrow() - } - - suspend fun awaitOrNull(): T? { - return delegate.await().getOrNull() - } - - fun cancel() { - delegate.cancel() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt deleted file mode 100644 index ade77ea97..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.core.cache - -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource - -class StubContentCache : ContentCache { - - override val isCachingEnabled: Boolean = false - - override suspend fun getDetails(source: MangaSource, url: String): Manga? = null - - override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) = Unit - - override suspend fun getPages(source: MangaSource, url: String): List? = null - - override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) = Unit - - override suspend fun getRelatedManga(source: MangaSource, url: String): List? = null - - override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) = Unit -} 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 deleted file mode 100644 index 5565f2c58..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ /dev/null @@ -1,128 +0,0 @@ -package org.koitharu.kotatsu.core.db - -import android.content.Context -import androidx.room.Database -import androidx.room.InvalidationTracker -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -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.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.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity -import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity -import org.koitharu.kotatsu.core.db.entity.TagEntity -import org.koitharu.kotatsu.core.db.migrations.Migration10To11 -import org.koitharu.kotatsu.core.db.migrations.Migration11To12 -import org.koitharu.kotatsu.core.db.migrations.Migration12To13 -import org.koitharu.kotatsu.core.db.migrations.Migration13To14 -import org.koitharu.kotatsu.core.db.migrations.Migration14To15 -import org.koitharu.kotatsu.core.db.migrations.Migration15To16 -import org.koitharu.kotatsu.core.db.migrations.Migration16To17 -import org.koitharu.kotatsu.core.db.migrations.Migration17To18 -import org.koitharu.kotatsu.core.db.migrations.Migration1To2 -import org.koitharu.kotatsu.core.db.migrations.Migration2To3 -import org.koitharu.kotatsu.core.db.migrations.Migration3To4 -import org.koitharu.kotatsu.core.db.migrations.Migration4To5 -import org.koitharu.kotatsu.core.db.migrations.Migration5To6 -import org.koitharu.kotatsu.core.db.migrations.Migration6To7 -import org.koitharu.kotatsu.core.db.migrations.Migration7To8 -import org.koitharu.kotatsu.core.db.migrations.Migration8To9 -import org.koitharu.kotatsu.core.db.migrations.Migration9To10 -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao -import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity -import org.koitharu.kotatsu.favourites.data.FavouriteEntity -import org.koitharu.kotatsu.favourites.data.FavouritesDao -import org.koitharu.kotatsu.history.data.HistoryDao -import org.koitharu.kotatsu.history.data.HistoryEntity -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity -import org.koitharu.kotatsu.suggestions.data.SuggestionDao -import org.koitharu.kotatsu.suggestions.data.SuggestionEntity -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 = 18 - -@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, - ], - version = DATABASE_VERSION, -) -abstract class MangaDatabase : RoomDatabase() { - - abstract fun getHistoryDao(): HistoryDao - - abstract fun getTagsDao(): TagsDao - - abstract fun getMangaDao(): MangaDao - - abstract fun getFavouritesDao(): FavouritesDao - - abstract fun getPreferencesDao(): PreferencesDao - - abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao - - abstract fun getTracksDao(): TracksDao - - abstract fun getTrackLogsDao(): TrackLogsDao - - abstract fun getSuggestionDao(): SuggestionDao - - abstract fun getBookmarksDao(): BookmarksDao - - abstract fun getScrobblingDao(): ScrobblingDao - - abstract fun getSourcesDao(): MangaSourcesDao -} - -fun getDatabaseMigrations(context: Context): Array = arrayOf( - Migration1To2(), - Migration2To3(), - Migration3To4(), - Migration4To5(), - Migration5To6(), - Migration6To7(), - Migration7To8(), - Migration8To9(), - Migration9To10(), - Migration10To11(), - Migration11To12(), - Migration12To13(), - Migration13To14(), - Migration14To15(), - Migration15To16(), - Migration16To17(context), - Migration17To18(), -) - -fun MangaDatabase(context: Context): MangaDatabase = Room - .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db") - .addMigrations(*getDatabaseMigrations(context)) - .addCallback(DatabasePrePopulateCallback(context.resources)) - .build() - -fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) { - val scope = processLifecycleScope - if (scope.isActive) { - processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { - removeObserver(observer) - } - } -} 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 deleted file mode 100644 index 7ee5567b0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.core.db.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import androidx.room.Upsert -import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity -import org.koitharu.kotatsu.core.db.entity.MangaWithTags -import org.koitharu.kotatsu.core.db.entity.TagEntity - -@Dao -abstract class MangaDao { - - @Transaction - @Query("SELECT * FROM manga WHERE manga_id = :id") - abstract suspend fun find(id: Long): MangaWithTags? - - @Transaction - @Query("SELECT * FROM manga WHERE public_url = :publicUrl") - abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? - - @Transaction - @Query("SELECT * FROM manga WHERE source = :source") - abstract suspend fun findAllBySource(source: String): List - - @Transaction - @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") - abstract suspend fun searchByTitle(query: String, limit: Int): List - - @Transaction - @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit") - abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List - - @Upsert - abstract suspend fun upsert(manga: MangaEntity) - - @Update(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun update(manga: MangaEntity): Int - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long - - @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") - abstract suspend fun clearTagRelation(mangaId: Long) - - @Transaction - @Delete - abstract suspend fun delete(subjects: Collection) - - @Transaction - open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) { - upsert(manga) - if (tags != null) { - clearTagRelation(manga.id) - tags.map { - MangaTagsEntity(manga.id, it.id) - }.forEach { - insertTagRelation(it) - } - } - } -} 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 deleted file mode 100644 index 9401d5030..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ /dev/null @@ -1,90 +0,0 @@ -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.RawQuery -import androidx.room.Transaction -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.core.db.entity.MangaSourceEntity -import org.koitharu.kotatsu.explore.data.SourcesSortOrder - -@Dao -abstract class MangaSourcesDao { - - @Query("SELECT * FROM sources ORDER BY sort_key") - abstract suspend fun findAll(): List - - @Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key") - abstract suspend fun findAllDisabled(): List - - @Query("SELECT * FROM sources WHERE enabled = 0") - abstract fun observeDisabled(): Flow> - - @Query("SELECT * FROM sources ORDER BY sort_key") - abstract fun observeAll(): Flow> - - @Query("SELECT IFNULL(MAX(sort_key),0) FROM sources") - abstract suspend fun getMaxSortKey(): Int - - @Query("UPDATE sources SET enabled = 0") - abstract suspend fun disableAllSources() - - @Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source") - abstract suspend fun setSortKey(source: String, sortKey: Int) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - @Transaction - abstract suspend fun insertIfAbsent(entries: Collection) - - @Upsert - abstract suspend fun upsert(entry: MangaSourceEntity) - - fun observeEnabled(order: SourcesSortOrder): Flow> { - val orderBy = getOrderBy(order) - - @Language("RoomSql") - val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $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 $orderBy") - return findAllImpl(query) - } - - @Transaction - open suspend fun setEnabled(source: String, isEnabled: Boolean) { - if (updateIsEnabled(source, isEnabled) == 0) { - val entity = MangaSourceEntity( - source = source, - isEnabled = isEnabled, - sortKey = getMaxSortKey() + 1, - ) - upsert(entity) - } - } - - @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") - protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int - - @RawQuery(observedEntities = [MangaSourceEntity::class]) - protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow> - - @RawQuery - protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List - - private fun getOrderBy(order: SourcesSortOrder) = when (order) { - SourcesSortOrder.ALPHABETIC -> "source ASC" - SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC" - SourcesSortOrder.MANUAL -> "sort_key ASC" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt deleted file mode 100644 index d79b37b3a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.core.db.dao - -import androidx.room.* -import kotlinx.coroutines.flow.Flow -import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity - -@Dao -abstract class PreferencesDao { - - @Query("SELECT * FROM preferences WHERE manga_id = :mangaId") - abstract suspend fun find(mangaId: Long): MangaPrefsEntity? - - @Query("SELECT * FROM preferences WHERE manga_id = :mangaId") - abstract fun observe(mangaId: Long): Flow - - @Upsert - abstract suspend fun upsert(pref: MangaPrefsEntity) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt deleted file mode 100644 index baf63c241..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.koitharu.kotatsu.core.db.dao - -import androidx.room.Dao -import androidx.room.Query -import androidx.room.Upsert -import org.koitharu.kotatsu.core.db.entity.TagEntity - -@Dao -abstract class TagsDao { - - @Query("SELECT * FROM tags WHERE source = :source") - abstract suspend fun findTags(source: String): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites) - GROUP BY tags.title - ORDER BY COUNT(manga_id) DESC - LIMIT :limit""", - ) - abstract suspend fun findPopularTags(limit: Int): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - WHERE tags.source = :source - GROUP BY tags.title - ORDER BY COUNT(manga_id) DESC - LIMIT :limit""", - ) - abstract suspend fun findPopularTags(source: String, limit: Int): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - WHERE tags.source = :source - GROUP BY tags.title - ORDER BY COUNT(manga_id) ASC - LIMIT :limit""", - ) - abstract suspend fun findRareTags(source: String, limit: Int): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - WHERE tags.source = :source AND title LIKE :query - GROUP BY tags.title - ORDER BY COUNT(manga_id) DESC - LIMIT :limit""", - ) - abstract suspend fun findTags(source: String, query: String, limit: Int): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites) - GROUP BY tags.title - ORDER BY COUNT(manga_id) DESC - LIMIT :limit""", - ) - abstract suspend fun findTags(query: String, limit: Int): List - - @Query( - """ - SELECT tags.* FROM manga_tags - LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id - WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId) - GROUP BY tags.tag_id - ORDER BY COUNT(manga_id) DESC; - """, - ) - abstract suspend fun findRelatedTags(tagId: Long): List - - @Query( - """ - SELECT tags.* FROM manga_tags - LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id - WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids)) - GROUP BY tags.tag_id - ORDER BY COUNT(manga_id) DESC; - """, - ) - abstract suspend fun findRelatedTags(ids: Set): List - - @Upsert - abstract suspend fun upsert(tags: Iterable) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt deleted file mode 100644 index 18fe1db04..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.core.db.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -@Entity( - tableName = "preferences", - foreignKeys = [ - ForeignKey( - entity = MangaEntity::class, - parentColumns = ["manga_id"], - childColumns = ["manga_id"], - onDelete = ForeignKey.CASCADE, - ), - ], -) -data class MangaPrefsEntity( - @PrimaryKey(autoGenerate = false) - @ColumnInfo(name = "manga_id") - val mangaId: Long, - @ColumnInfo(name = "mode") val mode: Int, - @ColumnInfo(name = "cf_brightness") val cfBrightness: Float, - @ColumnInfo(name = "cf_contrast") val cfContrast: Float, - @ColumnInfo(name = "cf_invert") val cfInvert: Boolean, - @ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt deleted file mode 100644 index 00243e8df..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaSourceEntity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.koitharu.kotatsu.core.db.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.TABLE_SOURCES - -@Entity( - tableName = TABLE_SOURCES, -) -data class MangaSourceEntity( - @PrimaryKey(autoGenerate = false) - @ColumnInfo(name = "source") - val source: String, - @ColumnInfo(name = "enabled") val isEnabled: Boolean, - @ColumnInfo(name = "sort_key", index = true) val sortKey: Int, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt deleted file mode 100644 index 228e1b181..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration12To13 : Migration(12, 13) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1") - db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt deleted file mode 100644 index 11c961ec3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration13To14 : Migration(13, 14) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration14To15.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration14To15.kt deleted file mode 100644 index 3089222f5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration14To15.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration14To15 : Migration(14, 15) { - - override fun migrate(db: SupportSQLiteDatabase) = Unit -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt deleted file mode 100644 index 3e6783460..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration15To16.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration15To16 : Migration(15, 16) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt deleted file mode 100644 index e49a9b1ee..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration16To17.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import android.content.Context -import androidx.preference.PreferenceManager -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import org.koitharu.kotatsu.parsers.model.MangaSource - -class Migration16To17(context: Context) : Migration(16, 17) { - - private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))") - db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") - val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() - val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() - val sources = MangaSource.entries - for (source in sources) { - if (source == MangaSource.LOCAL) { - continue - } - val name = source.name - val isHidden = name in hiddenSources - var sortKey = order.indexOf(name) - if (sortKey == -1) { - if (isHidden) { - sortKey = order.size + source.ordinal - } else { - continue - } - } - db.execSQL( - "INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)", - arrayOf(name, (!isHidden).toInt(), sortKey), - ) - } - } - - private fun Boolean.toInt() = if (this) 1 else 0 -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt deleted file mode 100644 index e80e77b97..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration17To18 : Migration(17, 18) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt deleted file mode 100644 index 139e27191..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration3To4 : Migration(3, 4) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt deleted file mode 100644 index aeb7acc18..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration5To6 : Migration(5, 6) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt deleted file mode 100644 index c4471960b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.db.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Migration7To8 : Migration(7, 8) { - - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0") - db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )") - db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt deleted file mode 100644 index 23b2523a0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions - -import okhttp3.Headers -import okio.IOException -import org.koitharu.kotatsu.parsers.model.MangaSource - -class CloudFlareProtectedException( - val url: String, - val source: MangaSource?, - @Transient val headers: Headers, -) : IOException("Protected by CloudFlare") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/SyncApiException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/SyncApiException.kt deleted file mode 100644 index b314d1b59..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/SyncApiException.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions - -class SyncApiException( - message: String, - val code: Int, -) : RuntimeException(message) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/TooManyRequestExceptions.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/TooManyRequestExceptions.kt deleted file mode 100644 index 6e99991f6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/TooManyRequestExceptions.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions - -import okio.IOException -import java.time.Instant -import java.time.temporal.ChronoUnit - -class TooManyRequestExceptions( - val url: String, - val retryAt: Instant?, -) : IOException() { - val retryAfter: Long - get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0 -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt deleted file mode 100644 index 3631b6106..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions - -class WrongPasswordException : IllegalArgumentException() 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 deleted file mode 100644 index 1edf3b662..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions.resolve - -import android.content.DialogInterface -import android.view.View -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.parsers.exception.ParseException - -class DialogErrorObserver( - host: View, - fragment: Fragment?, - resolver: ExceptionResolver?, - private val onResolved: Consumer?, -) : ErrorObserver(host, fragment, resolver, onResolved) { - - constructor( - host: View, - fragment: Fragment?, - ) : this(host, fragment, null, null) - - override suspend fun emit(value: Throwable) { - val listener = DialogListener(value) - val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context) - .setMessage(value.getDisplayMessage(host.context.resources)) - .setNegativeButton(R.string.close, listener) - .setOnCancelListener(listener) - if (canResolve(value)) { - dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) - } else if (value is ParseException) { - val fm = fragmentManager - if (fm != null) { - dialogBuilder.setPositiveButton(R.string.details) { _, _ -> - ErrorDetailsDialog.show(fm, value, value.url) - } - } - } - val dialog = dialogBuilder.create() - if (activity != null) { - dialog.setOwnerActivity(activity) - } - dialog.show() - } - - private inner class DialogListener( - private val error: Throwable, - ) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener { - - override fun onClick(dialog: DialogInterface?, which: Int) { - when (which) { - DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false) - DialogInterface.BUTTON_POSITIVE -> resolve(error) - } - } - - override fun onCancel(dialog: DialogInterface?) { - onResolved?.accept(false) - } - } -} 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 deleted file mode 100644 index f14a42f79..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions.resolve - -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.core.util.Consumer -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.coroutineScope -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.util.ext.findActivity -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope - -abstract class ErrorObserver( - protected val host: View, - protected val fragment: Fragment?, - private val resolver: ExceptionResolver?, - private val onResolved: Consumer?, -) : FlowCollector { - - protected val activity = host.context.findActivity() - - private val lifecycleScope: LifecycleCoroutineScope - get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope) - - protected val fragmentManager: FragmentManager? - get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager - - protected fun canResolve(error: Throwable): Boolean { - return resolver != null && ExceptionResolver.canResolve(error) - } - - private fun isAlive(): Boolean { - return when { - fragment != null -> fragment.view != null - activity != null -> !activity.isDestroyed - else -> true - } - } - - protected fun resolve(error: Throwable) { - if (isAlive()) { - lifecycleScope.launch { - val isResolved = resolver?.resolve(error) ?: false - if (isActive) { - onResolved?.accept(isResolved) - } - } - } - } -} 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 deleted file mode 100644 index e39897cfc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions.resolve - -import android.view.View -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.main.ui.owners.BottomNavOwner -import org.koitharu.kotatsu.parsers.exception.ParseException - -class SnackbarErrorObserver( - host: View, - fragment: Fragment?, - resolver: ExceptionResolver?, - onResolved: Consumer?, -) : ErrorObserver(host, fragment, resolver, onResolved) { - - constructor( - host: View, - fragment: Fragment?, - ) : this(host, fragment, null, null) - - override suspend fun emit(value: Throwable) { - val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT) - if (activity is BottomNavOwner) { - snackbar.anchorView = activity.bottomNav - } - if (canResolve(value)) { - snackbar.setAction(ExceptionResolver.getResolveStringId(value)) { - resolve(value) - } - } else if (value is ParseException) { - val fm = fragmentManager - if (fm != null) { - snackbar.setAction(R.string.details) { - ErrorDetailsDialog.show(fm, value, value.url) - } - } - } - snackbar.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ToastErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ToastErrorObserver.kt deleted file mode 100644 index 4e603a480..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ToastErrorObserver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions.resolve - -import android.view.View -import android.widget.Toast -import androidx.fragment.app.Fragment -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage - -class ToastErrorObserver( - host: View, - fragment: Fragment?, -) : ErrorObserver(host, fragment, null, null) { - - override suspend fun emit(value: Throwable) { - val toast = Toast.makeText(host.context, value.getDisplayMessage(host.context.resources), Toast.LENGTH_SHORT) - toast.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt deleted file mode 100644 index ab8713642..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/fs/FileSequence.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.core.fs - -import android.os.Build -import org.koitharu.kotatsu.core.util.iterator.CloseableIterator -import org.koitharu.kotatsu.core.util.iterator.MappingIterator -import java.io.File -import java.nio.file.Files -import java.nio.file.Path - -class FileSequence(private val dir: File) : Sequence { - - override fun iterator(): Iterator { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val stream = Files.newDirectoryStream(dir.toPath()) - CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream) - } else { - dir.listFiles().orEmpty().iterator() - } - } -} 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 deleted file mode 100644 index a1d2a5544..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.koitharu.kotatsu.core.github - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONArray -import org.json.JSONObject -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.network.BaseHttpClient -import org.koitharu.kotatsu.core.os.AppValidator -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.asArrayList -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -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 javax.inject.Inject -import javax.inject.Singleton - -private const val CONTENT_TYPE_APK = "application/vnd.android.package-archive" - -@Singleton -class AppUpdateRepository @Inject constructor( - private val appValidator: AppValidator, - private val settings: AppSettings, - @BaseHttpClient private val okHttp: OkHttpClient, -) { - - private val availableUpdate = MutableStateFlow(null) - - fun observeAvailableUpdate() = availableUpdate.asStateFlow() - - suspend fun getAvailableVersions(): List { - val request = Request.Builder() - .get() - .url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases?page=1&per_page=10") - val jsonArray = okHttp.newCall(request.build()).await().parseJsonArray() - return jsonArray.mapJSONNotNull { json -> - val asset = json.optJSONArray("assets")?.find { jo -> - jo.optString("content_type") == CONTENT_TYPE_APK - } ?: return@mapJSONNotNull null - AppVersion( - id = json.getLong("id"), - url = json.getString("html_url"), - name = json.getString("name").removePrefix("v"), - apkSize = asset.getLong("size"), - apkUrl = asset.getString("browser_download_url"), - description = json.getString("body"), - ) - } - } - - suspend fun fetchUpdate(): AppVersion? = withContext(Dispatchers.Default) { - if (!isUpdateSupported()) { - return@withContext null - } - runCatchingCancellable { - val currentVersion = VersionId(BuildConfig.VERSION_NAME) - val available = getAvailableVersions().asArrayList() - available.sortBy { it.versionId } - if (currentVersion.isStable && !settings.isUnstableUpdatesAllowed) { - available.retainAll { it.versionId.isStable } - } - available.maxByOrNull { it.versionId } - ?.takeIf { it.versionId > currentVersion } - }.onFailure { - it.printStackTraceDebug() - }.onSuccess { - availableUpdate.value = it - }.getOrNull() - } - - fun isUpdateSupported(): Boolean { - return BuildConfig.DEBUG || appValidator.isOriginalApp - } - - suspend fun getCurrentVersionChangelog(): String? { - val currentVersion = VersionId(BuildConfig.VERSION_NAME) - val available = getAvailableVersions() - return available.find { x -> x.versionId == currentVersion }?.description - } - - private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? { - val size = length() - for (i in 0 until size) { - val jo = getJSONObject(i) - if (predicate(jo)) { - return jo - } - } - return null - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt deleted file mode 100644 index 60a1e1d83..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/FileLogger.kt +++ /dev/null @@ -1,148 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import android.content.Context -import androidx.annotation.WorkerThread -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.core.util.ext.subdir -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.io.File -import java.io.FileOutputStream -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale -import java.util.concurrent.ConcurrentLinkedQueue - -private const val DIR = "logs" -private const val FLUSH_DELAY = 2_000L -private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB - -class FileLogger( - context: Context, - private val settings: AppSettings, - name: String, -) { - - val file by lazy { - val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR) - File(dir, "$name.log") - } - val isEnabled: Boolean - get() = settings.isLoggingEnabled - private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT) - private val buffer = ConcurrentLinkedQueue() - private val mutex = Mutex() - private var flushJob: Job? = null - - fun log(message: String, e: Throwable? = null) { - if (!isEnabled) { - return - } - val text = buildString { - append(dateTimeFormatter.format(LocalDateTime.now())) - append(": ") - if (e != null) { - append("E!") - } - append(message) - if (e != null) { - append(' ') - append(e.stackTraceToString()) - appendLine() - } - } - buffer.add(text) - postFlush() - } - - inline fun log(messageProducer: () -> String) { - if (isEnabled) { - log(messageProducer()) - } - } - - suspend fun flush() { - if (!isEnabled) { - return - } - flushJob?.cancelAndJoin() - flushImpl() - } - - @WorkerThread - fun flushBlocking() { - if (!isEnabled) { - return - } - runBlockingSafe { flushJob?.cancelAndJoin() } - runBlockingSafe { flushImpl() } - } - - private fun postFlush() { - if (flushJob?.isActive == true) { - return - } - flushJob = processLifecycleScope.launch(Dispatchers.Default) { - delay(FLUSH_DELAY) - runCatchingCancellable { - flushImpl() - }.onFailure { - it.printStackTraceDebug() - } - } - } - - private suspend fun flushImpl() = withContext(NonCancellable) { - mutex.withLock { - if (buffer.isEmpty()) { - return@withContext - } - runInterruptible(Dispatchers.IO) { - if (file.length() > MAX_SIZE_BYTES) { - rotate() - } - FileOutputStream(file, true).use { - while (true) { - val message = buffer.poll() ?: break - it.write(message.toByteArray()) - it.write('\n'.code) - } - it.flush() - } - } - } - } - - @WorkerThread - private fun rotate() { - val length = file.length() - val bakFile = File(file.parentFile, file.name + ".bak") - file.renameTo(bakFile) - bakFile.inputStream().use { input -> - input.skip(length - MAX_SIZE_BYTES / 2) - file.outputStream().use { output -> - input.copyTo(output) - output.flush() - } - } - bakFile.delete() - } - - private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try { - runBlocking(NonCancellable) { block() } - } catch (_: InterruptedException) { - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt deleted file mode 100644 index 008ca7d92..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/Loggers.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class TrackerLogger - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class SyncLogger diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt deleted file mode 100644 index 8253044d8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/logs/LoggersModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.core.logs - -import android.content.Context -import androidx.collection.arraySetOf -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.ElementsIntoSet -import org.koitharu.kotatsu.core.prefs.AppSettings - -@Module -@InstallIn(SingletonComponent::class) -object LoggersModule { - - @Provides - @TrackerLogger - fun provideTrackerLogger( - @ApplicationContext context: Context, - settings: AppSettings, - ) = FileLogger(context, settings, "tracker") - - @Provides - @SyncLogger - fun provideSyncLogger( - @ApplicationContext context: Context, - settings: AppSettings, - ) = FileLogger(context, settings, "sync") - - @Provides - @ElementsIntoSet - fun provideAllLoggers( - @TrackerLogger trackerLogger: FileLogger, - @SyncLogger syncLogger: FileLogger, - ): Set<@JvmSuppressWildcards FileLogger> = arraySetOf( - trackerLogger, - syncLogger, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt deleted file mode 100644 index 017f1b73e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.core.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import java.time.Instant - -@Parcelize -data class FavouriteCategory( - val id: Long, - val title: String, - val sortKey: Int, - val order: ListSortOrder, - val createdAt: Instant, - val isTrackingEnabled: Boolean, - val isVisibleInLibrary: Boolean, -) : Parcelable, ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is FavouriteCategory && id == other.id - } - - override fun getChangePayload(previousState: ListModel): Any? { - if (previousState !is FavouriteCategory) { - return null - } - return if (isTrackingEnabled != previousState.isTrackingEnabled || isVisibleInLibrary != previousState.isVisibleInLibrary) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt deleted file mode 100644 index d2b579b10..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.koitharu.kotatsu.core.model - -import android.net.Uri -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.os.LocaleListCompat -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.iterator -import org.koitharu.kotatsu.details.ui.model.ChapterListItem -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.util.mapToSet -import com.google.android.material.R as materialR - -@JvmName("mangaIds") -fun Collection.ids() = mapToSet { it.id } - -fun Collection.distinctById() = distinctBy { it.id } - -@JvmName("chaptersIds") -fun Collection.ids() = mapToSet { it.id } - -fun Collection.findById(id: Long) = find { x -> x.id == id } - -fun Collection.countChaptersByBranch(): Int { - if (size <= 1) { - return size - } - val acc = HashMap() - for (item in this) { - val branch = item.chapter.branch - acc[branch] = (acc[branch] ?: 0) + 1 - } - return acc.values.max() -} - -@get:StringRes -val MangaState.titleResId: Int - get() = when (this) { - MangaState.ONGOING -> R.string.state_ongoing - MangaState.FINISHED -> R.string.state_finished - MangaState.ABANDONED -> R.string.state_abandoned - MangaState.PAUSED -> R.string.state_paused - MangaState.UPCOMING -> R.string.state_upcoming - } - -@get:DrawableRes -val MangaState.iconResId: Int - get() = when (this) { - MangaState.ONGOING -> R.drawable.ic_play - MangaState.FINISHED -> R.drawable.ic_state_finished - MangaState.ABANDONED -> R.drawable.ic_state_abandoned - MangaState.PAUSED -> R.drawable.ic_action_pause - MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp - } - -@get:StringRes -val ContentRating.titleResId: Int - get() = when (this) { - ContentRating.SAFE -> R.string.rating_safe - ContentRating.SUGGESTIVE -> R.string.rating_suggestive - ContentRating.ADULT -> R.string.rating_adult - } - -fun Manga.findChapter(id: Long): MangaChapter? { - return chapters?.findById(id) -} - -fun Manga.getPreferredBranch(history: MangaHistory?): String? { - val ch = chapters - if (ch.isNullOrEmpty()) { - return null - } - if (history != null) { - val currentChapter = ch.findById(history.chapterId) - if (currentChapter != null) { - return currentChapter.branch - } - } - val groups = ch.groupBy { it.branch } - if (groups.size == 1) { - return groups.keys.first() - } - for (locale in LocaleListCompat.getAdjustedDefault()) { - val displayLanguage = locale.getDisplayLanguage(locale) - val displayName = locale.getDisplayName(locale) - val candidates = HashMap>(3) - for (branch in groups.keys) { - if (branch != null && ( - branch.contains(displayLanguage, ignoreCase = true) || - branch.contains(displayName, ignoreCase = true) - ) - ) { - candidates[branch] = groups[branch] ?: continue - } - } - if (candidates.isNotEmpty()) { - return candidates.maxBy { it.value.size }.key - } - } - return groups.maxByOrNull { it.value.size }?.key -} - -val Manga.isLocal: Boolean - get() = source == MangaSource.LOCAL - -val Manga.appUrl: Uri - get() = Uri.parse("https://kotatsu.app/manga").buildUpon() - .appendQueryParameter("source", source.name) - .appendQueryParameter("name", title) - .appendQueryParameter("url", url) - .build() 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 deleted file mode 100644 index dc736c8b9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.koitharu.kotatsu.core.model - -import android.content.Context -import android.graphics.Color -import android.text.SpannableStringBuilder -import android.text.style.ForegroundColorSpan -import android.text.style.RelativeSizeSpan -import android.text.style.SuperscriptSpan -import androidx.annotation.StringRes -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getDisplayName -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.toLocale -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.toTitleCase -import java.util.Locale -import com.google.android.material.R as materialR - -fun MangaSource(name: String): MangaSource { - MangaSource.entries.forEach { - if (it.name == name) return it - } - return MangaSource.DUMMY -} - -fun MangaSource.isNsfw() = contentType == ContentType.HENTAI - -@get:StringRes -val ContentType.titleResId - get() = when (this) { - ContentType.MANGA -> R.string.content_type_manga - ContentType.HENTAI -> R.string.content_type_hentai - ContentType.COMICS -> R.string.content_type_comics - ContentType.OTHER -> R.string.content_type_other - } - -fun MangaSource.getSummary(context: Context): String { - val type = context.getString(contentType.titleResId) - val locale = locale?.toLocale().getDisplayName(context) - return context.getString(R.string.source_summary_pattern, type, locale) -} - -fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) { - buildSpannedString { - append(title) - append(' ') - appendNsfwLabel(context) - } -} else { - title -} - -private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( - ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), - RelativeSizeSpan(0.74f), - SuperscriptSpan(), -) { - append(context.getString(R.string.nsfw)) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt deleted file mode 100644 index 190185bdd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.core.model.parcelable - -import android.os.Parcel -import android.os.Parcelable -import kotlinx.parcelize.Parceler -import kotlinx.parcelize.Parcelize -import org.koitharu.kotatsu.core.util.ext.readSerializableCompat -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaSource - -@Parcelize -data class ParcelableChapter( - val chapter: MangaChapter, -) : Parcelable { - - companion object : Parceler { - - override fun create(parcel: Parcel) = ParcelableChapter( - MangaChapter( - id = parcel.readLong(), - name = parcel.readString().orEmpty(), - number = parcel.readInt(), - url = parcel.readString().orEmpty(), - scanlator = parcel.readString(), - uploadDate = parcel.readLong(), - branch = parcel.readString(), - source = parcel.readSerializableCompat() ?: MangaSource.DUMMY, - ) - ) - - override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { - parcel.writeLong(id) - parcel.writeString(name) - parcel.writeInt(number) - parcel.writeString(url) - parcel.writeString(scanlator) - parcel.writeLong(uploadDate) - parcel.writeString(branch) - parcel.writeSerializable(source) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt deleted file mode 100644 index c4d19a593..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.core.model.parcelable - -import android.os.Parcel -import android.os.Parcelable -import androidx.core.os.ParcelCompat -import kotlinx.parcelize.Parceler -import kotlinx.parcelize.Parcelize -import org.koitharu.kotatsu.core.util.ext.readParcelableCompat -import org.koitharu.kotatsu.core.util.ext.readSerializableCompat -import org.koitharu.kotatsu.parsers.model.Manga - -@Parcelize -data class ParcelableManga( - val manga: Manga, -) : Parcelable { - - companion object : Parceler { - - override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) { - parcel.writeLong(id) - parcel.writeString(title) - parcel.writeString(altTitle) - parcel.writeString(url) - parcel.writeString(publicUrl) - parcel.writeFloat(rating) - ParcelCompat.writeBoolean(parcel, isNsfw) - parcel.writeString(coverUrl) - parcel.writeString(largeCoverUrl) - parcel.writeString(description) - parcel.writeParcelable(ParcelableMangaTags(tags), flags) - parcel.writeSerializable(state) - parcel.writeString(author) - parcel.writeSerializable(source) - } - - override fun create(parcel: Parcel) = ParcelableManga( - Manga( - id = parcel.readLong(), - title = requireNotNull(parcel.readString()), - altTitle = parcel.readString(), - url = requireNotNull(parcel.readString()), - publicUrl = requireNotNull(parcel.readString()), - rating = parcel.readFloat(), - isNsfw = ParcelCompat.readBoolean(parcel), - coverUrl = requireNotNull(parcel.readString()), - largeCoverUrl = parcel.readString(), - description = parcel.readString(), - tags = requireNotNull(parcel.readParcelableCompat()).tags, - state = parcel.readSerializableCompat(), - author = parcel.readString(), - chapters = null, - source = requireNotNull(parcel.readSerializableCompat()), - ) - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPage.kt deleted file mode 100644 index eee936e84..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPage.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.koitharu.kotatsu.core.model.parcelable - -import android.os.Parcel -import android.os.Parcelable -import kotlinx.parcelize.Parceler -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.TypeParceler -import org.koitharu.kotatsu.core.util.ext.readSerializableCompat -import org.koitharu.kotatsu.parsers.model.MangaPage - -object MangaPageParceler : Parceler { - override fun create(parcel: Parcel) = MangaPage( - id = parcel.readLong(), - url = requireNotNull(parcel.readString()), - preview = parcel.readString(), - source = requireNotNull(parcel.readSerializableCompat()), - ) - - override fun MangaPage.write(parcel: Parcel, flags: Int) { - parcel.writeLong(id) - parcel.writeString(url) - parcel.writeString(preview) - parcel.writeSerializable(source) - } -} - -@Parcelize -@TypeParceler -class ParcelableMangaPage(val page: MangaPage) : Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt deleted file mode 100644 index 75640156a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.core.model.parcelable - -import android.os.Parcel -import android.os.Parcelable -import kotlinx.parcelize.Parceler -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.TypeParceler -import org.koitharu.kotatsu.core.util.ext.readSerializableCompat -import org.koitharu.kotatsu.parsers.model.MangaTag - -object MangaTagParceler : Parceler { - override fun create(parcel: Parcel) = MangaTag( - title = requireNotNull(parcel.readString()), - key = requireNotNull(parcel.readString()), - source = requireNotNull(parcel.readSerializableCompat()), - ) - - override fun MangaTag.write(parcel: Parcel, flags: Int) { - parcel.writeString(title) - parcel.writeString(key) - parcel.writeSerializable(source) - } -} - -@Parcelize -@TypeParceler -data class ParcelableMangaTags(val tags: Set) : Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt deleted file mode 100644 index 87a410ba6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.ProxySelector -import java.net.SocketAddress -import java.net.URI - -class AppProxySelector( - private val settings: AppSettings, -) : ProxySelector() { - - init { - setDefault(this) - } - - private var cachedProxy: Proxy? = null - - override fun select(uri: URI?): List { - return listOf(getProxy()) - } - - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - ioe?.printStackTraceDebug() - } - - private fun getProxy(): Proxy { - val type = settings.proxyType - val address = settings.proxyAddress - val port = settings.proxyPort - if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) { - return Proxy.NO_PROXY - } - cachedProxy?.let { - val addr = it.address() as? InetSocketAddress - if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) { - return it - } - } - val proxy = Proxy(type, InetSocketAddress(address, port)) - cachedProxy = proxy - return proxy - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt deleted file mode 100644 index 52710c57b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CacheLimitInterceptor.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okhttp3.CacheControl -import okhttp3.Interceptor -import okhttp3.Response -import java.util.concurrent.TimeUnit - -class CacheLimitInterceptor : Interceptor { - - private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1) - private val defaultCacheControl = CacheControl.Builder() - .maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS) - .build() - .toString() - - override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - val responseCacheControl = CacheControl.parse(response.headers) - if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) { - return response - } - return response.newBuilder() - .header(CommonHeaders.CACHE_CONTROL, defaultCacheControl) - .build() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt deleted file mode 100644 index b17c0e789..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import android.util.Log -import dagger.Lazy -import okhttp3.Headers -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.network.UserAgents -import org.koitharu.kotatsu.parsers.util.mergeWith -import java.net.IDN -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class CommonHeadersInterceptor @Inject constructor( - private val mangaRepositoryFactoryLazy: Lazy, -) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - val source = request.tag(MangaSource::class.java) - val repository = if (source != null) { - mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository - } else { - if (BuildConfig.DEBUG) { - Log.w("Http", "Request without source tag: ${request.url}") - } - null - } - val headersBuilder = request.headers.newBuilder() - repository?.headers?.let { - headersBuilder.mergeWith(it, replaceExisting = false) - } - if (headersBuilder[CommonHeaders.USER_AGENT] == null) { - headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE - } - if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { - val idn = IDN.toASCII(repository.domain) - headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/") - } - val newRequest = request.newBuilder().headers(headersBuilder.build()).build() - return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) - } - - private fun Headers.Builder.trySet(name: String, value: String) = try { - set(name, value) - } catch (e: IllegalArgumentException) { - e.printStackTraceDebug() - } - - private class ProxyChain( - private val delegate: Interceptor.Chain, - private val request: Request, - ) : Interceptor.Chain by delegate { - - override fun request(): Request = request - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/GZipInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/GZipInterceptor.kt deleted file mode 100644 index 09a544105..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/GZipInterceptor.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okhttp3.Interceptor -import okhttp3.Response -import okio.IOException -import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING - -class GZipInterceptor : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val newRequest = chain.request().newBuilder() - newRequest.addHeader(CONTENT_ENCODING, "gzip") - return try { - chain.proceed(newRequest.build()) - } catch (e: NullPointerException) { - throw IOException(e) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/HttpClients.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/HttpClients.kt deleted file mode 100644 index fb69fe291..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/HttpClients.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class BaseHttpClient - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class MangaHttpClient diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt deleted file mode 100644 index 54423ba67..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ImageProxyInterceptor.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import android.util.Log -import androidx.collection.ArraySet -import coil.intercept.Interceptor -import coil.request.ErrorResult -import coil.request.ImageResult -import coil.request.SuccessResult -import coil.size.Dimension -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.ensureSuccess -import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.Collections -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ImageProxyInterceptor @Inject constructor( - private val settings: AppSettings, -) : Interceptor { - - private val blacklist = Collections.synchronizedSet(ArraySet()) - - override suspend fun intercept(chain: Interceptor.Chain): ImageResult { - val request = chain.request - if (!settings.isImagesProxyEnabled) { - return chain.proceed(request) - } - val url: HttpUrl? = when (val data = request.data) { - is HttpUrl -> data - is String -> data.toHttpUrlOrNull() - else -> null - } - if (url == null || !url.isHttpOrHttps || url.host in blacklist) { - return chain.proceed(request) - } - val newUrl = HttpUrl.Builder() - .scheme("https") - .host("wsrv.nl") - .addQueryParameter("url", url.toString()) - .addQueryParameter("fit", "outside") - .addQueryParameter("we", null) - val size = request.sizeResolver.size() - (size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) } - (size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) } - - val newRequest = request.newBuilder() - .data(newUrl.build()) - .build() - val result = chain.proceed(newRequest) - return if (result is SuccessResult) { - result - } else { - logDebug((result as? ErrorResult)?.throwable) - chain.proceed(request).also { - if (it is SuccessResult) { - blacklist.add(url.host) - } - } - } - } - - suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response { - if (!settings.isImagesProxyEnabled) { - return okHttp.newCall(request).await() - } - val sourceUrl = request.url - val targetUrl = HttpUrl.Builder() - .scheme("https") - .host("wsrv.nl") - .addQueryParameter("url", sourceUrl.toString()) - .addQueryParameter("we", null) - val newRequest = request.newBuilder() - .url(targetUrl.build()) - .build() - return runCatchingCancellable { - okHttp.doCall(newRequest) - }.recover { - logDebug(it) - okHttp.doCall(request).also { - blacklist.add(sourceUrl.host) - } - }.getOrThrow() - } - - private suspend fun OkHttpClient.doCall(request: Request): Response { - return newCall(request).await().ensureSuccess() - } - - private fun logDebug(e: Throwable?) { - if (BuildConfig.DEBUG) { - Log.w("ImageProxy", e.toString()) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt deleted file mode 100644 index efdc9564c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/MirrorSwitchInterceptor.kt +++ /dev/null @@ -1,161 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import androidx.collection.ArraySet -import dagger.Lazy -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.ResponseBody.Companion.toResponseBody -import okhttp3.internal.canParseAsIpAddress -import okhttp3.internal.closeQuietly -import okhttp3.internal.publicsuffix.PublicSuffixDatabase -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.parsers.model.MangaSource -import java.util.EnumMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class MirrorSwitchInterceptor @Inject constructor( - private val mangaRepositoryFactoryLazy: Lazy, - private val settings: AppSettings, -) : Interceptor { - - private val locks = EnumMap(MangaSource::class.java) - private val blacklist = EnumMap>(MangaSource::class.java) - - val isEnabled: Boolean - get() = settings.isMirrorSwitchingAvailable - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - if (!isEnabled) { - return chain.proceed(request) - } - return try { - val response = chain.proceed(request) - if (response.isFailed) { - val responseCopy = response.copy() - response.closeQuietly() - trySwitchMirror(request, chain)?.also { - responseCopy.closeQuietly() - } ?: responseCopy - } else { - response - } - } catch (e: Exception) { - trySwitchMirror(request, chain) ?: throw e - } - } - - suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) { - if (!isEnabled) { - return@runInterruptible false - } - val mirrors = repository.getAvailableMirrors() - if (mirrors.size <= 1) { - return@runInterruptible false - } - synchronized(obtainLock(repository.source)) { - val currentMirror = repository.domain - if (currentMirror !in mirrors) { - return@synchronized false - } - addToBlacklist(repository.source, currentMirror) - val newMirror = mirrors.firstOrNull { x -> - x != currentMirror && !isBlacklisted(repository.source, x) - } ?: return@synchronized false - repository.domain = newMirror - true - } - } - - fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) { - blacklist[repository.source]?.remove(oldMirror) - repository.domain = oldMirror - } - - private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { - val source = request.tag(MangaSource::class.java) ?: return null - val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null - val mirrors = repository.getAvailableMirrors() - if (mirrors.isEmpty()) { - return null - } - return synchronized(obtainLock(repository.source)) { - tryMirrors(repository, mirrors, chain, request) - } - } - - private fun tryMirrors( - repository: RemoteMangaRepository, - mirrors: List, - chain: Interceptor.Chain, - request: Request, - ): Response? { - val url = request.url - val currentDomain = url.topPrivateDomain() - if (currentDomain !in mirrors) { - return null - } - val urlBuilder = url.newBuilder() - for (mirror in mirrors) { - if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) { - continue - } - val newHost = hostOf(url.host, mirror) ?: continue - val newRequest = request.newBuilder() - .url(urlBuilder.host(newHost).build()) - .build() - val response = chain.proceed(newRequest) - if (response.isFailed) { - addToBlacklist(repository.source, mirror) - response.closeQuietly() - } else { - repository.domain = mirror - return response - } - } - return null - } - - private val Response.isFailed: Boolean - get() = code in 400..599 - - private fun hostOf(host: String, newDomain: String): String? { - if (newDomain.canParseAsIpAddress()) { - return newDomain - } - val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null - return host.removeSuffix(domain) + newDomain - } - - private fun Response.copy(): Response { - return newBuilder() - .body(body?.copy()) - .build() - } - - private fun ResponseBody.copy(): ResponseBody { - return source().readByteArray().toResponseBody(contentType()) - } - - private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) { - Any() - } - - private fun isBlacklisted(source: MangaSource, domain: String): Boolean { - return blacklist[source]?.contains(domain) == true - } - - private fun addToBlacklist(source: MangaSource, domain: String) { - blacklist.getOrPut(source) { - ArraySet(2) - }.add(domain) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt deleted file mode 100644 index e9e6a6dc6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import android.content.Context -import android.util.AndroidRuntimeException -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import okhttp3.Cache -import okhttp3.CookieJar -import okhttp3.OkHttpClient -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.local.data.LocalStorageManager -import java.util.concurrent.TimeUnit -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -interface NetworkModule { - - @Binds - fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar - - companion object { - - @Provides - @Singleton - fun provideCookieJar( - @ApplicationContext context: Context - ): MutableCookieJar = try { - AndroidCookieJar() - } catch (e: AndroidRuntimeException) { - // WebView is not available - PreferencesCookieJar(context) - } - - @Provides - @Singleton - fun provideHttpCache( - localStorageManager: LocalStorageManager, - ): Cache = localStorageManager.createHttpCache() - - @Provides - @Singleton - @BaseHttpClient - fun provideBaseHttpClient( - cache: Cache, - cookieJar: CookieJar, - settings: AppSettings, - ): OkHttpClient = OkHttpClient.Builder().apply { - connectTimeout(20, TimeUnit.SECONDS) - readTimeout(60, TimeUnit.SECONDS) - writeTimeout(20, TimeUnit.SECONDS) - cookieJar(cookieJar) - proxySelector(AppProxySelector(settings)) - proxyAuthenticator(ProxyAuthenticator(settings)) - dns(DoHManager(cache, settings)) - if (settings.isSSLBypassEnabled) { - bypassSSLErrors() - } - cache(cache) - addInterceptor(GZipInterceptor()) - addInterceptor(CloudFlareInterceptor()) - addInterceptor(RateLimitInterceptor()) - if (BuildConfig.DEBUG) { - addInterceptor(CurlLoggingInterceptor()) - } - }.build() - - @Provides - @Singleton - @MangaHttpClient - fun provideMangaHttpClient( - @BaseHttpClient baseClient: OkHttpClient, - commonHeadersInterceptor: CommonHeadersInterceptor, - mirrorSwitchInterceptor: MirrorSwitchInterceptor, - ): OkHttpClient = baseClient.newBuilder().apply { - addNetworkInterceptor(CacheLimitInterceptor()) - addInterceptor(commonHeadersInterceptor) - addInterceptor(mirrorSwitchInterceptor) - }.build() - - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt deleted file mode 100644 index fb4ffad7e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okhttp3.Authenticator -import okhttp3.Credentials -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import org.koitharu.kotatsu.core.prefs.AppSettings -import java.net.PasswordAuthentication -import java.net.Proxy - -class ProxyAuthenticator( - private val settings: AppSettings, -) : Authenticator, java.net.Authenticator() { - - init { - setDefault(this) - } - - override fun authenticate(route: Route?, response: Response): Request? { - if (!isProxyEnabled()) { - return null - } - if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) { - return null - } - val login = settings.proxyLogin ?: return null - val password = settings.proxyPassword ?: return null - val credential = Credentials.basic(login, password) - return response.request.newBuilder() - .header(CommonHeaders.PROXY_AUTHORIZATION, credential) - .build() - } - - override fun getPasswordAuthentication(): PasswordAuthentication? { - if (!isProxyEnabled()) { - return null - } - val login = settings.proxyLogin ?: return null - val password = settings.proxyPassword ?: return null - return PasswordAuthentication(login, password.toCharArray()) - } - - private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/RateLimitInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/RateLimitInterceptor.kt deleted file mode 100644 index 3b3168d47..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/RateLimitInterceptor.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okhttp3.Interceptor -import okhttp3.Response -import okhttp3.internal.closeQuietly -import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions -import java.time.Instant -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter - -class RateLimitInterceptor : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - if (response.code == 429) { - val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate() - val request = response.request - response.closeQuietly() - throw TooManyRequestExceptions( - url = request.url.toString(), - retryAt = retryDate, - ) - } - return response - } - - private fun String.parseRetryDate(): Instant? { - return toLongOrNull()?.let { Instant.now().plusSeconds(it) } - ?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt deleted file mode 100644 index d5ef6fd59..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/SSLBypass.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import android.annotation.SuppressLint -import okhttp3.OkHttpClient -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.security.SecureRandom -import java.security.cert.X509Certificate -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.X509TrustManager - -@SuppressLint("CustomX509TrustManager") -fun OkHttpClient.Builder.bypassSSLErrors() = also { builder -> - runCatching { - val trustAllCerts = object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) = Unit - - override fun checkServerTrusted(chain: Array, authType: String) = Unit - - override fun getAcceptedIssuers(): Array = emptyArray() - } - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, arrayOf(trustAllCerts), SecureRandom()) - val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory - builder.sslSocketFactory(sslSocketFactory, trustAllCerts) - builder.hostnameVerifier { _, _ -> true } - }.onFailure { - it.printStackTraceDebug() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt deleted file mode 100644 index 9c1d2afe2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.core.network.cookies - -import android.webkit.CookieManager -import androidx.annotation.WorkerThread -import androidx.core.util.Predicate -import okhttp3.Cookie -import okhttp3.HttpUrl -import org.koitharu.kotatsu.core.util.ext.newBuilder -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -class AndroidCookieJar : MutableCookieJar { - - private val cookieManager = CookieManager.getInstance() - - @WorkerThread - override fun loadForRequest(url: HttpUrl): List { - val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() - return rawCookie.split(';').mapNotNull { - Cookie.parse(url, it) - } - } - - @WorkerThread - override fun saveFromResponse(url: HttpUrl, cookies: List) { - if (cookies.isEmpty()) { - return - } - val urlString = url.toString() - for (cookie in cookies) { - cookieManager.setCookie(urlString, cookie.toString()) - } - } - - override fun removeCookies(url: HttpUrl, predicate: Predicate?) { - val cookies = loadForRequest(url) - if (cookies.isEmpty()) { - return - } - val urlString = url.toString() - for (c in cookies) { - if (predicate != null && !predicate.test(c)) { - continue - } - val nc = c.newBuilder() - .expiresAt(System.currentTimeMillis() - 100000) - .build() - cookieManager.setCookie(urlString, nc.toString()) - } - } - - override suspend fun clear() = suspendCoroutine { continuation -> - cookieManager.removeAllCookies(continuation::resume) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt deleted file mode 100644 index 8aa8ad1c1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/CookieWrapper.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.core.network.cookies - -import android.util.Base64 -import okhttp3.Cookie -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream - - -data class CookieWrapper( - val cookie: Cookie, -) { - - constructor(encodedString: String) : this( - ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use { - val name = it.readUTF() - val value = it.readUTF() - val expiresAt = it.readLong() - val domain = it.readUTF() - val path = it.readUTF() - val secure = it.readBoolean() - val httpOnly = it.readBoolean() - val persistent = it.readBoolean() - val hostOnly = it.readBoolean() - Cookie.Builder().also { c -> - c.name(name) - c.value(value) - if (persistent) { - c.expiresAt(expiresAt) - } - if (hostOnly) { - c.hostOnlyDomain(domain) - } else { - c.domain(domain) - } - c.path(path) - if (secure) { - c.secure() - } - if (httpOnly) { - c.httpOnly() - } - }.build() - }, - ) - - fun encode(): String { - val output = ByteArrayOutputStream() - ObjectOutputStream(output).use { - it.writeUTF(cookie.name) - it.writeUTF(cookie.value) - it.writeLong(cookie.expiresAt) - it.writeUTF(cookie.domain) - it.writeUTF(cookie.path) - it.writeBoolean(cookie.secure) - it.writeBoolean(cookie.httpOnly) - it.writeBoolean(cookie.persistent) - it.writeBoolean(cookie.hostOnly) - } - return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) - } - - fun isExpired() = cookie.expiresAt < System.currentTimeMillis() - - fun key(): String { - return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt deleted file mode 100644 index a1f1b3ec5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/MutableCookieJar.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.core.network.cookies - -import androidx.annotation.WorkerThread -import androidx.core.util.Predicate -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl - -interface MutableCookieJar : CookieJar { - - @WorkerThread - override fun loadForRequest(url: HttpUrl): List - - @WorkerThread - override fun saveFromResponse(url: HttpUrl, cookies: List) - - @WorkerThread - fun removeCookies(url: HttpUrl, predicate: Predicate?) - - suspend fun clear(): Boolean -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt deleted file mode 100644 index acc7f5eea..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/PreferencesCookieJar.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.koitharu.kotatsu.core.network.cookies - -import android.content.Context -import androidx.annotation.WorkerThread -import androidx.collection.ArrayMap -import androidx.core.content.edit -import androidx.core.util.Predicate -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Cookie -import okhttp3.HttpUrl -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -private const val PREFS_NAME = "cookies" - -class PreferencesCookieJar( - context: Context, -) : MutableCookieJar { - - private val cache = ArrayMap() - private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private var isLoaded = false - - @WorkerThread - @Synchronized - override fun loadForRequest(url: HttpUrl): List { - loadPersistent() - val expired = HashSet() - val result = ArrayList() - for ((key, cookie) in cache) { - if (cookie.isExpired()) { - expired += key - } else if (cookie.cookie.matches(url)) { - result += cookie.cookie - } - } - if (expired.isNotEmpty()) { - cache.removeAll(expired) - removePersistent(expired) - } - return result - } - - @WorkerThread - @Synchronized - override fun saveFromResponse(url: HttpUrl, cookies: List) { - val wrapped = cookies.map { CookieWrapper(it) } - prefs.edit(commit = true) { - for (cookie in wrapped) { - val key = cookie.key() - cache[key] = cookie - if (cookie.cookie.persistent) { - putString(key, cookie.encode()) - } - } - } - } - - @Synchronized - @WorkerThread - override fun removeCookies(url: HttpUrl, predicate: Predicate?) { - loadPersistent() - val toRemove = HashSet() - for ((key, cookie) in cache) { - if (cookie.isExpired() || cookie.cookie.matches(url)) { - if (predicate == null || predicate.test(cookie.cookie)) { - toRemove += key - } - } - } - if (toRemove.isNotEmpty()) { - cache.removeAll(toRemove) - removePersistent(toRemove) - } - } - - override suspend fun clear(): Boolean { - cache.clear() - withContext(Dispatchers.IO) { - prefs.edit(commit = true) { clear() } - } - return true - } - - @Synchronized - private fun loadPersistent() { - if (!isLoaded) { - val map = prefs.all - cache.ensureCapacity(map.size) - for ((k, v) in map) { - val cookie = try { - CookieWrapper(v as String) - } catch (e: Exception) { - e.printStackTraceDebug() - continue - } - cache[k] = cookie - } - isLoaded = true - } - } - - private fun removePersistent(keys: Collection) { - prefs.edit(commit = true) { - for (key in keys) { - remove(key) - } - } - } -} 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 deleted file mode 100644 index 8c92b6b3e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.koitharu.kotatsu.core.os - -import android.content.Context -import android.content.SharedPreferences -import android.content.pm.ShortcutManager -import android.os.Build -import androidx.annotation.VisibleForTesting -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.room.InvalidationTracker -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import coil.size.Size -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.TABLE_HISTORY -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.favicon.faviconUri -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation -import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -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 - -@Singleton -class AppShortcutManager @Inject constructor( - @ApplicationContext private val context: Context, - private val coil: ImageLoader, - private val historyRepository: HistoryRepository, - private val mangaRepository: MangaDataRepository, - private val settings: AppSettings, -) : InvalidationTracker.Observer(TABLE_HISTORY), SharedPreferences.OnSharedPreferenceChangeListener { - - private val iconSize by lazy { - Size(ShortcutManagerCompat.getIconMaxWidth(context), ShortcutManagerCompat.getIconMaxHeight(context)) - } - private var shortcutsUpdateJob: Job? = null - - init { - settings.subscribe(this) - } - - override fun onInvalidated(tables: Set) { - if (!settings.isDynamicShortcutsEnabled) { - return - } - val prevJob = shortcutsUpdateJob - shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) { - prevJob?.join() - updateShortcutsImpl() - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == AppSettings.KEY_SHORTCUTS) { - if (settings.isDynamicShortcutsEnabled) { - onInvalidated(emptySet()) - } else { - clearShortcuts() - } - } - } - - suspend fun requestPinShortcut(manga: Manga): Boolean = try { - ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null) - } catch (e: IllegalStateException) { - e.printStackTraceDebug() - false - } - - suspend fun requestPinShortcut(source: MangaSource): Boolean = try { - ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(source), null) - } catch (e: IllegalStateException) { - e.printStackTraceDebug() - false - } - - @VisibleForTesting - suspend fun await(): Boolean { - return shortcutsUpdateJob?.join() != null - } - - fun notifyMangaOpened(mangaId: Long) { - ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString()) - } - - fun isDynamicShortcutsAvailable(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && - context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0 - } - - private suspend fun updateShortcutsImpl() = runCatchingCancellable { - val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5) - val shortcuts = historyRepository.getList(0, maxShortcuts) - .filter { x -> x.title.isNotEmpty() } - .map { buildShortcutInfo(it) } - ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) - }.onFailure { - it.printStackTraceDebug() - } - - private fun clearShortcuts() { - try { - ShortcutManagerCompat.removeAllDynamicShortcuts(context) - } catch (_: IllegalStateException) { - } - } - - private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat { - val icon = runCatchingCancellable { - coil.execute( - ImageRequest.Builder(context) - .data(manga.coverUrl) - .size(iconSize) - .source(manga.source) - .scale(Scale.FILL) - .transformations(ThumbnailTransformation()) - .build(), - ).getDrawableOrThrow().toBitmap() - }.fold( - onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, - onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, - ) - mangaRepository.storeManga(manga) - return ShortcutInfoCompat.Builder(context, manga.id.toString()) - .setShortLabel(manga.title) - .setLongLabel(manga.title) - .setIcon(icon) - .setLongLived(true) - .setIntent( - ReaderActivity.IntentBuilder(context) - .mangaId(manga.id) - .build(), - ) - .build() - } - - private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat { - val icon = runCatchingCancellable { - coil.execute( - ImageRequest.Builder(context) - .data(source.faviconUri()) - .size(iconSize) - .scale(Scale.FIT) - .build(), - ).getDrawableOrThrow().toBitmap() - }.fold( - onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, - onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, - ) - return ShortcutInfoCompat.Builder(context, source.name) - .setShortLabel(source.title) - .setLongLabel(source.title) - .setIcon(icon) - .setLongLived(true) - .setIntent(MangaListActivity.newIntent(context, source)) - .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 deleted file mode 100644 index da18d57a4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.core.os - -import android.annotation.SuppressLint -import android.content.Context -import android.content.pm.PackageManager -import dagger.hilt.android.qualifiers.ApplicationContext -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.util.byte2HexFormatted -import java.io.ByteArrayInputStream -import java.io.InputStream -import java.security.MessageDigest -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AppValidator @Inject constructor( - @ApplicationContext private val context: Context, -) { - - val isOriginalApp by lazy { - getCertificateSHA1Fingerprint() == CERT_SHA1 - } - - @Suppress("DEPRECATION") - @SuppressLint("PackageManagerGetSignatures") - private fun getCertificateSHA1Fingerprint(): String? = runCatching { - val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES) - val signatures = requireNotNull(packageInfo?.signatures) - val cert: ByteArray = signatures.first().toByteArray() - val input: InputStream = ByteArrayInputStream(cert) - val cf = CertificateFactory.getInstance("X509") - val c = cf.generateCertificate(input) as X509Certificate - val md: MessageDigest = MessageDigest.getInstance("SHA1") - val publicKey: ByteArray = md.digest(c.encoded) - return publicKey.byte2HexFormatted() - }.onFailure { error -> - error.printStackTraceDebug() - }.getOrNull() - - private companion object { - - private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkManageIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkManageIntent.kt deleted file mode 100644 index 50f0e2fdf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkManageIntent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.koitharu.kotatsu.core.os - -import android.content.Intent -import android.os.Build -import android.provider.Settings - -@Suppress("FunctionName") -fun NetworkManageIntent(): Intent { - val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Settings.Panel.ACTION_INTERNET_CONNECTIVITY - } else { - Settings.ACTION_WIRELESS_SETTINGS - } - return Intent(action) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt deleted file mode 100644 index 63907bdde..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.os - -import android.net.ConnectivityManager -import android.net.ConnectivityManager.NetworkCallback -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import kotlinx.coroutines.flow.first -import org.koitharu.kotatsu.core.util.MediatorStateFlow -import org.koitharu.kotatsu.core.util.ext.isOnline - -class NetworkState( - private val connectivityManager: ConnectivityManager, -) : MediatorStateFlow(connectivityManager.isOnline()) { - - private val callback = NetworkCallbackImpl() - - @Synchronized - override fun onActive() { - invalidate() - val request = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - connectivityManager.registerNetworkCallback(request, callback) - } - - @Synchronized - override fun onInactive() { - connectivityManager.unregisterNetworkCallback(callback) - } - - suspend fun awaitForConnection() { - if (value) { - return - } - first { it } - } - - private fun invalidate() { - publishValue(connectivityManager.isOnline()) - } - - private inner class NetworkCallbackImpl : NetworkCallback() { - - override fun onAvailable(network: Network) = invalidate() - - override fun onLost(network: Network) = invalidate() - - override fun onUnavailable() = invalidate() - } -} 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 deleted file mode 100644 index ba2b08223..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import androidx.core.net.toUri -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity -import org.koitharu.kotatsu.core.db.entity.toEntities -import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.prefs.ReaderMode -import org.koitharu.kotatsu.core.util.ext.toFileOrNull -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.reader.domain.ReaderColorFilter -import javax.inject.Inject -import javax.inject.Provider - -@Reusable -class MangaDataRepository @Inject constructor( - private val db: MangaDatabase, - private val resolverProvider: Provider, -) { - - suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { - db.withTransaction { - storeManga(manga) - val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) - db.getPreferencesDao().upsert(entity.copy(mode = mode.id)) - } - } - - suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { - db.withTransaction { - storeManga(manga) - val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id) - db.getPreferencesDao().upsert( - entity.copy( - cfBrightness = colorFilter?.brightness ?: 0f, - cfContrast = colorFilter?.contrast ?: 0f, - cfInvert = colorFilter?.isInverted ?: false, - ), - ) - } - } - - suspend fun getReaderMode(mangaId: Long): ReaderMode? { - return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) } - } - - suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? { - return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull() - } - - fun observeColorFilter(mangaId: Long): Flow { - return db.getPreferencesDao().observe(mangaId) - .map { it?.getColorFilterOrNull() } - .distinctUntilChanged() - } - - suspend fun findMangaById(mangaId: Long): Manga? { - return db.getMangaDao().find(mangaId)?.toManga() - } - - suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { - return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() - } - - suspend fun resolveIntent(intent: MangaIntent): Manga? = when { - intent.manga != null -> intent.manga - intent.mangaId != 0L -> findMangaById(intent.mangaId) - intent.uri != null -> resolverProvider.get().resolve(intent.uri) - else -> null - } - - suspend fun storeManga(manga: Manga) { - db.withTransaction { - // avoid storing local manga if remote one is already stored - val existing = if (manga.isLocal) { - db.getMangaDao().find(manga.id)?.manga - } else { - null - } - if (existing == null || existing.source == manga.source.name) { - val tags = manga.tags.toEntities() - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga.toEntity(), tags) - } - } - } - - suspend fun findTags(source: MangaSource): Set { - return db.getTagsDao().findTags(source.name).toMangaTags() - } - - suspend fun cleanupLocalManga() { - val dao = db.getMangaDao() - val broken = dao.findAllBySource(MangaSource.LOCAL.name) - .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } - if (broken.isNotEmpty()) { - dao.delete(broken.map { it.manga }) - } - } - - private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { - return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) { - ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale) - } else { - null - } - } - - private fun newEntity(mangaId: Long) = MangaPrefsEntity( - mangaId = mangaId, - mode = -1, - cfBrightness = 0f, - cfContrast = 0f, - cfInvert = false, - cfGrayscale = false, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt deleted file mode 100644 index 25dd7749e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -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.util.ext.getParcelableCompat -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.parsers.model.Manga - -class MangaIntent private constructor( - @JvmField val manga: Manga?, - @JvmField val id: Long, - @JvmField val uri: Uri?, -) { - - constructor(intent: Intent?) : this( - manga = intent?.getParcelableExtraCompat(KEY_MANGA)?.manga, - id = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE, - uri = intent?.data, - ) - - constructor(savedStateHandle: SavedStateHandle) : this( - manga = savedStateHandle.get(KEY_MANGA)?.manga, - id = savedStateHandle[KEY_ID] ?: ID_NONE, - uri = savedStateHandle[BaseActivity.EXTRA_DATA], - ) - - constructor(args: Bundle?) : this( - manga = args?.getParcelableCompat(KEY_MANGA)?.manga, - id = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE, - uri = null, - ) - - val mangaId: Long - get() = if (id != ID_NONE) id else manga?.id ?: uri?.lastPathSegment?.toLongOrNull() ?: ID_NONE - - companion object { - - 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/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt deleted file mode 100644 index 483776ef6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import android.net.Uri -import coil.request.CachePolicy -import dagger.Reusable -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.exception.NotFoundException -import org.koitharu.kotatsu.parsers.model.ContentType -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.almostEquals -import org.koitharu.kotatsu.parsers.util.levenshteinDistance -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.toRelativeUrl -import javax.inject.Inject - -@Reusable -class MangaLinkResolver @Inject constructor( - private val repositoryFactory: MangaRepository.Factory, - private val sourcesRepository: MangaSourcesRepository, - private val dataRepository: MangaDataRepository, -) { - - suspend fun resolve(uri: Uri): Manga { - return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") { - resolveAppLink(uri) - } else { - resolveExternalLink(uri) - } ?: throw NotFoundException("Cannot resolve link", uri.toString()) - } - - private suspend fun resolveAppLink(uri: Uri): Manga? { - require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } - val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } - val source = MangaSource(sourceName) - require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" } - val repo = repositoryFactory.create(source) - return repo.findExact( - url = uri.getQueryParameter("url"), - title = uri.getQueryParameter("name"), - ) - } - - private suspend fun resolveExternalLink(uri: Uri): Manga? { - dataRepository.findMangaByPublicUrl(uri.toString())?.let { - return it - } - val host = uri.host ?: return null - val repo = sourcesRepository.allMangaSources.asSequence() - .map { source -> - repositoryFactory.create(source) as RemoteMangaRepository - }.find { repo -> - host in repo.domains - } ?: return null - return repo.findExact(uri.toString().toRelativeUrl(host), null) - } - - private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? { - if (!title.isNullOrEmpty()) { - val list = getList(0, MangaListFilter.Search(title)) - if (url != null) { - list.find { it.url == url }?.let { - return it - } - } - list.minByOrNull { it.title.levenshteinDistance(title) } - ?.takeIf { it.title.almostEquals(title, 0.2f) } - ?.let { return it } - } - val seed = getDetailsNoCache( - getSeedManga(source, url ?: return null, title), - ) - return runCatchingCancellable { - val seedTitle = seed.title.ifEmpty { - seed.altTitle - }.ifNullOrEmpty { - seed.author - } ?: return@runCatchingCancellable null - val seedList = getList(0, MangaListFilter.Search(seedTitle)) - seedList.first { x -> x.url == url } - }.getOrThrow() - } - - private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga { - return if (this is RemoteMangaRepository) { - getDetails(manga, CachePolicy.READ_ONLY) - } else { - getDetails(manga) - } - } - - private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga( - id = run { - var h = 1125899906842597L - source.name.forEach { c -> - h = 31 * h + c.code - } - url.forEach { c -> - h = 31 * h + c.code - } - h - }, - title = title.orEmpty(), - altTitle = null, - url = url, - publicUrl = "", - rating = 0.0f, - isNsfw = source.contentType == ContentType.HENTAI, - coverUrl = "", - tags = emptySet(), - state = null, - author = null, - largeCoverUrl = null, - description = null, - chapters = null, - source = source, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt deleted file mode 100644 index 8971392d8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import androidx.annotation.AnyThread -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.MangaLoaderContext -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.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaPage -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.model.SortOrder -import java.lang.ref.WeakReference -import java.util.EnumMap -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.collections.set - -interface MangaRepository { - - val source: MangaSource - - val sortOrders: Set - - val states: Set - - val contentRatings: Set - - var defaultSortOrder: SortOrder - - val isMultipleTagsSupported: Boolean - - val isTagsExclusionSupported: Boolean - - val isSearchSupported: Boolean - - suspend fun getList(offset: Int, filter: MangaListFilter?): List - - suspend fun getDetails(manga: Manga): Manga - - suspend fun getPages(chapter: MangaChapter): List - - suspend fun getPageUrl(page: MangaPage): String - - suspend fun getTags(): Set - - suspend fun getLocales(): Set - - suspend fun getRelated(seed: Manga): List - - @Singleton - class Factory @Inject constructor( - private val localMangaRepository: LocalMangaRepository, - private val loaderContext: MangaLoaderContext, - private val contentCache: ContentCache, - private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, - ) { - - private val cache = EnumMap>(MangaSource::class.java) - - @AnyThread - fun create(source: MangaSource): MangaRepository { - if (source == MangaSource.LOCAL) { - return localMangaRepository - } - cache[source]?.get()?.let { return it } - return synchronized(cache) { - cache[source]?.get()?.let { return it } - val repository = RemoteMangaRepository( - parser = MangaParser(source, loaderContext), - cache = contentCache, - mirrorSwitchInterceptor = mirrorSwitchInterceptor, - ) - cache[source] = WeakReference(repository) - repository - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt deleted file mode 100644 index 217d38572..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ /dev/null @@ -1,231 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import android.util.Log -import coil.request.CachePolicy -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.currentCoroutineContext -import okhttp3.Headers -import okhttp3.Interceptor -import okhttp3.Response -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.cache.SafeDeferred -import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor -import org.koitharu.kotatsu.core.prefs.SourceSettings -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.parsers.MangaParser -import org.koitharu.kotatsu.parsers.MangaParserAuthProvider -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.exception.ParseException -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.Favicons -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaPage -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.model.SortOrder -import org.koitharu.kotatsu.parsers.util.domain -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.Locale - -class RemoteMangaRepository( - private val parser: MangaParser, - private val cache: ContentCache, - private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, -) : MangaRepository, Interceptor { - - override val source: MangaSource - get() = parser.source - - override val sortOrders: Set - get() = parser.availableSortOrders - - override val states: Set - get() = parser.availableStates - - override val contentRatings: Set - get() = parser.availableContentRating - - override var defaultSortOrder: SortOrder - get() = getConfig().defaultSortOrder ?: sortOrders.first() - set(value) { - getConfig().defaultSortOrder = value - } - - override val isMultipleTagsSupported: Boolean - get() = parser.isMultipleTagsSupported - - override val isSearchSupported: Boolean - get() = parser.isSearchSupported - - override val isTagsExclusionSupported: Boolean - get() = parser.isTagsExclusionSupported - - var domain: String - get() = parser.domain - set(value) { - getConfig()[parser.configKeyDomain] = value - } - - val domains: Array - get() = parser.configKeyDomain.presetValues - - val headers: Headers - get() = parser.headers - - override fun intercept(chain: Interceptor.Chain): Response { - return if (parser is Interceptor) { - parser.intercept(chain) - } else { - chain.proceed(chain.request()) - } - } - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { - return mirrorSwitchInterceptor.withMirrorSwitching { - parser.getList(offset, filter) - } - } - - override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) - - override suspend fun getPages(chapter: MangaChapter): List { - cache.getPages(source, chapter.url)?.let { return it } - val pages = asyncSafe { - mirrorSwitchInterceptor.withMirrorSwitching { - parser.getPages(chapter).distinctById() - } - } - cache.putPages(source, chapter.url, pages) - return pages.await() - } - - override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { - parser.getPageUrl(page) - } - - override suspend fun getTags(): Set = mirrorSwitchInterceptor.withMirrorSwitching { - parser.getAvailableTags() - } - - override suspend fun getLocales(): Set { - return parser.getAvailableLocales() - } - - suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { - parser.getFavicons() - } - - override suspend fun getRelated(seed: Manga): List { - cache.getRelatedManga(source, seed.url)?.let { return it } - val related = asyncSafe { - parser.getRelatedManga(seed).filterNot { it.id == seed.id } - } - cache.putRelatedManga(source, seed.url, related) - return related.await() - } - - suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga { - if (cachePolicy.readEnabled) { - cache.getDetails(source, manga.url)?.let { return it } - } - val details = asyncSafe { - mirrorSwitchInterceptor.withMirrorSwitching { - parser.getDetails(manga) - } - } - if (cachePolicy.writeEnabled) { - cache.putDetails(source, manga.url, details) - } - return details.await() - } - - suspend fun peekDetails(manga: Manga): Manga? { - return cache.getDetails(source, manga.url) - } - - suspend fun find(manga: Manga): Manga? { - val list = getList(0, MangaListFilter.Search(manga.title)) - return list.find { x -> x.id == manga.id } - } - - fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider - - fun getConfigKeys(): List> = ArrayList>().also { - parser.onCreateConfig(it) - } - - fun getAvailableMirrors(): List { - return parser.configKeyDomain.presetValues.toList() - } - - fun isSlowdownEnabled(): Boolean { - return getConfig().isSlowdownEnabled - } - - private fun getConfig() = parser.config as SourceSettings - - private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred { - var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] - if (dispatcher == null || dispatcher is MainCoroutineDispatcher) { - dispatcher = Dispatchers.Default - } - return SafeDeferred( - processLifecycleScope.async(dispatcher) { - runCatchingCancellable { block() } - }, - ) - } - - private fun List.distinctById(): List { - if (isEmpty()) { - return emptyList() - } - val result = ArrayList(size) - val set = HashSet(size) - for (page in this) { - if (set.add(page.id)) { - result.add(page) - } else if (BuildConfig.DEBUG) { - Log.w(null, "Duplicate page: $page") - } - } - return result - } - - private suspend fun MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R { - if (!isEnabled) { - return block() - } - val initialMirror = domain - val result = runCatchingCancellable { - block() - } - if (result.isValidResult()) { - return result.getOrThrow() - } - return if (trySwitchMirror(this@RemoteMangaRepository)) { - val newResult = runCatchingCancellable { - block() - } - if (newResult.isValidResult()) { - return newResult.getOrThrow() - } else { - rollback(this@RemoteMangaRepository, initialMirror) - return result.getOrThrow() - } - } else { - result.getOrThrow() - } - } - - private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException - && (getOrNull() as? Collection<*>)?.isEmpty() != true -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt deleted file mode 100644 index a430f1601..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ /dev/null @@ -1,195 +0,0 @@ -package org.koitharu.kotatsu.core.parser.favicon - -import android.content.Context -import android.net.Uri -import android.webkit.MimeTypeMap -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.disk.DiskCache -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.network.HttpException -import coil.request.Options -import coil.size.Size -import coil.size.pxOrElse -import kotlinx.coroutines.ensureActive -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.internal.closeQuietly -import okio.Closeable -import okio.buffer -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.local.data.util.withExtraCloseable -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.await -import java.net.HttpURLConnection -import kotlin.coroutines.coroutineContext - -private const val FALLBACK_SIZE = 9999 // largest icon - -class FaviconFetcher( - private val okHttpClient: OkHttpClient, - private val diskCache: Lazy, - private val mangaSource: MangaSource, - private val options: Options, - private val mangaRepositoryFactory: MangaRepository.Factory, -) : Fetcher { - - private val diskCacheKey - get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" - - private val fileSystem - get() = checkNotNull(diskCache.value).fileSystem - - override suspend fun fetch(): FetchResult { - getCached(options)?.let { return it } - val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository - val sizePx = maxOf( - options.size.width.pxOrElse { FALLBACK_SIZE }, - options.size.height.pxOrElse { FALLBACK_SIZE }, - ) - var favicons = repo.getFavicons() - var lastError: Exception? = null - while (favicons.isNotEmpty()) { - coroutineContext.ensureActive() - val icon = favicons.find(sizePx) ?: throwNSEE(lastError) - val response = try { - loadIcon(icon.url, mangaSource) - } catch (e: CloudFlareProtectedException) { - throw e - } catch (e: HttpException) { - lastError = e - favicons -= icon - continue - } - val responseBody = response.requireBody() - val source = writeToDiskCache(responseBody)?.toImageSource()?.also { - response.closeQuietly() - } ?: responseBody.toImageSource(response) - return SourceResult( - source = source, - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type), - dataSource = response.toDataSource(), - ) - } - throwNSEE(lastError) - } - - private suspend fun loadIcon(url: String, source: MangaSource): Response { - val request = Request.Builder() - .url(url) - .get() - .tag(MangaSource::class.java, source) - @Suppress("UNCHECKED_CAST") - options.tags.asMap().forEach { request.tag(it.key as Class, it.value) } - val response = okHttpClient.newCall(request.build()).await() - if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { - response.closeQuietly() - throw HttpException(response) - } - return response - } - - private fun getCached(options: Options): SourceResult? { - if (!options.diskCachePolicy.readEnabled) { - return null - } - val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null - return SourceResult( - source = snapshot.toImageSource(), - mimeType = null, - dataSource = DataSource.DISK, - ) - } - - private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? { - if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { - return null - } - val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null - try { - fileSystem.write(editor.data) { - writeAllCancellable(body.source()) - } - return editor.commitAndOpenSnapshot() - } catch (e: Throwable) { - try { - editor.abort() - } catch (abortingError: Throwable) { - e.addSuppressed(abortingError) - } - body.closeQuietly() - throw e - } finally { - body.closeQuietly() - } - } - - private fun DiskCache.Snapshot.toImageSource(): ImageSource { - return ImageSource(data, fileSystem, diskCacheKey, this) - } - - private fun ResponseBody.toImageSource(response: Closeable): ImageSource { - return ImageSource( - source().withExtraCloseable(response).buffer(), - options.context, - FaviconMetadata(mangaSource), - ) - } - - private fun Response.toDataSource(): DataSource { - return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK - } - - private fun Response.requireBody(): ResponseBody { - return checkNotNull(body) { "response body == null" } - } - - private fun Size.toCacheKey() = buildString { - append(width.toString()) - append('x') - append(height.toString()) - } - - private fun throwNSEE(lastError: Exception?): Nothing { - if (lastError != null) { - throw lastError - } else { - throw NoSuchElementException("No favicons found") - } - } - - class Factory( - context: Context, - private val okHttpClient: OkHttpClient, - private val mangaRepositoryFactory: MangaRepository.Factory, - ) : Fetcher.Factory { - - private val diskCache = lazy { - val rootDir = context.externalCacheDir ?: context.cacheDir - DiskCache.Builder() - .directory(rootDir.resolve(CacheDir.FAVICONS.dir)) - .build() - } - - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { - return if (data.scheme == URI_SCHEME_FAVICON) { - val mangaSource = MangaSource(data.schemeSpecificPart) - FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory) - } else { - null - } - } - } - - class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt deleted file mode 100644 index 48f393325..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.core.parser.favicon - -import android.net.Uri -import org.koitharu.kotatsu.parsers.model.MangaSource - -const val URI_SCHEME_FAVICON = "favicon" - -fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null) \ No newline at end of file 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 deleted file mode 100644 index c23523b3f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ /dev/null @@ -1,571 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import android.content.Context -import android.content.SharedPreferences -import android.net.ConnectivityManager -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.annotation.FloatRange -import androidx.appcompat.app.AppCompatDelegate -import androidx.collection.ArraySet -import androidx.core.content.edit -import androidx.core.os.LocaleListCompat -import androidx.preference.PreferenceManager -import dagger.hilt.android.qualifiers.ApplicationContext -import org.json.JSONArray -import org.koitharu.kotatsu.core.model.ZoomMode -import org.koitharu.kotatsu.core.network.DoHProvider -import org.koitharu.kotatsu.core.util.ext.connectivityManager -import org.koitharu.kotatsu.core.util.ext.getEnumValue -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.putEnumValue -import org.koitharu.kotatsu.core.util.ext.takeIfReadable -import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.explore.data.SourcesSortOrder -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.find -import org.koitharu.kotatsu.parsers.util.mapNotNullToSet -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.reader.domain.ReaderColorFilter -import java.io.File -import java.net.Proxy -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AppSettings @Inject constructor(@ApplicationContext context: Context) { - - private val prefs = PreferenceManager.getDefaultSharedPreferences(context) - private val connectivityManager = context.connectivityManager - - var listMode: ListMode - get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) - set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } - - val theme: Int - get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() - ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - - val colorScheme: ColorScheme - get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default) - - val isAmoledTheme: Boolean - get() = prefs.getBoolean(KEY_THEME_AMOLED, false) - - var mainNavItems: List - get() { - val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',') - return if (raw.isNullOrEmpty()) { - listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED) - } else { - raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) } - } - } - set(value) { - prefs.edit { - putString(KEY_NAV_MAIN, value.joinToString(",") { it.name }) - } - } - - var gridSize: Int - get() = prefs.getInt(KEY_GRID_SIZE, 100) - set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) } - - var historyListMode: ListMode - get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode) - set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) } - - var suggestionsListMode: ListMode - get() = prefs.getEnumValue(KEY_LIST_MODE_SUGGESTIONS, listMode) - set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_SUGGESTIONS, value) } - - var favoritesListMode: ListMode - get() = prefs.getEnumValue(KEY_LIST_MODE_FAVORITES, listMode) - set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_FAVORITES, value) } - - var isNsfwContentDisabled: Boolean - get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) - set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } - - var appLocales: LocaleListCompat - get() { - val raw = prefs.getString(KEY_APP_LOCALE, null) - return LocaleListCompat.forLanguageTags(raw) - } - set(value) { - prefs.edit { - putString(KEY_APP_LOCALE, value.toLanguageTags()) - } - } - - val readerPageSwitch: Set - get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) - - val isReaderZoomButtonsEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false) - - val isReaderTapsAdaptive: Boolean - get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) - - val isReaderOptimizationEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) - - var isTrafficWarningEnabled: Boolean - get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) - set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } - - var isAllFavouritesVisible: Boolean - get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) - set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } - - val isTrackerEnabled: Boolean - get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) - - val isTrackerWifiOnly: Boolean - get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false) - - val isTrackerNotificationsEnabled: Boolean - get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) - - var notificationSound: Uri - get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() - ?: Settings.System.DEFAULT_NOTIFICATION_URI - set(value) = prefs.edit { putString(KEY_NOTIFICATIONS_SOUND, value.toString()) } - - val notificationVibrate: Boolean - get() = prefs.getBoolean(KEY_NOTIFICATIONS_VIBRATE, false) - - val notificationLight: Boolean - get() = prefs.getBoolean(KEY_NOTIFICATIONS_LIGHT, true) - - val readerAnimation: ReaderAnimation - get() = prefs.getEnumValue(KEY_READER_ANIMATION, ReaderAnimation.DEFAULT) - - val readerBackground: ReaderBackground - get() = prefs.getEnumValue(KEY_READER_BACKGROUND, ReaderBackground.DEFAULT) - - val defaultReaderMode: ReaderMode - get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD) - - val isReaderModeDetectionEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true) - - var isHistoryGroupingEnabled: Boolean - get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true) - set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) } - - val isReadingIndicatorsEnabled: Boolean - get() = prefs.getBoolean(KEY_READING_INDICATORS, true) - - val isHistoryExcludeNsfw: Boolean - get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false) - - var isIncognitoModeEnabled: Boolean - get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false) - set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) } - - var chaptersReverse: Boolean - get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false) - set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) } - - val zoomMode: ZoomMode - get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER) - - val trackSources: Set - get() = prefs.getStringSet(KEY_TRACK_SOURCES, null) ?: setOf(TRACK_FAVOURITES) - - var appPassword: String? - get() = prefs.getString(KEY_APP_PASSWORD, null) - set(value) = prefs.edit { - if (value != null) putString(KEY_APP_PASSWORD, value) else remove( - KEY_APP_PASSWORD, - ) - } - - val isLoggingEnabled: Boolean - get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false) - - var isBiometricProtectionEnabled: Boolean - get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) - set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } - - val isMirrorSwitchingAvailable: Boolean - get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false) - - val isExitConfirmationEnabled: Boolean - get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false) - - val isDynamicShortcutsEnabled: Boolean - get() = prefs.getBoolean(KEY_SHORTCUTS, true) - - val isUnstableUpdatesAllowed: Boolean - get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false) - - val defaultDetailsTab: Int - get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0 - - val isContentPrefetchEnabled: Boolean - get() { - if (isBackgroundNetworkRestricted()) { - return false - } - val policy = - NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) - return policy.isNetworkAllowed(connectivityManager) - } - - var sourcesSortOrder: SourcesSortOrder - get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL) - set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) } - - var isSourcesGridMode: Boolean - get() = prefs.getBoolean(KEY_SOURCES_GRID, false) - set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } - - val isNewSourcesTipEnabled: Boolean - get() = prefs.getBoolean(KEY_SOURCES_NEW, true) - - val isPagesNumbersEnabled: Boolean - get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) - - val screenshotsPolicy: ScreenshotsPolicy - get() = runCatching { - val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT) - if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key) - }.getOrDefault(ScreenshotsPolicy.ALLOW) - - var userSpecifiedMangaDirectories: Set - get() { - val set = prefs.getStringSet(KEY_LOCAL_MANGA_DIRS, emptySet()).orEmpty() - return set.mapNotNullToSet { File(it).takeIfReadable() } - } - set(value) { - val set = value.mapToSet { it.absolutePath } - prefs.edit { putStringSet(KEY_LOCAL_MANGA_DIRS, set) } - } - - var mangaStorageDir: File? - get() = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { - File(it) - }?.takeIf { it.exists() && it in userSpecifiedMangaDirectories } - set(value) = prefs.edit { - if (value == null) { - remove(KEY_LOCAL_STORAGE) - } else { - val userDirs = userSpecifiedMangaDirectories - if (value !in userDirs) { - userSpecifiedMangaDirectories = userDirs + value - } - putString(KEY_LOCAL_STORAGE, value.path) - } - } - - val isDownloadsWiFiOnly: Boolean - get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) - - var isSuggestionsEnabled: Boolean - get() = prefs.getBoolean(KEY_SUGGESTIONS, false) - set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) } - - val isSuggestionsWiFiOnly: Boolean - get() = prefs.getBoolean(KEY_SUGGESTIONS_WIFI_ONLY, false) - - val isSuggestionsExcludeNsfw: Boolean - get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) - - val isSuggestionsNotificationAvailable: Boolean - get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, false) - - val suggestionsTagsBlacklist: Set - get() { - val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') - if (string.isNullOrEmpty()) { - return emptySet() - } - return string.split(',').mapToSet { it.trim() } - } - - val isReaderBarEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_BAR, true) - - val isReaderSliderEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_SLIDER, true) - - val isReaderKeepScreenOn: Boolean - get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) - - var readerColorFilter: ReaderColorFilter? - get() = runCatching { - val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness) - val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast) - val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted) - val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale) - ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty } - }.getOrNull() - set(value) { - prefs.edit { - val cf = value ?: ReaderColorFilter.EMPTY - putFloat(KEY_CF_BRIGHTNESS, cf.brightness) - putFloat(KEY_CF_CONTRAST, cf.contrast) - putBoolean(KEY_CF_INVERTED, cf.isInverted) - putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale) - } - } - - val isImagesProxyEnabled: Boolean - get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) - - val dnsOverHttps: DoHProvider - get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) - - val isSSLBypassEnabled: Boolean - get() = prefs.getBoolean(KEY_SSL_BYPASS, false) - - val proxyType: Proxy.Type - get() { - val raw = prefs.getString(KEY_PROXY_TYPE, null) ?: return Proxy.Type.DIRECT - return enumValues().find { it.name == raw } ?: Proxy.Type.DIRECT - } - - val proxyAddress: String? - get() = prefs.getString(KEY_PROXY_ADDRESS, null) - - val proxyPort: Int - get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0 - - val proxyLogin: String? - get() = prefs.getString(KEY_PROXY_LOGIN, null)?.takeUnless { it.isEmpty() } - - val proxyPassword: String? - get() = prefs.getString(KEY_PROXY_PASSWORD, null)?.takeUnless { it.isEmpty() } - - var localListOrder: SortOrder - get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST) - set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) } - - var historySortOrder: ListSortOrder - get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED) - set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) } - - val isRelatedMangaEnabled: Boolean - get() = prefs.getBoolean(KEY_RELATED_MANGA, true) - - val isWebtoonZoomEnable: Boolean - get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true) - - @get:FloatRange(from = 0.0, to = 1.0) - var readerAutoscrollSpeed: Float - get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f) - set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { - putFloat( - KEY_READER_AUTOSCROLL_SPEED, - value, - ) - } - - val isPagesPreloadEnabled: Boolean - get() { - if (isBackgroundNetworkRestricted()) { - return false - } - val policy = NetworkPolicy.from( - prefs.getString(KEY_PAGES_PRELOAD, null), - NetworkPolicy.NON_METERED, - ) - return policy.isNetworkAllowed(connectivityManager) - } - - val is32BitColorsEnabled: Boolean - get() = prefs.getBoolean(KEY_32BIT_COLOR, false) - - val isPeriodicalBackupEnabled: Boolean - get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) - - val periodicalBackupFrequency: Long - get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L - - var periodicalBackupOutput: Uri? - get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() - set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } - - fun isTipEnabled(tip: String): Boolean { - return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true - } - - fun closeTip(tip: String) { - val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty() - if (tip in closedTips) { - return - } - prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) } - } - - fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - prefs.registerOnSharedPreferenceChangeListener(listener) - } - - fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - prefs.unregisterOnSharedPreferenceChangeListener(listener) - } - - fun observe() = prefs.observe() - - fun getAllValues(): Map = prefs.all - - fun upsertAll(m: Map) { - prefs.edit { - m.forEach { e -> - when (val v = e.value) { - is Boolean -> putBoolean(e.key, v) - is Int -> putInt(e.key, v) - is Long -> putLong(e.key, v) - is Float -> putFloat(e.key, v) - is String -> putString(e.key, v) - is JSONArray -> putStringSet(e.key, v.toStringSet()) - } - } - } - } - - private fun isBackgroundNetworkRestricted(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED - } else { - false - } - } - - private fun JSONArray.toStringSet(): Set { - val len = length() - val result = ArraySet(len) - for (i in 0 until len) { - result.add(getString(i)) - } - return result - } - - companion object { - - const val PAGE_SWITCH_TAPS = "taps" - const val PAGE_SWITCH_VOLUME_KEYS = "volume" - - const val TRACK_HISTORY = "history" - const val TRACK_FAVOURITES = "favourites" - - const val KEY_LIST_MODE = "list_mode_2" - const val KEY_LIST_MODE_HISTORY = "list_mode_history" - const val KEY_LIST_MODE_FAVORITES = "list_mode_favorites" - const val KEY_LIST_MODE_SUGGESTIONS = "list_mode_suggestions" - const val KEY_THEME = "theme" - const val KEY_COLOR_THEME = "color_theme" - const val KEY_THEME_AMOLED = "amoled_theme" - const val KEY_TRAFFIC_WARNING = "traffic_warning" - const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" - const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" - const val KEY_COOKIES_CLEAR = "cookies_clear" - const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" - const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" - const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" - const val KEY_GRID_SIZE = "grid_size" - const val KEY_REMOTE_SOURCES = "remote_sources" - const val KEY_LOCAL_STORAGE = "local_storage" - const val KEY_READER_SWITCHERS = "reader_switchers" - const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons" - const val KEY_TRACKER_ENABLED = "tracker_enabled" - const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" - const val KEY_TRACK_SOURCES = "track_sources" - const val KEY_TRACK_CATEGORIES = "track_categories" - const val KEY_TRACK_WARNING = "track_warning" - const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" - const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" - const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" - const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" - 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_MODE = "reader_mode" - const val KEY_READER_MODE_DETECT = "reader_mode_detect" - const val KEY_APP_PASSWORD = "app_password" - const val KEY_PROTECT_APP = "protect_app" - const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" - const val KEY_APP_VERSION = "app_version" - const val KEY_ZOOM_MODE = "zoom_mode" - const val KEY_BACKUP = "backup" - const val KEY_RESTORE = "restore" - const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" - const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" - const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output" - const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" - const val KEY_HISTORY_GROUPING = "history_grouping" - const val KEY_READING_INDICATORS = "reading_indicators" - const val KEY_REVERSE_CHAPTERS = "reverse_chapters" - const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" - const val KEY_PAGES_NUMBERS = "pages_numbers" - const val KEY_SCREENSHOTS_POLICY = "screenshots_policy" - const val KEY_PAGES_PRELOAD = "pages_preload" - const val KEY_SUGGESTIONS = "suggestions" - const val KEY_SUGGESTIONS_WIFI_ONLY = "suggestions_wifi" - const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" - const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" - const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications" - const val KEY_SHIKIMORI = "shikimori" - const val KEY_ANILIST = "anilist" - const val KEY_MAL = "mal" - const val KEY_DOWNLOADS_WIFI = "downloads_wifi" - const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" - const val KEY_DOH = "doh" - const val KEY_EXIT_CONFIRM = "exit_confirm" - const val KEY_INCOGNITO_MODE = "incognito" - 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_BACKGROUND = "reader_background" - const val KEY_READER_SCREEN_ON = "reader_screen_on" - const val KEY_SHORTCUTS = "dynamic_shortcuts" - const val KEY_READER_TAPS_LTR = "reader_taps_ltr" - const val KEY_READER_OPTIMIZE = "reader_optimize" - const val KEY_LOCAL_LIST_ORDER = "local_order" - const val KEY_HISTORY_ORDER = "history_order" - const val KEY_WEBTOON_ZOOM = "webtoon_zoom" - const val KEY_PREFETCH_CONTENT = "prefetch_content" - const val KEY_APP_LOCALE = "app_locale" - const val KEY_LOGGING_ENABLED = "logging" - const val KEY_LOGS_SHARE = "logs_share" - const val KEY_SOURCES_GRID = "sources_grid" - const val KEY_SOURCES_NEW = "sources_new" - const val KEY_UPDATES_UNSTABLE = "updates_unstable" - const val KEY_TIPS_CLOSED = "tips_closed" - const val KEY_SSL_BYPASS = "ssl_bypass" - const val KEY_READER_AUTOSCROLL_SPEED = "as_speed" - const val KEY_MIRROR_SWITCHING = "mirror_switching" - const val KEY_PROXY = "proxy" - const val KEY_PROXY_TYPE = "proxy_type" - const val KEY_PROXY_ADDRESS = "proxy_address" - const val KEY_PROXY_PORT = "proxy_port" - const val KEY_PROXY_AUTH = "proxy_auth" - const val KEY_PROXY_LOGIN = "proxy_login" - const val KEY_PROXY_PASSWORD = "proxy_password" - const val KEY_IMAGES_PROXY = "images_proxy" - const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" - const val KEY_DISABLE_NSFW = "no_nsfw" - const val KEY_RELATED_MANGA = "related_manga" - const val KEY_NAV_MAIN = "nav_main" - const val KEY_32BIT_COLOR = "enhanced_colors" - const val KEY_SOURCES_ORDER = "sources_sort_order" - const val KEY_SOURCES_CATALOG = "sources_catalog" - const val KEY_CF_BRIGHTNESS = "cf_brightness" - const val KEY_CF_CONTRAST = "cf_contrast" - const val KEY_CF_INVERTED = "cf_inverted" - const val KEY_CF_GRAYSCALE = "cf_grayscale" - const val KEY_IGNORE_DOZE = "ignore_dose" - const val KEY_DETAILS_TAB = "details_tab" - - // About - const val KEY_APP_UPDATE = "app_update" - const val KEY_APP_TRANSLATION = "about_app_translation" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt deleted file mode 100644 index ed6d14f68..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transform - -fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { - var lastValue: T = valueProducer() - emit(lastValue) - observe().collect { - if (it == key) { - val value = valueProducer() - if (value != lastValue) { - emit(value) - } - lastValue = value - } - } -} - -fun AppSettings.observeAsStateFlow( - scope: CoroutineScope, - key: String, - valueProducer: AppSettings.() -> T, -): StateFlow = observe().transform { - if (it == key) { - emit(valueProducer()) - } -}.stateIn(scope, SharingStarted.Eagerly, valueProducer()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt deleted file mode 100644 index 5ba52e13d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.os.Build -import androidx.core.content.edit - -private const val CATEGORY_ID = "cat_id" -private const val BACKGROUND = "bg" - -class AppWidgetConfig( - context: Context, - cls: Class, - val widgetId: Int, -) { - - private val prefs = context.getSharedPreferences("appwidget_${cls.simpleName}_$widgetId", Context.MODE_PRIVATE) - - var categoryId: Long - get() = prefs.getLong(CATEGORY_ID, 0L) - set(value) = prefs.edit { putLong(CATEGORY_ID, value) } - - var hasBackground: Boolean - get() = prefs.getBoolean(BACKGROUND, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) - set(value) = prefs.edit { putBoolean(BACKGROUND, value) } - - fun clear() { - prefs.edit { clear() } - } - - fun copyFrom(other: AppWidgetConfig) { - prefs.edit { - clear() - putLong(CATEGORY_ID, other.categoryId) - putBoolean(BACKGROUND, other.hasBackground) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt deleted file mode 100644 index c2ffee581..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ColorScheme.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import androidx.annotation.StringRes -import androidx.annotation.StyleRes -import com.google.android.material.color.DynamicColors -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.parsers.util.find - -enum class ColorScheme( - @StyleRes val styleResId: Int, - @StringRes val titleResId: Int, -) { - - DEFAULT(R.style.Theme_Kotatsu, R.string.system_default), - MONET(R.style.Theme_Kotatsu_Monet, R.string.theme_name_dynamic), - MIKU(R.style.Theme_Kotatsu_Miku, R.string.theme_name_miku), - RENA(R.style.Theme_Kotatsu_Asuka, R.string.theme_name_asuka), - FROG(R.style.Theme_Kotatsu_Mion, R.string.theme_name_mion), - BLUEBERRY(R.style.Theme_Kotatsu_Rikka, R.string.theme_name_rikka), - NAME2(R.style.Theme_Kotatsu_Sakura, R.string.theme_name_sakura), - MAMIMI(R.style.Theme_Kotatsu_Mamimi, R.string.theme_name_mamimi), - KANADE(R.style.Theme_Kotatsu_Kanade, R.string.theme_name_kanade) - ; - - companion object { - - val default: ColorScheme - get() = if (DynamicColors.isDynamicColorAvailable()) { - MONET - } else { - DEFAULT - } - - fun getAvailableList(): List { - val list = ColorScheme.entries.toMutableList() - if (!DynamicColors.isDynamicColorAvailable()) { - list.remove(MONET) - } - return list - } - - fun safeValueOf(name: String): ColorScheme? { - return ColorScheme.entries.find(name) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt deleted file mode 100644 index 691c4ed59..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NavItem.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import androidx.annotation.DrawableRes -import androidx.annotation.IdRes -import androidx.annotation.StringRes -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.list.ui.model.ListModel - -enum class NavItem( - @IdRes val id: Int, - @StringRes val title: Int, - @DrawableRes val icon: Int, -) : ListModel { - - HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector), - FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector), - LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector), - EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector), - SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector), - FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector), - BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector), - ; - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is NavItem && ordinal == other.ordinal - } - - fun isAvailable(settings: AppSettings): Boolean = when (this) { - SUGGESTIONS -> settings.isSuggestionsEnabled - FEED -> settings.isTrackerEnabled - else -> true - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt deleted file mode 100644 index fc5556801..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/NetworkPolicy.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import android.net.ConnectivityManager - -enum class NetworkPolicy( - private val key: Int, -) { - - NEVER(0), - ALWAYS(1), - NON_METERED(2); - - fun isNetworkAllowed(cm: ConnectivityManager) = when (this) { - NEVER -> false - ALWAYS -> true - NON_METERED -> !cm.isActiveNetworkMetered - } - - companion object { - - fun from(key: String?, default: NetworkPolicy): NetworkPolicy { - val intKey = key?.toIntOrNull() ?: return default - return NetworkPolicy.entries.find { it.key == intKey } ?: default - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt deleted file mode 100644 index e95559322..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderAnimation.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -enum class ReaderAnimation { - - // Do not rename this - NONE, DEFAULT, ADVANCED; -} 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 deleted file mode 100644 index 5422b0322..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.core.prefs - -import android.content.Context -import android.view.ContextThemeWrapper -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable -import org.koitharu.kotatsu.core.util.ext.getThemeDrawable -import com.google.android.material.R as materialR - -enum class ReaderBackground { - - DEFAULT, LIGHT, DARK, WHITE, BLACK; - - fun resolve(context: Context) = when (this) { - DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground) - LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light) - .getThemeDrawable(android.R.attr.windowBackground) - - DARK -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Dark) - .getThemeDrawable(android.R.attr.windowBackground) - - WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable() - BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt deleted file mode 100644 index 6809043ef..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/AlertDialogFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.CallSuper -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import androidx.viewbinding.ViewBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -abstract class AlertDialogFragment : DialogFragment() { - - var viewBinding: B? = null - private set - - @Deprecated("", ReplaceWith("requireViewBinding()")) - protected val binding: B - get() = requireViewBinding() - - final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = onCreateViewBinding(layoutInflater, null) - viewBinding = binding - return MaterialAlertDialogBuilder(requireContext(), theme) - .setView(binding.root) - .run(::onBuildDialog) - .create() - .also(::onDialogCreated) - } - - final override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = viewBinding?.root - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewBindingCreated(requireViewBinding(), savedInstanceState) - } - - @CallSuper - override fun onDestroyView() { - viewBinding = null - super.onDestroyView() - } - - open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder - - open fun onDialogCreated(dialog: AlertDialog) = Unit - - @Deprecated("", ReplaceWith("viewBinding")) - protected fun bindingOrNull() = viewBinding - - fun requireViewBinding(): B = checkNotNull(viewBinding) { - "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." - } - - protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B - - protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseAppWidgetProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseAppWidgetProvider.kt deleted file mode 100644 index cddc4af55..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseAppWidgetProvider.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.widget.RemoteViews -import androidx.annotation.CallSuper -import org.koitharu.kotatsu.core.prefs.AppWidgetConfig - -abstract class BaseAppWidgetProvider : AppWidgetProvider() { - - @CallSuper - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - appWidgetIds.forEach { id -> - val config = AppWidgetConfig(context, javaClass, id) - val views = onUpdateWidget(context, config) - appWidgetManager.updateAppWidget(id, views) - } - } - - override fun onDeleted(context: Context, appWidgetIds: IntArray) { - super.onDeleted(context, appWidgetIds) - for (id in appWidgetIds) { - AppWidgetConfig(context, javaClass, id).clear() - } - } - - override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { - super.onRestored(context, oldWidgetIds, newWidgetIds) - if (oldWidgetIds.size != newWidgetIds.size) { - return - } - for (i in oldWidgetIds.indices) { - val oldId = oldWidgetIds[i] - val newId = newWidgetIds[i] - val oldConfig = AppWidgetConfig(context, javaClass, oldId) - val newConfig = AppWidgetConfig(context, javaClass, newId) - newConfig.copyFrom(oldConfig) - oldConfig.clear() - } - } - - protected abstract fun onUpdateWidget( - context: Context, - config: AppWidgetConfig, - ): RemoteViews -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt deleted file mode 100644 index 0809799b9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.viewbinding.ViewBinding -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate - -@Suppress("LeakingThis") -abstract class BaseFragment : - Fragment(), - WindowInsetsDelegate.WindowInsetsListener { - - var viewBinding: B? = null - private set - - @Deprecated("", ReplaceWith("requireViewBinding()")) - protected val binding: B - get() = requireViewBinding() - - @JvmField - protected val exceptionResolver = ExceptionResolver(this) - - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() - - protected val actionModeDelegate: ActionModeDelegate - get() = (requireActivity() as BaseActivity<*>).actionModeDelegate - - final override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = onCreateViewBinding(inflater, container) - viewBinding = binding - return binding.root - } - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - insetsDelegate.onViewCreated(view) - insetsDelegate.addInsetsListener(this) - onViewBindingCreated(requireViewBinding(), savedInstanceState) - } - - override fun onDestroyView() { - viewBinding = null - insetsDelegate.removeInsetsListener(this) - insetsDelegate.onDestroyView() - super.onDestroyView() - } - - fun requireViewBinding(): B = checkNotNull(viewBinding) { - "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." - } - - @Deprecated("", ReplaceWith("viewBinding")) - protected fun bindingOrNull() = viewBinding - - protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B - - protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt deleted file mode 100644 index 7852c1b50..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.WindowManager -import androidx.core.content.ContextCompat -import androidx.viewbinding.ViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.util.SystemUiController - -abstract class BaseFullscreenActivity : - BaseActivity() { - - protected lateinit var systemUiController: SystemUiController - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - with(window) { - systemUiController = SystemUiController(this) - statusBarColor = Color.TRANSPARENT - navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - ContextCompat.getColor(this@BaseFullscreenActivity, R.color.dim) - } else { - Color.TRANSPARENT - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - attributes.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } - } - // insetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - systemUiController.setSystemUiVisible(true) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt deleted file mode 100644 index 8b8cec8a4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseListAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.AsyncListDiffer.ListListener -import com.hannesdorfmann.adapterdelegates4.AdapterDelegate -import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.flow.FlowCollector -import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.model.ListModel -import kotlin.coroutines.suspendCoroutine - -open class BaseListAdapter : AsyncListDifferDelegationAdapter( - AsyncDifferConfig.Builder(ListModelDiffCallback()) - .setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor()) - .build(), -), FlowCollector?> { - - override suspend fun emit(value: List?) = suspendCoroutine { cont -> - setItems(value.orEmpty(), ContinuationResumeRunnable(cont)) - } - - fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): BaseListAdapter { - delegatesManager.addDelegate(type.ordinal, delegate) - return this - } - - fun addListListener(listListener: ListListener) { - differ.addListListener(listListener) - } - - fun removeListListener(listListener: ListListener) { - differ.removeListListener(listListener) - } -} 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 deleted file mode 100644 index aabadc67a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.preference.PreferenceFragmentCompat -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.settings.SettingsActivity -import javax.inject.Inject - -@AndroidEntryPoint -abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : - PreferenceFragmentCompat(), - WindowInsetsDelegate.WindowInsetsListener, - RecyclerViewOwner { - - @Inject - lateinit var settings: AppSettings - - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() - - override val recyclerView: RecyclerView - get() = listView - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val themedContext = (view.parentView ?: view).context - view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground)) - listView.clipToPadding = false - insetsDelegate.onViewCreated(view) - insetsDelegate.addInsetsListener(this) - } - - override fun onDestroyView() { - insetsDelegate.removeInsetsListener(this) - insetsDelegate.onDestroyView() - super.onDestroyView() - } - - override fun onResume() { - super.onResume() - setTitle(if (titleId != 0) getString(titleId) else null) - } - - @CallSuper - override fun onWindowInsetsChanged(insets: Insets) { - listView.updatePadding( - bottom = insets.bottom, - ) - } - - protected fun setTitle(title: CharSequence?) { - (activity as? SettingsActivity)?.setSectionTitle(title) - } - - protected fun startActivitySafe(intent: Intent) { - try { - startActivity(intent) - } catch (_: ActivityNotFoundException) { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt deleted file mode 100644 index 7a8f1463c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import androidx.lifecycle.LifecycleService - -abstract class BaseService : LifecycleService() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt deleted file mode 100644 index 244636ded..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.util.ext.EventFlow -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 kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -abstract class BaseViewModel : ViewModel() { - - @JvmField - protected val loadingCounter = MutableStateFlow(0) - - @JvmField - protected val errorEvent = MutableEventFlow() - - val onError: EventFlow - get() = errorEvent - - val isLoading: StateFlow = loadingCounter.map { it > 0 } - .stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0) - - protected fun launchJob( - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit - ): Job = viewModelScope.launch(context + createErrorHandler(), start, block) - - protected fun launchLoadingJob( - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit - ): Job = viewModelScope.launch(context + createErrorHandler(), start) { - loadingCounter.increment() - try { - block() - } finally { - loadingCounter.decrement() - } - } - - protected fun Flow.withLoading() = onStart { - loadingCounter.increment() - }.onCompletion { - loadingCounter.decrement() - } - - protected fun Flow.withErrorHandling() = catch { error -> - errorEvent.call(error) - } - - protected fun MutableStateFlow.increment() = update { it + 1 } - - protected fun MutableStateFlow.decrement() = update { it - 1 } - - private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> - throwable.printStackTraceDebug() - if (throwable !is CancellationException) { - errorEvent.call(throwable) - } - } -} 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 deleted file mode 100644 index 2d4ead31d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import android.content.Intent -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -abstract class CoroutineIntentService : BaseService() { - - private val mutex = Mutex() - protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default - - final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - launchCoroutine(intent, startId) - return START_REDELIVER_INTENT - } - - private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) { - mutex.withLock { - try { - if (intent != null) { - withContext(dispatcher) { - processIntent(startId, intent) - } - } - } catch (e: Throwable) { - e.printStackTraceDebug() - onError(startId, e) - } finally { - stopSelf(startId) - } - } - } - - protected abstract suspend fun processIntent(startId: Int, intent: Intent) - - protected abstract fun onError(startId: Int, error: Throwable) - - private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable -> - throwable.printStackTraceDebug() - onError(startId, throwable) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt deleted file mode 100644 index 06a744fd9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import androidx.recyclerview.widget.AsyncListDiffer.ListListener -import androidx.recyclerview.widget.DiffUtil -import com.hannesdorfmann.adapterdelegates4.AdapterDelegate -import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.model.ListModel -import java.util.Collections -import java.util.LinkedList - -open class ReorderableListAdapter : ListDelegationAdapter>(), FlowCollector?> { - - private val listListeners = LinkedList>() - - override suspend fun emit(value: List?) { - val oldList = items.orEmpty() - val newList = value.orEmpty() - val diffResult = withContext(Dispatchers.Default) { - val diffCallback = DiffCallback(oldList, newList) - DiffUtil.calculateDiff(diffCallback) - } - super.setItems(newList) - diffResult.dispatchUpdatesTo(this) - listListeners.forEach { it.onCurrentListChanged(oldList, newList) } - } - - @Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR) - override fun setItems(items: List?) { - super.setItems(items) - } - - fun reorderItems(oldPos: Int, newPos: Int) { - Collections.swap(items ?: return, oldPos, newPos) - notifyItemMoved(oldPos, newPos) - } - - fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): ReorderableListAdapter { - delegatesManager.addDelegate(type.ordinal, delegate) - return this - } - - fun addListListener(listListener: ListListener) { - listListeners.add(listListener) - } - - fun removeListListener(listListener: ListListener) { - listListeners.remove(listListener) - } - - protected class DiffCallback( - val oldList: List, - val newList: List, - ) : DiffUtil.Callback() { - - override fun getOldListSize(): Int = oldList.size - - override fun getNewListSize(): Int = newList.size - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - return newItem.areItemsTheSame(oldItem) - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val oldItem = oldList[oldItemPosition] - val newItem = newList[newItemPosition] - return newItem == oldItem - } - } -} 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 deleted file mode 100644 index 3e8048c4a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.koitharu.kotatsu.core.ui.dialog - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.text.HtmlCompat -import androidx.core.text.htmlEncode -import androidx.core.text.method.LinkMovementMethodCompat -import androidx.core.text.parseAsHtml -import androidx.fragment.app.FragmentManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -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() { - - private lateinit var exception: Throwable - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val args = requireArguments() - exception = args.requireSerializable(ARG_ERROR) - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding { - return DialogErrorDetailsBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.textViewMessage) { - movementMethod = LinkMovementMethodCompat.getInstance() - text = context.getString( - R.string.manga_error_description_pattern, - exception.message?.htmlEncode().orEmpty(), - arguments?.getString(ARG_URL), - ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY) - } - } - - @Suppress("NAME_SHADOWING") - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - val builder = super.onBuildDialog(builder) - .setCancelable(true) - .setNegativeButton(android.R.string.cancel, null) - .setTitle(R.string.error_occurred) - .setNeutralButton(androidx.preference.R.string.copy) { _, _ -> - copyToClipboard() - } - if (exception.isReportable()) { - builder.setPositiveButton(R.string.report) { _, _ -> - dismiss() - exception.report() - } - } - return builder - } - - private fun copyToClipboard() { - val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - ?: return - clipboardManager.setPrimaryClip( - ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()), - ) - } - - 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/dialog/RecyclerViewAlertDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt deleted file mode 100644 index 3199138e4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RecyclerViewAlertDialog.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.koitharu.kotatsu.core.ui.dialog - -import android.content.Context -import android.content.DialogInterface -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.hannesdorfmann.adapterdelegates4.AdapterDelegate -import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager -import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter -import org.koitharu.kotatsu.R - -class RecyclerViewAlertDialog private constructor( - private val delegate: AlertDialog -) : DialogInterface by delegate { - - fun show() = delegate.show() - - class Builder(context: Context) { - - private val recyclerView = RecyclerView(context) - private val delegatesManager = AdapterDelegatesManager>() - private var items: List? = null - - private val delegate = MaterialAlertDialogBuilder(context) - .setView(recyclerView) - - init { - recyclerView.layoutManager = LinearLayoutManager(context) - recyclerView.updatePadding( - top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing), - ) - recyclerView.clipToPadding = false - } - - fun setTitle(@StringRes titleResId: Int): Builder { - delegate.setTitle(titleResId) - return this - } - - fun setTitle(title: CharSequence): Builder { - delegate.setTitle(title) - return this - } - - fun setIcon(@DrawableRes iconId: Int): Builder { - delegate.setIcon(iconId) - return this - } - - fun setPositiveButton( - @StringRes textId: Int, - listener: DialogInterface.OnClickListener, - ): Builder { - delegate.setPositiveButton(textId, listener) - return this - } - - fun setNegativeButton( - @StringRes textId: Int, - listener: DialogInterface.OnClickListener? = null - ): Builder { - delegate.setNegativeButton(textId, listener) - return this - } - - fun setCancelable(isCancelable: Boolean): Builder { - delegate.setCancelable(isCancelable) - return this - } - - fun addAdapterDelegate(subject: AdapterDelegate>): Builder { - delegatesManager.addDelegate(subject) - return this - } - - fun setItems(list: List): Builder { - items = list - return this - } - - fun create(): RecyclerViewAlertDialog { - recyclerView.adapter = ListDelegationAdapter(delegatesManager).also { - it.items = items - } - return RecyclerViewAlertDialog(delegate.create()) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RememberSelectionDialogListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RememberSelectionDialogListener.kt deleted file mode 100644 index e98e5d992..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/RememberSelectionDialogListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.ui.dialog - -import android.content.DialogInterface - -class RememberSelectionDialogListener(initialValue: Int) : DialogInterface.OnClickListener { - - var selection: Int = initialValue - private set - - override fun onClick(dialog: DialogInterface?, which: Int) { - selection = which - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt deleted file mode 100644 index 4d15077e1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/TwoButtonsAlertDialog.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.koitharu.kotatsu.core.ui.dialog - -import android.content.Context -import android.content.DialogInterface -import android.view.LayoutInflater -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible -import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding - -class TwoButtonsAlertDialog private constructor( - private val delegate: AlertDialog -) : DialogInterface by delegate { - - fun show() = delegate.show() - - class Builder(context: Context) { - - private val binding = DialogTwoButtonsBinding.inflate(LayoutInflater.from(context)) - - private val delegate = MaterialAlertDialogBuilder(context) - .setView(binding.root) - - fun setTitle(@StringRes titleResId: Int): Builder { - binding.title.setText(titleResId) - return this - } - - fun setTitle(title: CharSequence): Builder { - binding.title.text = title - return this - } - - fun setIcon(@DrawableRes iconId: Int): Builder { - binding.icon.setImageResource(iconId) - return this - } - - fun setPositiveButton( - @StringRes textId: Int, - listener: DialogInterface.OnClickListener, - ): Builder { - initButton(binding.button1, DialogInterface.BUTTON_POSITIVE, textId, listener) - return this - } - - fun setNegativeButton( - @StringRes textId: Int, - listener: DialogInterface.OnClickListener? = null - ): Builder { - initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener) - return this - } - - fun create(): TwoButtonsAlertDialog { - val dialog = delegate.create() - binding.root.tag = dialog - return TwoButtonsAlertDialog(dialog) - } - - private fun initButton( - button: MaterialButton, - which: Int, - @StringRes textId: Int, - listener: DialogInterface.OnClickListener?, - ) { - button.setText(textId) - button.isVisible = true - button.setOnClickListener { - val dialog = binding.root.tag as DialogInterface - listener?.onClick(dialog, which) - dialog.dismiss() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt deleted file mode 100644 index 43d662759..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.koitharu.kotatsu.core.ui.image - -import android.view.View -import android.view.View.OnLayoutChangeListener -import android.view.ViewGroup -import android.widget.ImageView -import coil.size.Dimension -import coil.size.Size -import coil.size.SizeResolver -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.math.roundToInt - -private const val ASPECT_RATIO_HEIGHT = 18f -private const val ASPECT_RATIO_WIDTH = 13f - -class CoverSizeResolver( - private val imageView: ImageView, -) : SizeResolver { - - override suspend fun size(): Size { - getSize()?.let { return it } - return suspendCancellableCoroutine { cont -> - val layoutListener = LayoutListener(cont) - imageView.addOnLayoutChangeListener(layoutListener) - cont.invokeOnCancellation { - imageView.removeOnLayoutChangeListener(layoutListener) - } - } - } - - private fun getSize(): Size? { - val lp = imageView.layoutParams - var width = getDimension(lp.width, imageView.width, imageView.paddingLeft + imageView.paddingRight) - var height = getDimension(lp.height, imageView.height, imageView.paddingTop + imageView.paddingBottom) - if (width == null && height == null) { - return null - } - if (height == null && width != null) { - height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()) - } else if (width == null && height != null) { - width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()) - } - return Size(checkNotNull(width), checkNotNull(height)) - } - - private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? { - if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) { - return null - } - val insetParamSize = paramSize - paddingSize - if (insetParamSize > 0) { - return Dimension(insetParamSize) - } - val insetViewSize = viewSize - paddingSize - if (insetViewSize > 0) { - return Dimension(insetViewSize) - } - return null - } - - private inner class LayoutListener( - private val continuation: CancellableContinuation, - ) : OnLayoutChangeListener { - - override fun onLayoutChange( - v: View, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - val size = getSize() ?: return - v.removeOnLayoutChangeListener(this) - continuation.resume(size) - } - } -} 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 deleted file mode 100644 index 492ee9f97..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.koitharu.kotatsu.core.ui.image - -import android.content.Context -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 androidx.annotation.StyleRes -import androidx.core.content.withStyledAttributes -import androidx.core.graphics.ColorUtils -import androidx.core.graphics.withClip -import com.google.android.material.color.MaterialColors -import org.koitharu.kotatsu.R -import kotlin.math.absoluteValue - -class FaviconDrawable( - context: Context, - @StyleRes styleResId: Int, - name: String, -) : Drawable() { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private var colorBackground = Color.WHITE - private var colorStroke = Color.LTGRAY - private val letter = name.take(1).uppercase() - private var cornerSize = 0f - private var colorForeground = Color.DKGRAY - private val textBounds = Rect() - private val tempRect = Rect() - private val boundsF = RectF() - private val clipPath = Path() - - init { - context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) { - colorBackground = getColor(R.styleable.FaviconFallbackDrawable_backgroundColor, colorBackground) - colorStroke = getColor(R.styleable.FaviconFallbackDrawable_strokeColor, colorStroke) - cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize) - paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f - } - paint.textAlign = Paint.Align.CENTER - paint.isFakeBoldText = true - colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground) - } - - override fun draw(canvas: Canvas) { - if (cornerSize > 0f) { - canvas.withClip(clipPath) { - doDraw(canvas) - } - } else { - doDraw(canvas) - } - } - - override fun onBoundsChange(bounds: Rect) { - super.onBoundsChange(bounds) - boundsF.set(bounds) - val innerWidth = bounds.width() - (paint.strokeWidth * 2f) - paint.textSize = getTextSizeForWidth(innerWidth, letter) * 0.5f - paint.getTextBounds(letter, 0, letter.length, textBounds) - clipPath.reset() - clipPath.addRoundRect(boundsF, cornerSize, cornerSize, Path.Direction.CW) - clipPath.close() - } - - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.colorFilter = colorFilter - } - - @Suppress("DeprecatedCallableAddReplaceWith") - @Deprecated("Deprecated in Java") - override fun getOpacity() = PixelFormat.TRANSPARENT - - private fun doDraw(canvas: Canvas) { - // background - paint.color = colorBackground - paint.style = Paint.Style.FILL - canvas.drawPaint(paint) - // letter - paint.color = colorForeground - 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.style = Paint.Style.STROKE - canvas.drawPath(clipPath, paint) - } - } - - private fun getTextSizeForWidth(width: Float, text: String): Float { - val testTextSize = 48f - paint.textSize = testTextSize - paint.getTextBounds(text, 0, text.length, tempRect) - return testTextSize * width / tempRect.width() - } - - private fun colorOfString(str: String): Int { - val hue = (str.hashCode() % 360).absoluteValue.toFloat() - return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt deleted file mode 100644 index 47d5461cb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/RegionBitmapDecoder.kt +++ /dev/null @@ -1,185 +0,0 @@ -package org.koitharu.kotatsu.core.ui.image - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.BitmapRegionDecoder -import android.graphics.Rect -import android.os.Build -import androidx.core.graphics.drawable.toDrawable -import coil.ImageLoader -import coil.decode.DecodeResult -import coil.decode.DecodeUtils -import coil.decode.Decoder -import coil.decode.ImageSource -import coil.fetch.SourceResult -import coil.request.Options -import coil.size.Dimension -import coil.size.Scale -import coil.size.Size -import coil.size.isOriginal -import coil.size.pxOrElse -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlin.math.roundToInt - -class RegionBitmapDecoder( - private val source: ImageSource, - private val options: Options, - private val parallelismLock: Semaphore, -) : Decoder { - - override suspend fun decode() = parallelismLock.withPermit { - runInterruptible { BitmapFactory.Options().decode() } - } - - private fun BitmapFactory.Options.decode(): DecodeResult { - val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(source.source().inputStream()) - } else { - @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance(source.source().inputStream(), false) - } - checkNotNull(regionDecoder) - try { - val rect = configureScale(regionDecoder.width, regionDecoder.height) - configureConfig() - val bitmap = regionDecoder.decodeRegion(rect, this) - bitmap.density = options.context.resources.displayMetrics.densityDpi - return DecodeResult( - drawable = bitmap.toDrawable(options.context.resources), - isSampled = true, - ) - } finally { - regionDecoder.recycle() - } - } - - private fun BitmapFactory.Options.configureConfig() { - var config = options.config - - inMutable = false - - if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) { - inPreferredColorSpace = options.colorSpace - } - inPremultiplied = options.premultipliedAlpha - - // Decode the image as RGB_565 as an optimization if allowed. - if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") { - config = Bitmap.Config.RGB_565 - } - - // High color depth images must be decoded as either RGBA_F16 or HARDWARE. - if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) { - config = Bitmap.Config.RGBA_F16 - } - - inPreferredConfig = config - } - - /** Compute and set the scaling properties for [BitmapFactory.Options]. */ - private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { - val dstWidth = options.size.widthPx(options.scale) { srcWidth } - val dstHeight = options.size.heightPx(options.scale) { srcHeight } - - val srcRatio = srcWidth / srcHeight.toDouble() - val dstRatio = dstWidth / dstHeight.toDouble() - val rect = if (srcRatio < dstRatio) { - // probably manga - Rect(0, 0, srcWidth, (srcWidth / dstRatio).toInt().coerceAtLeast(1)) - } else { - Rect(0, 0, (srcHeight / dstRatio).toInt().coerceAtLeast(1), srcHeight) - } - val scroll = options.parameters.value(PARAM_SCROLL) ?: SCROLL_UNDEFINED - if (scroll == SCROLL_UNDEFINED) { - rect.offsetTo( - (srcWidth - rect.width()) / 2, - (srcHeight - rect.height()) / 2, - ) - } else { - rect.offsetTo( - (srcWidth - rect.width()) / 2, - (scroll * dstRatio).toInt().coerceAtMost(srcHeight - rect.height()), - ) - } - - // Calculate the image's sample size. - inSampleSize = DecodeUtils.calculateInSampleSize( - srcWidth = rect.width(), - srcHeight = rect.height(), - dstWidth = dstWidth, - dstHeight = dstHeight, - scale = options.scale, - ) - - // Calculate the image's density scaling multiple. - var scale = DecodeUtils.computeSizeMultiplier( - srcWidth = rect.width() / inSampleSize.toDouble(), - srcHeight = rect.height() / inSampleSize.toDouble(), - dstWidth = dstWidth.toDouble(), - dstHeight = dstHeight.toDouble(), - scale = options.scale, - ) - - // Only upscale the image if the options require an exact size. - if (options.allowInexactSize) { - scale = scale.coerceAtMost(1.0) - } - - inScaled = scale != 1.0 - if (inScaled) { - if (scale > 1) { - // Upscale - inDensity = (Int.MAX_VALUE / scale).roundToInt() - inTargetDensity = Int.MAX_VALUE - } else { - // Downscale - inDensity = Int.MAX_VALUE - inTargetDensity = (Int.MAX_VALUE * scale).roundToInt() - } - } - return rect - } - - class Factory( - maxParallelism: Int = DEFAULT_MAX_PARALLELISM, - ) : Decoder.Factory { - - @Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") - @SinceKotlin("999.9") // Only public in Java. - constructor() : this() - - private val parallelismLock = Semaphore(maxParallelism) - - override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder { - return RegionBitmapDecoder(result.source, options, parallelismLock) - } - - override fun equals(other: Any?) = other is Factory - - override fun hashCode() = javaClass.hashCode() - } - - companion object { - - const val PARAM_SCROLL = "scroll" - const val SCROLL_UNDEFINED = -1 - private const val DEFAULT_MAX_PARALLELISM = 4 - - private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { - return if (isOriginal) original() else width.toPx(scale) - } - - private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int { - return if (isOriginal) original() else height.toPx(scale) - } - - private fun Dimension.toPx(scale: Scale) = pxOrElse { - when (scale) { - Scale.FILL -> Int.MIN_VALUE - Scale.FIT -> Int.MAX_VALUE - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt deleted file mode 100644 index c0ca38662..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/ThumbnailTransformation.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.core.ui.image - -import android.graphics.Bitmap -import android.media.ThumbnailUtils -import coil.size.Size -import coil.size.pxOrElse -import coil.transform.Transformation - -class ThumbnailTransformation : Transformation { - - override val cacheKey: String = javaClass.name - - override suspend fun transform(input: Bitmap, size: Size): Bitmap { - return ThumbnailUtils.extractThumbnail( - input, - size.width.pxOrElse { input.width }, - size.height.pxOrElse { input.height }, - ) - } - - override fun equals(other: Any?) = other is ThumbnailTransformation - - override fun hashCode() = javaClass.hashCode() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnTipCloseListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnTipCloseListener.kt deleted file mode 100644 index 81078afee..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnTipCloseListener.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list - -interface OnTipCloseListener { - - fun onCloseTip(tip: T) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/RecyclerScrollKeeper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/RecyclerScrollKeeper.kt deleted file mode 100644 index b6d651e46..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/RecyclerScrollKeeper.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver - -class RecyclerScrollKeeper( - private val rv: RecyclerView, -) : AdapterDataObserver() { - - private val scrollUpRunnable = Runnable { - (rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0) - } - - fun attach() { - rv.adapter?.registerAdapterDataObserver(this) - } - - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - super.onItemRangeInserted(positionStart, itemCount) - if (positionStart == 0 && isScrolledToTop()) { - postScrollUp() - } - } - - override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { - super.onItemRangeMoved(fromPosition, toPosition, itemCount) - if (toPosition == 0 && isScrolledToTop()) { - postScrollUp() - } - } - - private fun postScrollUp() { - rv.postDelayed(scrollUpRunnable, 500L) - } - - private fun isScrolledToTop(): Boolean { - return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt deleted file mode 100644 index 4ec8cc011..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt +++ /dev/null @@ -1,4 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list - -private const val PROVIDER_NAME = "selection_decoration_sectioned" - diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/BubbleAnimator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/BubbleAnimator.kt deleted file mode 100644 index 359edfc05..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/BubbleAnimator.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.fastscroll - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.view.View -import android.view.ViewAnimationUtils -import android.view.animation.AccelerateInterpolator -import android.view.animation.DecelerateInterpolator -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import org.koitharu.kotatsu.core.util.ext.animatorDurationScale -import org.koitharu.kotatsu.core.util.ext.measureWidth -import kotlin.math.hypot - -class BubbleAnimator( - private val bubble: View, -) { - - private val animationDuration = ( - bubble.resources.getInteger(android.R.integer.config_shortAnimTime) * - bubble.context.animatorDurationScale - ).toLong() - private var animator: Animator? = null - private var isHiding = false - - fun show() { - if (bubble.isVisible && !isHiding) { - return - } - isHiding = false - animator?.cancel() - animator = ViewAnimationUtils.createCircularReveal( - bubble, - bubble.measureWidth(), - bubble.measuredHeight, - 0f, - hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), - ).apply { - bubble.isVisible = true - duration = animationDuration - interpolator = DecelerateInterpolator() - start() - } - } - - fun hide() { - if (!bubble.isVisible || isHiding) { - return - } - animator?.cancel() - isHiding = true - animator = ViewAnimationUtils.createCircularReveal( - bubble, - bubble.width, - bubble.height, - hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(), - 0f, - ).apply { - duration = animationDuration - interpolator = AccelerateInterpolator() - addListener(HideListener()) - start() - } - } - - private inner class HideListener : AnimatorListenerAdapter() { - - private var isCancelled = false - - override fun onAnimationCancel(animation: Animator) { - super.onAnimationCancel(animation) - isCancelled = true - } - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - if (!isCancelled && animation === this@BubbleAnimator.animator) { - bubble.isInvisible = true - isHiding = false - this@BubbleAnimator.animator = null - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt deleted file mode 100644 index 1a1590019..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.fastscroll - -import android.content.Context -import android.util.AttributeSet -import android.view.ViewGroup -import androidx.annotation.AttrRes -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R - -class FastScrollRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle, -) : RecyclerView(context, attrs, defStyleAttr) { - - val fastScroller = FastScroller(context, attrs) - - var isFastScrollerEnabled: Boolean = true - set(value) { - field = value - fastScroller.isVisible = value && isVisible - } - - init { - fastScroller.id = R.id.fast_scroller - fastScroller.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - - override fun setAdapter(adapter: Adapter<*>?) { - super.setAdapter(adapter) - fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer) - } - - override fun setVisibility(visibility: Int) { - super.setVisibility(visibility) - fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - fastScroller.attachRecyclerView(this) - } - - override fun onDetachedFromWindow() { - fastScroller.detachRecyclerView() - super.onDetachedFromWindow() - } -} 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 deleted file mode 100644 index 55479d2d2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt +++ /dev/null @@ -1,552 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.fastscroll - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.TypedArray -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt -import androidx.annotation.DimenRes -import androidx.annotation.DrawableRes -import androidx.annotation.Px -import androidx.annotation.StyleableRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes -import androidx.core.view.GravityCompat -import androidx.core.view.ancestors -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.isLayoutReversed -import org.koitharu.kotatsu.databinding.FastScrollerBinding -import kotlin.math.roundToInt -import com.google.android.material.R as materialR - -private const val SCROLLBAR_HIDE_DELAY = 1000L -private const val TRACK_SNAP_RANGE = 5 - -@Suppress("MemberVisibilityCanBePrivate", "unused") -class FastScroller @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = R.attr.fastScrollerStyle, -) : LinearLayout(context, attrs, defStyleAttr) { - - enum class BubbleSize(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) { - NORMAL(R.drawable.fastscroll_bubble, R.dimen.fastscroll_bubble_text_size), - SMALL(R.drawable.fastscroll_bubble_small, R.dimen.fastscroll_bubble_text_size_small) - } - - private val binding = FastScrollerBinding.inflate(LayoutInflater.from(context), this) - - private val scrollbarPaddingEnd = context.resources.getDimension(R.dimen.fastscroll_scrollbar_padding_end) - - @ColorInt - private var bubbleColor = 0 - - @ColorInt - private var handleColor = 0 - - private var bubbleHeight = 0 - private var handleHeight = 0 - private var viewHeight = 0 - private var offset = 0 - private var hideScrollbar = true - private var showBubble = true - private var showBubbleAlways = false - private var bubbleSize = BubbleSize.NORMAL - private var bubbleImage: Drawable? = null - private var handleImage: Drawable? = null - private var trackImage: Drawable? = null - private var recyclerView: RecyclerView? = null - private val scrollbarAnimator = ScrollbarAnimator(binding.scrollbar, scrollbarPaddingEnd) - private val bubbleAnimator = BubbleAnimator(binding.bubble) - - private var fastScrollListener: FastScrollListener? = null - private var sectionIndexer: SectionIndexer? = null - - private val scrollbarHider = Runnable { - hideBubble() - hideScrollbar() - } - - private val scrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (!binding.thumb.isSelected && isEnabled) { - val y = recyclerView.scrollProportion - setViewPositions(y) - - if (showBubbleAlways) { - val targetPos = getRecyclerViewTargetPosition(y) - sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) } - } - } - } - - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - - if (isEnabled) { - when (newState) { - RecyclerView.SCROLL_STATE_DRAGGING -> { - handler.removeCallbacks(scrollbarHider) - showScrollbar() - if (showBubbleAlways && sectionIndexer != null) showBubble() - } - - RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) { - handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY) - } - } - } - } - } - - private val RecyclerView.scrollProportion: Float - get() { - val rangeDiff = computeVerticalScrollRange() - computeVerticalScrollExtent() - val proportion = computeVerticalScrollOffset() / if (rangeDiff > 0) rangeDiff.toFloat() else 1f - return viewHeight * proportion - } - - val isScrollbarVisible: Boolean - get() = binding.scrollbar.isVisible - - init { - clipChildren = false - orientation = HORIZONTAL - - @ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY) - @ColorInt var handleColor = bubbleColor - @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY) - @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE) - - var showTrack = false - - context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) { - bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor) - handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor) - trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor) - textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor) - hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar) - showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble) - showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways) - showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack) - bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL) - val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize) - binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset) - } - - setTrackColor(trackColor) - setHandleColor(handleColor) - setBubbleColor(bubbleColor) - setBubbleTextColor(textColor) - setHideScrollbar(hideScrollbar) - setBubbleVisible(showBubble, showBubbleAlways) - setTrackVisible(showTrack) - } - - override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { - super.onSizeChanged(w, h, oldW, oldH) - viewHeight = h - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - val setYPositions: () -> Unit = { - val y = event.y - setViewPositions(y) - setRecyclerViewPosition(y) - } - - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - if (!isScrollbarVisible || event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) { - return false - } - - requestDisallowInterceptTouchEvent(true) - setHandleSelected(true) - - handler.removeCallbacks(scrollbarHider) - showScrollbar() - if (showBubble && sectionIndexer != null) showBubble() - - fastScrollListener?.onFastScrollStart(this) - - setYPositions() - return true - } - - MotionEvent.ACTION_MOVE -> { - setYPositions() - return true - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - requestDisallowInterceptTouchEvent(false) - setHandleSelected(false) - - if (hideScrollbar) handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY) - if (!showBubbleAlways) hideBubble() - - fastScrollListener?.onFastScrollStop(this) - - return true - } - } - - return super.onTouchEvent(event) - } - - /** - * Set the enabled state of this view. - * - * @param enabled True if this view is enabled, false otherwise - */ - override fun setEnabled(enabled: Boolean) { - super.setEnabled(enabled) - isVisible = enabled - } - - /** - * Set the [ViewGroup.LayoutParams] associated with this view. These supply - * parameters to the *parent* of this view specifying how it should be arranged. - * - * @param params The [ViewGroup.LayoutParams] for this view, cannot be null - */ - override fun setLayoutParams(params: ViewGroup.LayoutParams) { - params.width = LayoutParams.WRAP_CONTENT - super.setLayoutParams(params) - } - - /** - * Set the [ViewGroup.LayoutParams] associated with this view. These supply - * parameters to the *parent* of this view specifying how it should be arranged. - * - * @param viewGroup The parent [ViewGroup] for this view, cannot be null - */ - fun setLayoutParams(viewGroup: ViewGroup) { - val recyclerViewId = recyclerView?.id ?: NO_ID - val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) - val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) - - require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" } - - when (viewGroup) { - is ConstraintLayout -> { - val endId = if (recyclerView?.parent === parent) recyclerViewId else ConstraintSet.PARENT_ID - val startId = id - - ConstraintSet().apply { - clone(viewGroup) - connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP) - connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM) - connect(startId, ConstraintSet.END, endId, ConstraintSet.END) - applyTo(viewGroup) - } - - layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { - height = 0 - setMargins(offset, marginTop, offset, marginBottom) - } - } - - is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply { - height = LayoutParams.MATCH_PARENT - anchorGravity = GravityCompat.END - anchorId = recyclerViewId - setMargins(offset, marginTop, offset, marginBottom) - } - - is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply { - height = LayoutParams.MATCH_PARENT - gravity = GravityCompat.END - setMargins(offset, marginTop, offset, marginBottom) - } - - is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { - height = 0 - addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) - addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) - addRule(RelativeLayout.ALIGN_END, recyclerViewId) - setMargins(offset, marginTop, offset, marginBottom) - } - - else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") - } - - updateViewHeights() - } - - /** - * Set the [RecyclerView] associated with this [FastScroller]. This allows the - * FastScroller to set its layout parameters and listen for scroll changes. - * - * @param recyclerView The [RecyclerView] to attach, cannot be null - * @see detachRecyclerView - */ - fun attachRecyclerView(recyclerView: RecyclerView) { - if (this.recyclerView != null) { - detachRecyclerView() - } - this.recyclerView = recyclerView - - if (parent is ViewGroup) { - setLayoutParams(parent as ViewGroup) - } else { - val viewGroup = findValidParent(recyclerView) - if (viewGroup != null) { - viewGroup.addView(this) - setLayoutParams(viewGroup) - } - } - - recyclerView.addOnScrollListener(scrollListener) - - // set initial positions for bubble and thumb - post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) } - } - - /** - * Clears references to the attached [RecyclerView] and stops listening for scroll changes. - * - * @see attachRecyclerView - */ - fun detachRecyclerView() { - recyclerView?.removeOnScrollListener(scrollListener) - recyclerView = null - } - - /** - * Set a new [FastScrollListener] that will listen to fast scroll events. - * - * @param fastScrollListener The new [FastScrollListener] to set, or null to set none - */ - fun setFastScrollListener(fastScrollListener: FastScrollListener?) { - this.fastScrollListener = fastScrollListener - } - - /** - * Set a new [SectionIndexer] that provides section text for this [FastScroller]. - * - * @param sectionIndexer The new [SectionIndexer] to set, or null to set none - */ - fun setSectionIndexer(sectionIndexer: SectionIndexer?) { - this.sectionIndexer = sectionIndexer - } - - /** - * Hide the scrollbar when not scrolling. - * - * @param hideScrollbar True to hide the scrollbar, false to show - */ - fun setHideScrollbar(hideScrollbar: Boolean) { - if (this.hideScrollbar != hideScrollbar) { - this.hideScrollbar = hideScrollbar - binding.scrollbar.isGone = hideScrollbar - } - } - - /** - * Show the scroll track while scrolling. - * - * @param visible True to show scroll track, false to hide - */ - fun setTrackVisible(visible: Boolean) { - binding.track.isVisible = visible - } - - /** - * Set the color of the scroll track. - * - * @param color The color for the scroll track - */ - fun setTrackColor(@ColorInt color: Int) { - if (trackImage == null) { - trackImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_track) - } - - trackImage?.let { - it.setTint(color) - binding.track.setImageDrawable(it) - } - } - - /** - * Set the color of the scroll thumb. - * - * @param color The color for the scroll thumb - */ - fun setHandleColor(@ColorInt color: Int) { - handleColor = color - - if (handleImage == null) { - handleImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle) - } - - handleImage?.let { - it.setTint(handleColor) - binding.thumb.setImageDrawable(it) - } - } - - /** - * Show the section bubble while scrolling. - * - * @param visible True to show the bubble, false to hide - * @param always True to always show the bubble, false to only show on thumb touch - */ - @JvmOverloads - fun setBubbleVisible(visible: Boolean, always: Boolean = false) { - showBubble = visible - showBubbleAlways = visible && always - } - - /** - * Set the background color of the section bubble. - * - * @param color The background color for the section bubble - */ - fun setBubbleColor(@ColorInt color: Int) { - bubbleColor = color - - if (bubbleImage == null) { - bubbleImage = ContextCompat.getDrawable(context, bubbleSize.drawableId) - } - - bubbleImage?.let { - it.setTint(bubbleColor) - binding.bubble.background = it - } - } - - /** - * Set the text color of the section bubble. - * - * @param color The text color for the section bubble - */ - fun setBubbleTextColor(@ColorInt color: Int) = binding.bubble.setTextColor(color) - - /** - * Set the scaled pixel text size of the section bubble. - * - * @param size The scaled pixel text size for the section bubble - */ - fun setBubbleTextSize(size: Int) { - binding.bubble.textSize = size.toFloat() - } - - private fun getRecyclerViewTargetPosition(y: Float) = recyclerView?.let { recyclerView -> - val itemCount = recyclerView.adapter?.itemCount ?: 0 - - val proportion = when { - binding.thumb.y == 0f -> 0f - binding.thumb.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f - else -> y / viewHeight.toFloat() - } - - var scrolledItemCount = (proportion * itemCount).roundToInt() - - if (recyclerView.layoutManager.isLayoutReversed) { - scrolledItemCount = itemCount - scrolledItemCount - } - - if (itemCount > 0) scrolledItemCount.coerceIn(0, itemCount - 1) else 0 - } ?: 0 - - private fun setRecyclerViewPosition(y: Float) { - val layoutManager = recyclerView?.layoutManager ?: return - val targetPos = getRecyclerViewTargetPosition(y) - layoutManager.scrollToPosition(targetPos) - if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) } - } - - private fun setViewPositions(y: Float) { - bubbleHeight = binding.bubble.measuredHeight - handleHeight = binding.thumb.measuredHeight - - val bubbleHandleHeight = bubbleHeight + handleHeight / 2f - - if (showBubble && viewHeight >= bubbleHandleHeight) { - binding.bubble.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight) - } - - if (viewHeight >= handleHeight) { - binding.thumb.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat()) - } - } - - private fun updateViewHeights() { - val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) - binding.bubble.measure(measureSpec, measureSpec) - bubbleHeight = binding.bubble.measuredHeight - binding.thumb.measure(measureSpec, measureSpec) - handleHeight = binding.thumb.measuredHeight - } - - private fun showBubble() { - bubbleAnimator.show() - } - - private fun hideBubble() { - bubbleAnimator.hide() - } - - private fun showScrollbar() { - if (recyclerView?.run { canScrollVertically(1) || canScrollVertically(-1) } == true) { - scrollbarAnimator.show() - } - } - - private fun hideScrollbar() { - scrollbarAnimator.hide() - } - - private fun setHandleSelected(selected: Boolean) { - binding.thumb.isSelected = selected - handleImage?.setTint(if (selected) bubbleColor else handleColor) - } - - private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize { - val ordinal = getInt(index, -1) - return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue - } - - private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p -> - if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { - p as ViewGroup - } else { - null - } - } - - private val BubbleSize.textSize - @Px get() = resources.getDimension(textSizeId) - - interface FastScrollListener { - - fun onFastScrollStart(fastScroller: FastScroller) - - fun onFastScrollStop(fastScroller: FastScroller) - } - - interface SectionIndexer { - - fun getSectionText(context: Context, position: Int): CharSequence? - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/ScrollbarAnimator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/ScrollbarAnimator.kt deleted file mode 100644 index 1d9287b2d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/ScrollbarAnimator.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.fastscroll - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.view.View -import android.view.ViewPropertyAnimator -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.animatorDurationScale - -class ScrollbarAnimator( - private val scrollbar: View, - private val scrollbarPaddingEnd: Float, -) { - - private val animationDuration = ( - scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) * - scrollbar.context.animatorDurationScale - ).toLong() - private var animator: ViewPropertyAnimator? = null - private var isHiding = false - - fun show() { - if (scrollbar.isVisible && !isHiding) { - return - } - isHiding = false - animator?.cancel() - scrollbar.translationX = scrollbarPaddingEnd - scrollbar.isVisible = true - animator = scrollbar - .animate() - .translationX(0f) - .alpha(1f) - .setListener(null) - .setDuration(animationDuration) - } - - fun hide() { - if (!scrollbar.isVisible || isHiding) { - return - } - animator?.cancel() - isHiding = true - animator = scrollbar.animate().apply { - translationX(scrollbarPaddingEnd) - alpha(0f) - duration = animationDuration - setListener(HideListener(this)) - } - } - - private inner class HideListener( - private val viewPropertyAnimator: ViewPropertyAnimator, - ) : AnimatorListenerAdapter() { - - private var isCancelled = false - - override fun onAnimationCancel(animation: Animator) { - super.onAnimationCancel(animation) - isCancelled = true - } - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - if (!isCancelled && this@ScrollbarAnimator.animator === viewPropertyAnimator) { - scrollbar.isInvisible = true - isHiding = false - this@ScrollbarAnimator.animator = null - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/LifecycleAwareViewHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/LifecycleAwareViewHolder.kt deleted file mode 100644 index 33f75ed37..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/LifecycleAwareViewHolder.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.lifecycle - -import android.view.View -import androidx.annotation.CallSuper -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.recyclerview.widget.RecyclerView - -abstract class LifecycleAwareViewHolder( - itemView: View, - private val parentLifecycleOwner: LifecycleOwner, -) : RecyclerView.ViewHolder(itemView), LifecycleOwner { - - @Suppress("LeakingThis") - final override val lifecycle = LifecycleRegistry(this) - private var isCurrent = false - - init { - parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver()) - if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { - lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - } - - fun setIsCurrent(value: Boolean) { - isCurrent = value - dispatchResumed() - } - - @CallSuper - open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) - - @CallSuper - open fun onResume() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - - @CallSuper - open fun onPause() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) - - @CallSuper - open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) - - private fun dispatchResumed() { - val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - if (isCurrent && isParentResumed) { - if (!isResumed()) { - onResume() - } - } else { - if (isResumed()) { - onPause() - } - } - } - - protected fun isResumed(): Boolean { - return lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - } - - private inner class ParentLifecycleObserver : DefaultLifecycleObserver { - - override fun onCreate(owner: LifecycleOwner) { - lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) - } - - override fun onStart(owner: LifecycleOwner) { - onStart() - } - - override fun onResume(owner: LifecycleOwner) { - dispatchResumed() - } - - override fun onPause(owner: LifecycleOwner) { - dispatchResumed() - } - - override fun onStop(owner: LifecycleOwner) { - onStop() - } - - override fun onDestroy(owner: LifecycleOwner) { - lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - owner.lifecycle.removeObserver(this) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/PagerLifecycleDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/PagerLifecycleDispatcher.kt deleted file mode 100644 index b767396e1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/PagerLifecycleDispatcher.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.lifecycle - -import androidx.core.view.children -import androidx.viewpager2.widget.ViewPager2 -import org.koitharu.kotatsu.core.util.ext.recyclerView - -class PagerLifecycleDispatcher( - private val pager: ViewPager2, -) : ViewPager2.OnPageChangeCallback() { - - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - val rv = pager.recyclerView ?: return - for (child in rv.children) { - val wh = rv.getChildViewHolder(child) ?: continue - (wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position) - } - } - - fun invalidate() { - onPageSelected(pager.currentItem) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/RecyclerViewLifecycleDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/RecyclerViewLifecycleDispatcher.kt deleted file mode 100644 index 7772d928b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/lifecycle/RecyclerViewLifecycleDispatcher.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.core.ui.list.lifecycle - -import androidx.core.view.children -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_POSITION - -class RecyclerViewLifecycleDispatcher : RecyclerView.OnScrollListener() { - - private var prevFirst = NO_POSITION - private var prevLast = NO_POSITION - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - invalidate(recyclerView) - } - - fun invalidate(recyclerView: RecyclerView) { - val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return - val first = lm.findFirstVisibleItemPosition() - val last = lm.findLastVisibleItemPosition() - if (first == prevFirst && last == prevLast) { - return - } - prevFirst = first - prevLast = last - if (first == NO_POSITION || last == NO_POSITION) { - return - } - for (child in recyclerView.children) { - val wh = recyclerView.getChildViewHolder(child) ?: continue - (wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition in first..last) - } - } -} 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 deleted file mode 100644 index ea731925c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.koitharu.kotatsu.core.ui.model - -import android.content.res.Resources -import org.koitharu.kotatsu.R -import java.time.LocalDate -import java.time.format.DateTimeFormatter - -sealed class DateTimeAgo { - - abstract fun format(resources: Resources): String - - object JustNow : DateTimeAgo() { - override fun format(resources: Resources): String { - return resources.getString(R.string.just_now) - } - - override fun toString() = "just_now" - - override fun equals(other: Any?): Boolean = other === JustNow - } - - data class MinutesAgo(val minutes: Int) : DateTimeAgo() { - override fun format(resources: Resources): String { - return 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 toString() = "hours_ago_$hours" - } - - object Today : DateTimeAgo() { - override fun format(resources: Resources): String { - return resources.getString(R.string.today) - } - - override fun toString() = "today" - - override fun equals(other: Any?): Boolean = other === Today - } - - object Yesterday : DateTimeAgo() { - override fun format(resources: Resources): String { - return resources.getString(R.string.yesterday) - } - - override fun toString() = "yesterday" - - override fun equals(other: Any?): Boolean = other === Yesterday - } - - data class DaysAgo(val days: Int) : DateTimeAgo() { - override fun format(resources: Resources): String { - return 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 { - return if (months == 0) { - resources.getString(R.string.this_month) - } else { - resources.getQuantityString(R.plurals.months_ago, months, months) - } - } - } - - data class Absolute(private val date: LocalDate) : DateTimeAgo() { - override fun format(resources: Resources): String { - return if (date == EPOCH_DATE) { - resources.getString(R.string.unknown) - } else { - date.format(formatter) - } - } - - 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") - } - } - - object LongAgo : DateTimeAgo() { - override fun format(resources: Resources): String { - return resources.getString(R.string.long_ago) - } - - override fun toString() = "long_ago" - - override fun equals(other: Any?): Boolean = other === LongAgo - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt deleted file mode 100644 index 077eca144..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetBehavior.kt +++ /dev/null @@ -1,125 +0,0 @@ -package org.koitharu.kotatsu.core.ui.sheet - -import android.app.Dialog -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.sidesheet.SideSheetBehavior -import com.google.android.material.sidesheet.SideSheetCallback -import com.google.android.material.sidesheet.SideSheetDialog -import java.util.LinkedList - -sealed class AdaptiveSheetBehavior { - - @JvmField - protected val callbacks = LinkedList() - - abstract var state: Int - - abstract var isDraggable: Boolean - - open val isHideable: Boolean = true - - fun addCallback(callback: AdaptiveSheetCallback) { - callbacks.add(callback) - } - - fun removeCallback(callback: AdaptiveSheetCallback) { - callbacks.remove(callback) - } - - class Bottom( - private val delegate: BottomSheetBehavior<*>, - ) : AdaptiveSheetBehavior() { - - override var state: Int - get() = delegate.state - set(value) { - delegate.state = value - } - - override var isDraggable: Boolean - get() = delegate.isDraggable - set(value) { - delegate.isDraggable = value - } - - override val isHideable: Boolean - get() = delegate.isHideable - - var isFitToContents: Boolean - get() = delegate.isFitToContents - set(value) { - delegate.isFitToContents = value - } - - init { - delegate.addBottomSheetCallback( - object : BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - callbacks.forEach { it.onStateChanged(bottomSheet, newState) } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - callbacks.forEach { it.onSlide(bottomSheet, slideOffset) } - } - }, - ) - } - } - - class Side( - private val delegate: SideSheetBehavior<*>, - ) : AdaptiveSheetBehavior() { - - override var state: Int - get() = delegate.state - set(value) { - delegate.state = value - } - - override var isDraggable: Boolean - get() = delegate.isDraggable - set(value) { - delegate.isDraggable = value - } - - init { - delegate.addCallback( - object : SideSheetCallback() { - override fun onStateChanged(sheet: View, newState: Int) { - callbacks.forEach { it.onStateChanged(sheet, newState) } - } - - override fun onSlide(sheet: View, slideOffset: Float) { - callbacks.forEach { it.onSlide(sheet, slideOffset) } - } - }, - ) - } - } - - companion object { - - const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED - const val STATE_COLLAPSED = BottomSheetBehavior.STATE_COLLAPSED - const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING - const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING - const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN - - fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) { - is BottomSheetDialog -> Bottom(dialog.behavior) - is SideSheetDialog -> Side(dialog.behavior) - else -> null - } - - fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = - when (val behavior = lp.behavior) { - is BottomSheetBehavior<*> -> Bottom(behavior) - is SideSheetBehavior<*> -> Side(behavior) - else -> null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt deleted file mode 100644 index 9abaebbc1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetCallback.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.core.ui.sheet - -import android.view.View - -interface AdaptiveSheetCallback { - - /** - * Called when the sheet changes its state. - * - * @param sheet The sheet view. - * @param newState The new state. - */ - fun onStateChanged(sheet: View, newState: Int) - - /** - * Called when the sheet is being dragged. - * - * @param sheet The sheet view. - * @param slideOffset The new offset of this sheet. - */ - fun onSlide(sheet: View, slideOffset: Float) = Unit -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt deleted file mode 100644 index cf70fbc0a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/AdaptiveSheetHeaderBar.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.koitharu.kotatsu.core.ui.sheet - -import android.content.Context -import android.util.AttributeSet -import android.view.InputDevice -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.widget.LinearLayout -import androidx.annotation.AttrRes -import androidx.annotation.StringRes -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.withStyledAttributes -import androidx.core.view.ancestors -import androidx.core.view.isGone -import androidx.core.view.isVisible -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding - -class AdaptiveSheetHeaderBar @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = 0, -) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback { - - private val binding = - LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this) - private var sheetBehavior: AdaptiveSheetBehavior? = null - - var title: CharSequence? - get() = binding.shTextViewTitle.text - set(value) { - binding.shTextViewTitle.text = value - } - - val isTitleVisible: Boolean - get() = binding.shLayoutSidesheet.isVisible - - init { - orientation = VERTICAL - binding.shButtonClose.setOnClickListener { dismissSheet() } - context.withStyledAttributes( - attrs, - R.styleable.AdaptiveSheetHeaderBar, defStyleAttr, - ) { - title = getText(R.styleable.AdaptiveSheetHeaderBar_title) - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - if (isInEditMode) { - val isTabled = resources.getBoolean(R.bool.is_tablet) - binding.shDragHandle.isGone = isTabled - binding.shLayoutSidesheet.isVisible = isTabled - } else { - setBottomSheetBehavior(findParentSheetBehavior()) - } - } - - override fun onDetachedFromWindow() { - setBottomSheetBehavior(null) - super.onDetachedFromWindow() - } - - override fun onGenericMotionEvent(event: MotionEvent): Boolean { - val behavior = sheetBehavior ?: return super.onGenericMotionEvent(event) - if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - if (event.actionMasked == MotionEvent.ACTION_SCROLL) { - if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) { - behavior.state = if ( - behavior is AdaptiveSheetBehavior.Bottom - && behavior.state == AdaptiveSheetBehavior.STATE_EXPANDED - ) { - AdaptiveSheetBehavior.STATE_COLLAPSED - } else { - AdaptiveSheetBehavior.STATE_HIDDEN - } - } else { - behavior.state = AdaptiveSheetBehavior.STATE_EXPANDED - } - return true - } - } - return super.onGenericMotionEvent(event) - } - - override fun onStateChanged(sheet: View, newState: Int) { - - } - - fun setTitle(@StringRes resId: Int) { - binding.shTextViewTitle.setText(resId) - } - - private fun setBottomSheetBehavior(behavior: AdaptiveSheetBehavior?) { - binding.shDragHandle.isVisible = behavior is AdaptiveSheetBehavior.Bottom - binding.shLayoutSidesheet.isVisible = behavior is AdaptiveSheetBehavior.Side - sheetBehavior?.removeCallback(this) - sheetBehavior = behavior - behavior?.addCallback(this) - } - - private fun dismissSheet() { - sheetBehavior?.state = AdaptiveSheetBehavior.STATE_HIDDEN - } - - private fun findParentSheetBehavior(): AdaptiveSheetBehavior? { - return ancestors.firstNotNullOfOrNull { - ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams) - ?.let { params -> AdaptiveSheetBehavior.from(params) } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt deleted file mode 100644 index d9941765d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.koitharu.kotatsu.core.ui.sheet - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import androidx.activity.OnBackPressedDispatcher -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.core.view.updateLayoutParams -import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.sidesheet.SideSheetDialog -import org.koitharu.kotatsu.R -import com.google.android.material.R as materialR - -abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { - - private var waitingForDismissAllowingStateLoss = false - private var isFitToContentsDisabled = false - - var viewBinding: B? = null - private set - - @Deprecated("", ReplaceWith("requireViewBinding()")) - protected val binding: B - get() = requireViewBinding() - - protected val behavior: AdaptiveSheetBehavior? - get() = AdaptiveSheetBehavior.from(dialog) - - val isExpanded: Boolean - get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED - - val onBackPressedDispatcher: OnBackPressedDispatcher - get() = requireComponentDialog().onBackPressedDispatcher - - final override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val binding = onCreateViewBinding(inflater, container) - viewBinding = binding - return binding.root - } - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val binding = requireViewBinding() - onViewBindingCreated(binding, savedInstanceState) - } - - override fun onDestroyView() { - viewBinding = null - super.onDestroyView() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - return if (context.resources.getBoolean(R.bool.is_tablet)) { - SideSheetDialog(context, theme) - } else { - BottomSheetDialog(context, theme) - } - } - - fun addSheetCallback(callback: AdaptiveSheetCallback) { - val b = behavior ?: return - b.addCallback(callback) - val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) - ?: dialog?.findViewById(materialR.id.coordinator) - if (rootView != null) { - callback.onStateChanged(rootView, b.state) - } - } - - protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B - - protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit - - protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { - val b = behavior ?: return - if (isExpanded) { - b.state = BottomSheetBehavior.STATE_EXPANDED - } - if (b is AdaptiveSheetBehavior.Bottom) { - b.isFitToContents = !isFitToContentsDisabled && !isExpanded - val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet) - rootView?.updateLayoutParams { - height = if (isFitToContentsDisabled || isExpanded) { - LayoutParams.MATCH_PARENT - } else { - LayoutParams.WRAP_CONTENT - } - } - } - b.isDraggable = !isLocked - } - - protected fun disableFitToContents() { - isFitToContentsDisabled = true - val b = behavior as? AdaptiveSheetBehavior.Bottom ?: return - b.isFitToContents = false - dialog?.findViewById(materialR.id.design_bottom_sheet)?.updateLayoutParams { - height = LayoutParams.MATCH_PARENT - } - } - - fun requireViewBinding(): B = checkNotNull(viewBinding) { - "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()." - } - - override fun dismiss() { - if (!tryDismissWithAnimation(false)) { - super.dismiss() - } - } - - override fun dismissAllowingStateLoss() { - if (!tryDismissWithAnimation(true)) { - super.dismissAllowingStateLoss() - } - } - - /** - * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, - * false otherwise. - */ - private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean { - val shouldDismissWithAnimation = when (val dialog = dialog) { - is BottomSheetDialog -> dialog.dismissWithAnimation - is SideSheetDialog -> dialog.isDismissWithSheetAnimationEnabled - else -> false - } - val behavior = behavior ?: return false - return if (shouldDismissWithAnimation && behavior.isHideable) { - dismissWithAnimation(behavior, allowingStateLoss) - true - } else { - false - } - } - - private fun dismissWithAnimation(behavior: AdaptiveSheetBehavior, allowingStateLoss: Boolean) { - waitingForDismissAllowingStateLoss = allowingStateLoss - if (behavior.state == AdaptiveSheetBehavior.STATE_HIDDEN) { - dismissAfterAnimation() - } else { - behavior.addCallback(SheetDismissCallback()) - behavior.state = AdaptiveSheetBehavior.STATE_HIDDEN - } - } - - private fun dismissAfterAnimation() { - if (waitingForDismissAllowingStateLoss) { - super.dismissAllowingStateLoss() - } else { - super.dismiss() - } - } - - private inner class SheetDismissCallback : AdaptiveSheetCallback { - override fun onStateChanged(sheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_HIDDEN) { - dismissAfterAnimation() - } - } - - override fun onSlide(sheet: View, slideOffset: Float) {} - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActivityRecreationHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActivityRecreationHandle.kt deleted file mode 100644 index e7f481f36..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActivityRecreationHandle.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.app.Activity -import android.os.Bundle -import androidx.core.app.ActivityCompat -import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks -import java.util.WeakHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleCallbacks { - - private val activities = WeakHashMap() - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - activities[activity] = Unit - } - - override fun onActivityDestroyed(activity: Activity) { - activities.remove(activity) - } - - fun recreateAll() { - val snapshot = activities.keys.toList() - snapshot.forEach { ActivityCompat.recreate(it) } - } - - fun recreate(cls: Class) { - val activity = activities.keys.find { x -> x.javaClass == cls } ?: return - ActivityCompat.recreate(activity) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/BaseActivityEntryPoint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/BaseActivityEntryPoint.kt deleted file mode 100644 index 309883319..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/BaseActivityEntryPoint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.koitharu.kotatsu.core.prefs.AppSettings - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface BaseActivityEntryPoint { - val settings: AppSettings -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CollapseActionViewCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CollapseActionViewCallback.kt deleted file mode 100644 index b417e40e3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CollapseActionViewCallback.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener -import androidx.activity.OnBackPressedCallback - -class CollapseActionViewCallback( - private val menuItem: MenuItem -) : OnBackPressedCallback(menuItem.isActionViewExpanded), OnActionExpandListener { - - override fun handleOnBackPressed() { - menuItem.collapseActionView() - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - isEnabled = true - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - isEnabled = false - return true - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/DefaultTextWatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/DefaultTextWatcher.kt deleted file mode 100644 index 999dd6641..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/DefaultTextWatcher.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.text.Editable -import android.text.TextWatcher - -interface DefaultTextWatcher : 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 - - override fun afterTextChanged(s: Editable?) = Unit -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/MenuInvalidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/MenuInvalidator.kt deleted file mode 100644 index d2fd6978e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/MenuInvalidator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import androidx.core.view.MenuHost -import kotlinx.coroutines.flow.FlowCollector - -class MenuInvalidator( - private val host: MenuHost, -) : FlowCollector { - - override suspend fun emit(value: Any?) { - host.invalidateMenu() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/OptionsMenuBadgeHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/OptionsMenuBadgeHelper.kt deleted file mode 100644 index 7e1ff5daf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/OptionsMenuBadgeHelper.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import androidx.annotation.IdRes -import androidx.appcompat.widget.Toolbar -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.badge.BadgeUtils -import com.google.android.material.badge.ExperimentalBadgeUtils - -@androidx.annotation.OptIn(ExperimentalBadgeUtils::class) -class OptionsMenuBadgeHelper( - private val toolbar: Toolbar, - @IdRes private val itemId: Int, -) { - - private var badge: BadgeDrawable? = null - - fun setBadgeVisible(isVisible: Boolean) { - if (isVisible) { - showBadge() - } else { - hideBadge() - } - } - - private fun hideBadge() { - badge?.let { - BadgeUtils.detachBadgeDrawable(it, toolbar, itemId) - } - badge = null - } - - private fun showBadge() { - val badgeDrawable = badge ?: BadgeDrawable.create(toolbar.context).also { - badge = it - } - BadgeUtils.attachBadgeDrawable(badgeDrawable, toolbar, itemId) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleAction.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleAction.kt deleted file mode 100644 index f9fea6652..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import androidx.annotation.StringRes - -class ReversibleAction( - @StringRes val stringResId: Int, - val handle: ReversibleHandle?, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt deleted file mode 100644 index b66e64cbb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleActionObserver.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.view.View -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.flow.FlowCollector -import org.koitharu.kotatsu.R - -class ReversibleActionObserver( - private val snackbarHost: View, -) : FlowCollector { - - override suspend fun emit(value: ReversibleAction) { - val handle = value.handle - val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG - val snackbar = Snackbar.make(snackbarHost, value.stringResId, length) - if (handle != null) { - snackbar.setAction(R.string.undo) { handle.reverseAsync() } - } - snackbar.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt deleted file mode 100644 index de38ffff4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ReversibleHandle.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -fun interface ReversibleHandle { - - suspend fun reverse() -} - -fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { - runCatchingCancellable { - withContext(NonCancellable) { - reverse() - } - }.onFailure { - it.printStackTraceDebug() - } -} - -operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle { - this.reverse() - other.reverse() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SpanSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SpanSizeResolver.kt deleted file mode 100644 index 9c0e07f91..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SpanSizeResolver.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.view.View -import androidx.annotation.Px -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.parsers.util.toIntUp -import kotlin.math.abs - -class SpanSizeResolver( - private val recyclerView: RecyclerView, - @Px private val minItemWidth: Int, -) : View.OnLayoutChangeListener { - - fun attach() { - recyclerView.addOnLayoutChangeListener(this) - } - - fun detach() { - recyclerView.removeOnLayoutChangeListener(this) - } - - override fun onLayoutChange( - v: View?, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - invalidateInternal(abs(right - left)) - } - - fun invalidate() { - invalidateInternal(recyclerView.width) - } - - private fun invalidateInternal(width: Int) { - if (width <= 0) { - return - } - val lm = recyclerView.layoutManager as? GridLayoutManager ?: return - val estimatedCount = (width / minItemWidth.toFloat()).toIntUp() - if (lm.spanCount != estimatedCount) { - lm.spanCount = estimatedCount - lm.spanSizeLookup?.run { - invalidateSpanGroupIndexCache() - invalidateSpanIndexCache() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/StatusBarDimHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/StatusBarDimHelper.kt deleted file mode 100644 index b58f36ae1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/StatusBarDimHelper.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.animation.ValueAnimator -import android.view.animation.AccelerateDecelerateInterpolator -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.shape.MaterialShapeDrawable -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import com.google.android.material.R as materialR - -class StatusBarDimHelper : AppBarLayout.OnOffsetChangedListener { - - private var animator: ValueAnimator? = null - private val interpolator = AccelerateDecelerateInterpolator() - - override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { - val foreground = appBarLayout.statusBarForeground ?: return - val start = foreground.alpha - val collapsed = verticalOffset != 0 - val end = if (collapsed) 255 else 0 - animator?.cancel() - if (start == end) { - animator = null - return - } - animator = ValueAnimator.ofInt(start, end).apply { - duration = appBarLayout.context.getAnimationDuration(materialR.integer.app_bar_elevation_anim_duration) - interpolator = this@StatusBarDimHelper.interpolator - addUpdateListener { - foreground.alpha = it.animatedValue as Int - } - start() - } - } - - fun attachToAppBar(appBarLayout: AppBarLayout) { - appBarLayout.addOnOffsetChangedListener(this) - appBarLayout.statusBarForeground = - MaterialShapeDrawable.createWithElevationOverlay(appBarLayout.context).apply { - alpha = 0 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SystemUiController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SystemUiController.kt deleted file mode 100644 index d5887d12f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/SystemUiController.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.os.Build -import android.view.View -import android.view.Window -import android.view.WindowInsets -import android.view.WindowInsetsController -import androidx.annotation.RequiresApi - -sealed class SystemUiController( - protected val window: Window, -) { - - abstract fun setSystemUiVisible(value: Boolean) - - @RequiresApi(Build.VERSION_CODES.S) - private class Api30Impl(window: Window) : SystemUiController(window) { - - private val insetsController = checkNotNull(window.decorView.windowInsetsController) - - override fun setSystemUiVisible(value: Boolean) { - if (value) { - insetsController.show(WindowInsets.Type.systemBars()) - insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT - } else { - insetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - insetsController.hide(WindowInsets.Type.systemBars()) - } - } - } - - @Suppress("DEPRECATION") - private class LegacyImpl(window: Window) : SystemUiController(window) { - - override fun setSystemUiVisible(value: Boolean) { - window.decorView.systemUiVisibility = if (value) { - View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - } else { - View.SYSTEM_UI_FLAG_LAYOUT_STABLE or - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or - View.SYSTEM_UI_FLAG_FULLSCREEN or - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - } - } - - companion object { - - operator fun invoke(window: Window): SystemUiController = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Api30Impl(window) - } else { - LegacyImpl(window) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CubicSlider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CubicSlider.kt deleted file mode 100644 index a75693982..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CubicSlider.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.content.Context -import android.util.ArrayMap -import android.util.AttributeSet -import com.google.android.material.slider.Slider -import kotlin.math.cbrt -import kotlin.math.pow - -class CubicSlider @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : Slider(context, attrs) { - - private val changeListeners = ArrayMap(1) - - override fun setValue(value: Float) { - super.setValue(value.unmap()) - } - - override fun getValue(): Float { - return super.getValue().map() - } - - override fun getValueFrom(): Float { - return super.getValueFrom().map() - } - - override fun setValueFrom(valueFrom: Float) { - super.setValueFrom(valueFrom.unmap()) - } - - override fun getValueTo(): Float { - return super.getValueTo().map() - } - - override fun setValueTo(valueTo: Float) { - super.setValueTo(valueTo.unmap()) - } - - override fun addOnChangeListener(listener: OnChangeListener) { - val mapper = OnChangeListenerMapper(listener) - super.addOnChangeListener(mapper) - changeListeners[listener] = mapper - } - - override fun removeOnChangeListener(listener: OnChangeListener) { - changeListeners.remove(listener)?.let { - super.removeOnChangeListener(it) - } - } - - override fun clearOnChangeListeners() { - super.clearOnChangeListeners() - changeListeners.clear() - } - - private fun Float.map(): Float { - return this.pow(3) - } - - private fun Float.unmap(): Float { - return cbrt(this) - } - - private inner class OnChangeListenerMapper( - private val delegate: OnChangeListener, - ) : OnChangeListener { - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - delegate.onValueChange(slider, value.map(), fromUser) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/EnhancedViewPager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/EnhancedViewPager.kt deleted file mode 100644 index a8680b5e3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/EnhancedViewPager.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import androidx.viewpager.widget.ViewPager -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -@SuppressLint("ClickableViewAccessibility") -class EnhancedViewPager @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : ViewPager(context, attrs) { - - var isUserInputEnabled: Boolean = true - set(value) { - field = value - if (!value) { - cancelPendingInputEvents() - } - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - return isUserInputEnabled && super.onTouchEvent(event) - } - - override fun onInterceptTouchEvent(event: MotionEvent): Boolean { - return try { - isUserInputEnabled && super.onInterceptTouchEvent(event) - } catch (e: IllegalArgumentException) { - e.printStackTraceDebug() - false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt deleted file mode 100644 index 629ffcd12..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.animation.ValueAnimator -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.animation.DecelerateInterpolator -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.bottomnavigation.BottomNavigationView -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.measureHeight - -class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor( - context: Context? = null, - attrs: AttributeSet? = null, -) : CoordinatorLayout.Behavior(context, attrs) { - - @ViewCompat.NestedScrollType - private var lastStartedType: Int = 0 - - private var offsetAnimator: ValueAnimator? = null - - private var dyRatio = 1F - - override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean { - return dependency is AppBarLayout - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: BottomNavigationView, - dependency: View, - ): Boolean { - val appBarSize = dependency.measureHeight() - dyRatio = if (appBarSize > 0) { - child.measureHeight().toFloat() / appBarSize - } else { - 1F - } - return false - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: BottomNavigationView, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean { - if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) { - return false - } - lastStartedType = type - offsetAnimator?.cancel() - return true - } - - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: BottomNavigationView, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int, - ) { - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat()) - } - - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: BottomNavigationView, - target: View, - type: Int, - ) { - if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) { - animateBottomNavigationVisibility(child, child.translationY < child.height / 2) - } - } - - private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) { - offsetAnimator?.cancel() - offsetAnimator = ValueAnimator().apply { - interpolator = DecelerateInterpolator() - duration = child.context.getAnimationDuration(R.integer.config_shorterAnimTime) - addUpdateListener { - child.translationY = it.animatedValue as Float - } - } - offsetAnimator?.setFloatValues( - child.translationY, - if (isVisible) 0F else child.height.toFloat(), - ) - offsetAnimator?.start() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/NestedRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/NestedRecyclerView.kt deleted file mode 100644 index 5056e9c08..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/NestedRecyclerView.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.MotionEvent -import androidx.core.content.withStyledAttributes -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R - -class NestedRecyclerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : RecyclerView(context, attrs) { - - private var maxHeight: Int = 0 - - init { - context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) { - maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight) - } - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(e: MotionEvent?): Boolean { - if (e?.actionMasked == MotionEvent.ACTION_UP) { - requestDisallowInterceptTouchEvent(false) - } else { - requestDisallowInterceptTouchEvent(true) - } - return super.onTouchEvent(e) - } - - override fun onMeasure(widthSpec: Int, heightSpec: Int) { - super.onMeasure( - widthSpec, - if (maxHeight == 0) { - heightSpec - } else { - MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) - }, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt deleted file mode 100644 index b5cb32206..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/PieChart.kt +++ /dev/null @@ -1,397 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.CornerPathEffect -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF -import android.graphics.Typeface -import android.os.Build -import android.os.Parcelable -import android.text.Layout -import android.text.StaticLayout -import android.text.TextDirectionHeuristic -import android.text.TextDirectionHeuristics -import android.text.TextPaint -import android.util.AttributeSet -import android.view.View -import androidx.annotation.RequiresApi -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.draw -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.resolveSp - -class PieChart @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr), PieChartInterface { - - private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1) - private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2) - private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3) - private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE) - private val marginText: Float = marginTextFirst + marginTextSecond - private val circleRect = RectF() - private var circleStrokeWidth: Float = context.resources.resolveDp(6f) - private var circleRadius: Float = 0f - private var circlePadding: Float = context.resources.resolveDp(8f) - private var circlePaintRoundSize: Boolean = true - private var circleSectionSpace: Float = 3f - private var circleCenterX: Float = 0f - private var circleCenterY: Float = 0f - private var numberTextPaint: TextPaint = TextPaint() - private var descriptionTextPain: TextPaint = TextPaint() - private var amountTextPaint: TextPaint = TextPaint() - private var textStartX: Float = 0f - private var textStartY: Float = 0f - private var textHeight: Int = 0 - private var textCircleRadius: Float = context.resources.resolveDp(4f) - private var textAmountStr: String = "" - private var textAmountY: Float = 0f - private var textAmountXNumber: Float = 0f - private var textAmountXDescription: Float = 0f - private var textAmountYDescription: Float = 0f - private var totalAmount: Int = 0 - private var pieChartColors: List = listOf() - private var percentageCircleList: List = listOf() - private var textRowList: MutableList = mutableListOf() - private var dataList: List> = listOf() - private var animationSweepAngle: Int = 0 - - init { - var textAmountSize: Float = context.resources.resolveSp(22f) - var textNumberSize: Float = context.resources.resolveSp(20f) - var textDescriptionSize: Float = context.resources.resolveSp(14f) - var textAmountColor: Int = Color.WHITE - var textNumberColor: Int = Color.WHITE - var textDescriptionColor: Int = Color.GRAY - - if (attrs != null) { - val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart) - - val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0) - pieChartColors = typeArray.resources.getStringArray(colorResId).toList() - - marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst) - marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond) - marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird) - marginSmallCircle = - typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle) - - circleStrokeWidth = - typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth) - circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding) - circlePaintRoundSize = - typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize) - circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace) - - textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius) - textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize) - textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize) - textDescriptionSize = - typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize) - textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor) - textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor) - textDescriptionColor = - typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor) - textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: "" - - typeArray.recycle() - } - - circlePadding += circleStrokeWidth - - // Инициализация кистей View - initPaints(amountTextPaint, textAmountSize, textAmountColor) - initPaints(numberTextPaint, textNumberSize, textNumberColor) - initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true) - } - - @RequiresApi(Build.VERSION_CODES.M) - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - textRowList.clear() - - val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH) - - val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT) - val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt()) - - textStartX = initSizeWidth - textTextWidth.toFloat() - textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2 - - calculateCircleRadius(initSizeWidth, initSizeHeight) - - setMeasuredDimension(initSizeWidth, initSizeHeight) - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - drawCircle(canvas) - drawText(canvas) - } - - override fun onRestoreInstanceState(state: Parcelable?) { - val pieChartState = state as? PieChartState - super.onRestoreInstanceState(pieChartState?.superState ?: state) - - dataList = pieChartState?.dataList ?: listOf() - } - - override fun onSaveInstanceState(): Parcelable { - val superState = super.onSaveInstanceState() - return PieChartState(superState, dataList) - } - - override fun setDataChart(list: List>) { - dataList = list - calculatePercentageOfData() - } - - override fun startAnimation() { - val animator = ValueAnimator.ofInt(0, 360).apply { - duration = context.getAnimationDuration(android.R.integer.config_longAnimTime) - interpolator = FastOutSlowInInterpolator() - addUpdateListener { valueAnimator -> - animationSweepAngle = valueAnimator.animatedValue as Int - invalidate() - } - } - animator.start() - } - - private fun drawCircle(canvas: Canvas) { - for (percent in percentageCircleList) { - if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) { - canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint) - } else if (animationSweepAngle > percent.percentToStartAt) { - canvas.drawArc( - circleRect, - percent.percentToStartAt, - animationSweepAngle - percent.percentToStartAt, - false, - percent.paint, - ) - } - } - } - - private fun drawText(canvas: Canvas) { - var textBuffY = textStartY - textRowList.forEachIndexed { index, staticLayout -> - if (index % 2 == 0) { - staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY) - canvas.drawCircle( - textStartX + marginSmallCircle / 2, - textBuffY + staticLayout.height / 2 + textCircleRadius / 2, - textCircleRadius, - Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) }, - ) - textBuffY += staticLayout.height + marginTextFirst - } else { - staticLayout.draw(canvas, textStartX, textBuffY) - textBuffY += staticLayout.height + marginTextSecond - } - } - - canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint) - canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain) - } - - private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) { - textPaint.color = textColor - textPaint.textSize = textSize - textPaint.isAntiAlias = true - - if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - } - - private fun resolveDefaultSize(spec: Int, defValue: Int): Int { - return when (MeasureSpec.getMode(spec)) { - MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue) - else -> MeasureSpec.getSize(spec) - } - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int { - val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT) - textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt() - - val textHeightWithPadding = textHeight + paddingTop + paddingBottom - return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight - } - - private fun calculateCircleRadius(width: Int, height: Int) { - val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT) - circleRadius = if (circleViewWidth > height) { - (height.toFloat() - circlePadding) / 2 - } else { - circleViewWidth.toFloat() / 2 - } - - with(circleRect) { - left = circlePadding - top = height / 2 - circleRadius - right = circleRadius * 2 + circlePadding - bottom = height / 2 + circleRadius - } - - circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2 - circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2 - - textAmountY = circleCenterY - - val sizeTextAmountNumber = getWidthOfAmountText( - totalAmount.toString(), - amountTextPaint, - ) - - textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2 - textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2 - textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun getTextViewHeight(maxWidth: Int): Int { - var textHeight = 0 - dataList.forEach { - val textLayoutNumber = getMultilineText( - text = it.first.toString(), - textPaint = numberTextPaint, - width = maxWidth, - ) - val textLayoutDescription = getMultilineText( - text = it.second, - textPaint = descriptionTextPain, - width = maxWidth, - ) - textRowList.apply { - add(textLayoutNumber) - add(textLayoutDescription) - } - textHeight += textLayoutNumber.height + textLayoutDescription.height - } - - return textHeight - } - - private fun calculatePercentageOfData() { - totalAmount = dataList.fold(0) { res, value -> res + value.first } - - var startAt = circleSectionSpace - percentageCircleList = dataList.mapIndexed { index, pair -> - var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace - percent = if (percent < 0f) 0f else percent - - val resultModel = PieChartModel( - percentOfCircle = percent, - percentToStartAt = startAt, - colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]), - stroke = circleStrokeWidth, - paintRound = circlePaintRoundSize, - ) - if (percent != 0f) startAt += percent + circleSectionSpace - resultModel - } - } - - private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect { - val bounds = Rect() - textPaint.getTextBounds(text, 0, text.length, bounds) - return bounds - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun getMultilineText( - text: CharSequence, - textPaint: TextPaint, - width: Int, - start: Int = 0, - end: Int = text.length, - alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, - textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR, - spacingMult: Float = 1f, - spacingAdd: Float = 0f - ): StaticLayout { - - return StaticLayout.Builder - .obtain(text, start, end, textPaint, width) - .setAlignment(alignment) - .setTextDirection(textDir) - .setLineSpacing(spacingAdd, spacingMult) - .build() - } - - companion object { - private const val DEFAULT_MARGIN_TEXT_1 = 2f - private const val DEFAULT_MARGIN_TEXT_2 = 10f - private const val DEFAULT_MARGIN_TEXT_3 = 2f - private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f - - private const val TEXT_WIDTH_PERCENT = 0.40 - private const val CIRCLE_WIDTH_PERCENT = 0.50 - - const val DEFAULT_VIEW_SIZE_HEIGHT = 150 - const val DEFAULT_VIEW_SIZE_WIDTH = 250 - } -} - -interface PieChartInterface { - - fun setDataChart(list: List>) - - fun startAnimation() -} - -data class PieChartModel( - var percentOfCircle: Float = 0f, - var percentToStartAt: Float = 0f, - var colorOfLine: Int = 0, - var stroke: Float = 0f, - var paint: Paint = Paint(), - var paintRound: Boolean = true -) { - - init { - if (percentOfCircle < 0 || percentOfCircle > 100) { - percentOfCircle = 100f - } - - percentOfCircle = 360 * percentOfCircle / 100 - - if (percentToStartAt < 0 || percentToStartAt > 100) { - percentToStartAt = 0f - } - - percentToStartAt = 360 * percentToStartAt / 100 - - if (colorOfLine == 0) { - colorOfLine = Color.parseColor("#000000") - } - - paint = Paint() - paint.color = colorOfLine - paint.isAntiAlias = true - paint.style = Paint.Style.STROKE - paint.strokeWidth = stroke - paint.isDither = true - - if (paintRound) { - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - paint.pathEffect = CornerPathEffect(8f) - } - } -} - -class PieChartState( - superSavedState: Parcelable?, - val dataList: List> -) : View.BaseSavedState(superSavedState), Parcelable diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SegmentedBarView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SegmentedBarView.kt deleted file mode 100644 index 78ca6ad48..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SegmentedBarView.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.animation.Animator -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Canvas -import android.graphics.Outline -import android.graphics.Paint -import android.util.AttributeSet -import android.view.View -import android.view.ViewOutlineProvider -import androidx.annotation.ColorInt -import androidx.annotation.FloatRange -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.parsers.util.replaceWith - -class SegmentedBarView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val segmentsData = ArrayList() - private val segmentsSizes = ArrayList() - private var cornerSize = 0f - private var scaleFactor = 1f - private var scaleAnimator: ValueAnimator? = null - - init { - paint.strokeWidth = context.resources.resolveDp(0f) - outlineProvider = OutlineProvider() - clipToOutline = true - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - cornerSize = h / 2f - updateSizes() - } - - override fun onDraw(canvas: Canvas) { - if (segmentsSizes.isEmpty()) { - return - } - val w = width.toFloat() - var x = w - segmentsSizes.last() - for (i in (0 until segmentsData.size).reversed()) { - val segment = segmentsData[i] - paint.color = segment.color - paint.style = Paint.Style.FILL - val segmentWidth = segmentsSizes[i] - canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint) - paint.style = Paint.Style.STROKE - canvas.drawRoundRect(0f, 0f, x + cornerSize, height.toFloat(), cornerSize, cornerSize, paint) - x -= segmentWidth - } - paint.style = Paint.Style.STROKE - canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint) - } - - override fun onAnimationStart(animation: Animator) = Unit - - override fun onAnimationEnd(animation: Animator) { - if (scaleAnimator === animation) { - scaleAnimator = null - } - } - - override fun onAnimationUpdate(animation: ValueAnimator) { - scaleFactor = animation.animatedValue as Float - updateSizes() - invalidate() - } - - override fun onAnimationCancel(animation: Animator) = Unit - - override fun onAnimationRepeat(animation: Animator) = Unit - - fun animateSegments(value: List) { - scaleAnimator?.cancel() - segmentsData.replaceWith(value) - if (!context.isAnimationsEnabled) { - scaleAnimator = null - scaleFactor = 1f - updateSizes() - invalidate() - return - } - scaleFactor = 0f - updateSizes() - invalidate() - val animator = ValueAnimator.ofFloat(0f, 1f) - animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime) - animator.interpolator = FastOutSlowInInterpolator() - animator.addUpdateListener(this@SegmentedBarView) - animator.addListener(this@SegmentedBarView) - scaleAnimator = animator - animator.start() - } - - private fun updateSizes() { - segmentsSizes.clear() - segmentsSizes.ensureCapacity(segmentsData.size + 1) - var w = width.toFloat() - val maxScale = (scaleFactor * (segmentsData.size - 1)).coerceAtLeast(1f) - for ((index, segment) in segmentsData.withIndex()) { - val scale = (scaleFactor * (index + 1) / maxScale).coerceAtMost(1f) - val segmentWidth = (w * segment.percent).coerceAtLeast( - if (index == 0) height.toFloat() else cornerSize, - ) * scale - segmentsSizes.add(segmentWidth) - w -= segmentWidth - } - segmentsSizes.add(w) - } - - data class Segment( - @FloatRange(from = 0.0, to = 1.0) val percent: Float, - @ColorInt val color: Int, - ) - - private class OutlineProvider : ViewOutlineProvider() { - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt deleted file mode 100644 index 1f80fb57b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SelectableTextView.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.content.Context -import android.text.Selection -import android.text.Spannable -import android.util.AttributeSet -import android.view.MotionEvent -import androidx.annotation.AttrRes -import com.google.android.material.textview.MaterialTextView - -class SelectableTextView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = android.R.attr.textViewStyle, -) : MaterialTextView(context, attrs, defStyleAttr) { - - override fun dispatchTouchEvent(event: MotionEvent?): Boolean { - fixSelectionRange() - return super.dispatchTouchEvent(event) - } - - // https://stackoverflow.com/questions/22810147/error-when-selecting-text-from-textview-java-lang-indexoutofboundsexception-se - private fun fixSelectionRange() { - if (selectionStart < 0 || selectionEnd < 0) { - val spannableText = text as? Spannable ?: return - Selection.setSelection(spannableText, text.length) - } - } - - override fun scrollTo(x: Int, y: Int) { - super.scrollTo(0, 0) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ShapeView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ShapeView.kt deleted file mode 100644 index 32b7f47dd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ShapeView.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Outline -import android.graphics.Paint -import android.graphics.Path -import android.util.AttributeSet -import android.view.View -import android.view.ViewOutlineProvider -import androidx.core.content.withStyledAttributes -import androidx.core.graphics.withClip -import com.google.android.material.drawable.DrawableUtils -import org.koitharu.kotatsu.R - -class ShapeView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : View(context, attrs, defStyleAttr) { - - private val corners = FloatArray(8) - private val outlinePath = Path() - private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG) - - init { - context.withStyledAttributes(attrs, R.styleable.ShapeView, defStyleAttr) { - val cornerSize = getDimension(R.styleable.ShapeView_cornerSize, 0f) - corners[0] = getDimension(R.styleable.ShapeView_cornerSizeTopLeft, cornerSize) - corners[1] = corners[0] - corners[2] = getDimension(R.styleable.ShapeView_cornerSizeTopRight, cornerSize) - corners[3] = corners[2] - corners[4] = getDimension(R.styleable.ShapeView_cornerSizeBottomRight, cornerSize) - corners[5] = corners[4] - corners[6] = getDimension(R.styleable.ShapeView_cornerSizeBottomLeft, cornerSize) - corners[7] = corners[6] - strokePaint.color = getColor(R.styleable.ShapeView_strokeColor, Color.TRANSPARENT) - strokePaint.strokeWidth = getDimension(R.styleable.ShapeView_strokeWidth, 0f) - strokePaint.style = Paint.Style.STROKE - } - outlineProvider = OutlineProvider() - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - if (w != oldw || h != oldh) { - rebuildPath() - } - } - - override fun draw(canvas: Canvas) { - canvas.withClip(outlinePath) { - super.draw(canvas) - } - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (strokePaint.strokeWidth > 0f) { - canvas.drawPath(outlinePath, strokePaint) - } - } - - private fun rebuildPath() { - outlinePath.reset() - val w = width - val h = height - if (w > 0 && h > 0) { - outlinePath.addRoundRect(0f, 0f, w.toFloat(), h.toFloat(), corners, Path.Direction.CW) - } - } - - private inner class OutlineProvider : ViewOutlineProvider() { - - @SuppressLint("RestrictedApi") - override fun getOutline(view: View?, outline: Outline) { - val corner = corners[0] - var isRoundRect = true - for (item in corners) { - if (item != corner) { - isRoundRect = false - break - } - } - if (isRoundRect) { - outline.setRoundRect(0, 0, width, height, corner) - } else { - DrawableUtils.setOutlineToPath(outline, outlinePath) - } - } - } -} 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 deleted file mode 100644 index 57e8fdcfa..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt +++ /dev/null @@ -1,157 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.TimeInterpolator -import android.content.Context -import android.os.Parcel -import android.os.Parcelable -import android.util.AttributeSet -import android.view.ViewPropertyAnimator -import androidx.annotation.AttrRes -import androidx.annotation.StyleRes -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.customview.view.AbsSavedState -import androidx.interpolator.view.animation.FastOutLinearInInterpolator -import androidx.interpolator.view.animation.LinearOutSlowInInterpolator -import com.google.android.material.bottomnavigation.BottomNavigationView -import org.koitharu.kotatsu.core.util.ext.applySystemAnimatorScale -import org.koitharu.kotatsu.core.util.ext.measureHeight -import com.google.android.material.R as materialR - -private const val STATE_DOWN = 1 -private const val STATE_UP = 2 - -private const val SLIDE_UP_ANIMATION_DURATION = 225L -private const val SLIDE_DOWN_ANIMATION_DURATION = 175L - -class SlidingBottomNavigationView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = materialR.attr.bottomNavigationStyle, - @StyleRes defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView, -) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes), - CoordinatorLayout.AttachedBehavior { - - private var currentAnimator: ViewPropertyAnimator? = null - - private var currentState = STATE_UP - private var behavior = HideBottomNavigationOnScrollBehavior() - - override fun getBehavior(): CoordinatorLayout.Behavior<*> { - return behavior - } - - override fun onSaveInstanceState(): Parcelable { - val superState = super.onSaveInstanceState() - return SavedState(superState, currentState, translationY) - } - - override fun onRestoreInstanceState(state: Parcelable?) { - if (state is SavedState) { - super.onRestoreInstanceState(state.superState) - super.setTranslationY(state.translationY) - currentState = state.currentState - } else { - super.onRestoreInstanceState(state) - } - } - - override fun setTranslationY(translationY: Float) { - // Disallow translation change when state down - if (currentState != STATE_DOWN) { - super.setTranslationY(translationY) - } - } - - fun show() { - if (currentState == STATE_UP) { - return - } - currentAnimator?.cancel() - clearAnimation() - - currentState = STATE_UP - animateTranslation( - 0F, - SLIDE_UP_ANIMATION_DURATION, - LinearOutSlowInInterpolator(), - ) - } - - fun hide() { - if (currentState == STATE_DOWN) { - return - } - currentAnimator?.cancel() - clearAnimation() - - currentState = STATE_DOWN - val target = measureHeight() - if (target == 0) { - return - } - animateTranslation( - target.toFloat(), - SLIDE_DOWN_ANIMATION_DURATION, - FastOutLinearInInterpolator(), - ) - } - - fun showOrHide(show: Boolean) { - if (show) { - show() - } else { - hide() - } - } - - private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { - currentAnimator = animate() - .translationY(targetY) - .setInterpolator(interpolator) - .setDuration(duration) - .applySystemAnimatorScale(context) - .setListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - currentAnimator = null - postInvalidate() - } - }, - ) - } - - internal class SavedState : AbsSavedState { - - var currentState = STATE_UP - var translationY = 0F - - constructor(superState: Parcelable, currentState: Int, translationY: Float) : super(superState) { - this.currentState = currentState - this.translationY = translationY - } - - constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { - currentState = source.readInt() - translationY = source.readFloat() - } - - override fun writeToParcel(out: Parcel, flags: Int) { - super.writeToParcel(out, flags) - out.writeInt(currentState) - out.writeFloat(translationY) - } - - companion object { - - @Suppress("unused") - @JvmField - val CREATOR: Parcelable.Creator = object : Parcelable.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/TipView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt deleted file mode 100644 index 53e5312de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt +++ /dev/null @@ -1,134 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.content.Context -import android.graphics.Outline -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewOutlineProvider -import android.widget.LinearLayout -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes -import androidx.core.view.setPadding -import com.google.android.material.shape.MaterialShapeDrawable -import com.google.android.material.shape.ShapeAppearanceModel -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.drawableStart -import org.koitharu.kotatsu.core.util.ext.getDrawableCompat -import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ViewTipBinding - -class TipView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.tipViewStyle, -) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener { - - private val binding = ViewTipBinding.inflate(LayoutInflater.from(context), this) - - var title: CharSequence? - get() = binding.textViewTitle.text - set(value) { - binding.textViewTitle.text = value - } - - var text: CharSequence? - get() = binding.textViewBody.text - set(value) { - binding.textViewBody.text = value - } - - var icon: Drawable? - get() = binding.textViewTitle.drawableStart - set(value) { - binding.textViewTitle.drawableStart = value - } - - var primaryButtonText: CharSequence? - get() = binding.buttonPrimary.textAndVisible - set(value) { - binding.buttonPrimary.textAndVisible = value - } - - var secondaryButtonText: CharSequence? - get() = binding.buttonSecondary.textAndVisible - set(value) { - binding.buttonSecondary.textAndVisible = value - } - - var onButtonClickListener: OnButtonClickListener? = null - - init { - orientation = VERTICAL - setPadding(context.resources.getDimensionPixelOffset(R.dimen.margin_normal)) - context.withStyledAttributes(attrs, R.styleable.TipView, defStyleAttr) { - title = getText(R.styleable.TipView_title) - text = getText(R.styleable.TipView_android_text) - icon = getDrawableCompat(context, R.styleable.TipView_icon) - primaryButtonText = getString(R.styleable.TipView_primaryButtonText) - secondaryButtonText = getString(R.styleable.TipView_secondaryButtonText) - val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build() - background = MaterialShapeDrawable(shapeAppearanceModel).also { - it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor) - ?: context.getThemeColorStateList(R.attr.m3ColorExploreButton) - it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f) - it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor) - it.elevation = getDimension(R.styleable.TipView_elevation, 0f) - } - outlineProvider = OutlineProvider(shapeAppearanceModel) - } - binding.buttonPrimary.setOnClickListener(this) - binding.buttonSecondary.setOnClickListener(this) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_primary -> onButtonClickListener?.onPrimaryButtonClick(this) - R.id.button_secondary -> onButtonClickListener?.onSecondaryButtonClick(this) - } - } - - fun setTitle(@StringRes resId: Int) { - binding.textViewTitle.setText(resId) - } - - fun setText(@StringRes resId: Int) { - binding.textViewBody.setText(resId) - } - - fun setPrimaryButtonText(@StringRes resId: Int) { - binding.buttonPrimary.setTextAndVisible(resId) - } - - fun setSecondaryButtonText(@StringRes resId: Int) { - binding.buttonSecondary.setTextAndVisible(resId) - } - - fun setIcon(@DrawableRes resId: Int) { - icon = ContextCompat.getDrawable(context, resId) - } - - interface OnButtonClickListener { - - fun onPrimaryButtonClick(tipView: TipView) - - fun onSecondaryButtonClick(tipView: TipView) - } - - private class OutlineProvider( - shapeAppearanceModel: ShapeAppearanceModel, - ) : ViewOutlineProvider() { - - private val shapeDrawable = MaterialShapeDrawable(shapeAppearanceModel) - override fun getOutline(view: View, outline: Outline) { - shapeDrawable.setBounds(0, 0, view.width, view.height) - shapeDrawable.getOutline(outline) - } - } - -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt deleted file mode 100644 index 8bf6e5a49..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TwoLinesItemView.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.ColorStateList -import android.content.res.TypedArray -import android.graphics.Color -import android.graphics.drawable.InsetDrawable -import android.graphics.drawable.RippleDrawable -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.RoundRectShape -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import androidx.annotation.AttrRes -import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes -import androidx.core.view.updateLayoutParams -import androidx.core.widget.ImageViewCompat -import androidx.core.widget.TextViewCompat -import com.google.android.material.ripple.RippleUtils -import com.google.android.material.shape.MaterialShapeDrawable -import com.google.android.material.shape.ShapeAppearanceModel -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding - -@SuppressLint("RestrictedApi") -class TwoLinesItemView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = 0, -) : LinearLayout(context, attrs, defStyleAttr) { - - private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this) - - var title: CharSequence? - get() = binding.title.text - set(value) { - binding.title.text = value - } - - var subtitle: CharSequence? - get() = binding.subtitle.textAndVisible - set(value) { - binding.subtitle.textAndVisible = value - } - - init { - var textColors: ColorStateList? = null - context.withStyledAttributes( - set = attrs, - attrs = R.styleable.TwoLinesItemView, - defStyleAttr = defStyleAttr, - defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView, - ) { - val itemRippleColor = getRippleColor(context) - val shape = createShapeDrawable(this) - val roundCorners = FloatArray(8) { resources.resolveDp(16f) } - background = RippleDrawable( - RippleUtils.sanitizeRippleDrawableColor(itemRippleColor), - shape, - ShapeDrawable(RoundRectShape(roundCorners, null, null)), - ) - val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0) - binding.layoutText.updateLayoutParams { marginStart = drawablePadding } - setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0)) - binding.title.text = getText(R.styleable.TwoLinesItemView_title) - binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle) - textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor) - val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat - TextViewCompat.setTextAppearance( - binding.title, - getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback), - ) - TextViewCompat.setTextAppearance( - binding.subtitle, - getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback), - ) - } - if (textColors == null) { - textColors = binding.title.textColors - } - binding.title.setTextColor(textColors) - binding.subtitle.setTextColor(textColors) - ImageViewCompat.setImageTintList(binding.icon, textColors) - } - - fun setIconResource(@DrawableRes resId: Int) { - binding.icon.setImageResource(resId) - } - - private fun createShapeDrawable(ta: TypedArray): InsetDrawable { - val shapeAppearance = ShapeAppearanceModel.builder( - context, - ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0), - ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0), - ).build() - val shapeDrawable = MaterialShapeDrawable(shapeAppearance) - shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor) - return InsetDrawable( - shapeDrawable, - ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0), - ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0), - ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0), - ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0), - ) - } - - private fun getRippleColor(context: Context): ColorStateList { - return ContextCompat.getColorStateList(context, R.color.selector_overlay) - ?: ColorStateList.valueOf(Color.TRANSPARENT) - } -} 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 deleted file mode 100644 index b6f8d222a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.LinearLayout -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 { - - private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this) - - var listener: ZoomControlListener? = null - - init { - binding.buttonZoomIn.setOnClickListener(this) - binding.buttonZoomOut.setOnClickListener(this) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_zoom_in -> listener?.onZoomIn() - R.id.button_zoom_out -> listener?.onZoomOut() - } - } - - interface ZoomControlListener { - - fun onZoomIn() - - fun onZoomOut() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt deleted file mode 100644 index 5926cd7a1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/AcraScreenLogger.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.app.Activity -import android.content.Context -import android.os.Bundle -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks -import org.acra.ACRA -import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks -import java.time.LocalTime -import java.time.temporal.ChronoUnit -import java.util.WeakHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AcraScreenLogger @Inject constructor() : FragmentLifecycleCallbacks(), DefaultActivityLifecycleCallbacks { - - private val keys = WeakHashMap() - - override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { - super.onFragmentAttached(fm, f, context) - ACRA.errorReporter.putCustomData(f.key(), f.arguments.contentToString()) - } - - override fun onFragmentDetached(fm: FragmentManager, f: Fragment) { - super.onFragmentDetached(fm, f) - ACRA.errorReporter.removeCustomData(f.key()) - keys.remove(f) - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - super.onActivityCreated(activity, savedInstanceState) - ACRA.errorReporter.putCustomData(activity.key(), activity.intent.extras.contentToString()) - (activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(this, true) - } - - override fun onActivityDestroyed(activity: Activity) { - super.onActivityDestroyed(activity) - ACRA.errorReporter.removeCustomData(activity.key()) - keys.remove(activity) - } - - private fun Any.key() = keys.getOrPut(this) { - val time = LocalTime.now().truncatedTo(ChronoUnit.SECONDS) - "$time: ${javaClass.simpleName}" - } - - @Suppress("DEPRECATION") - private fun Bundle?.contentToString() = this?.keySet()?.joinToString { k -> - val v = get(k) - "$k=$v" - } ?: toString() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CancellableSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CancellableSource.kt deleted file mode 100644 index d06daa6ea..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CancellableSource.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import kotlinx.coroutines.Job -import kotlinx.coroutines.ensureActive -import okio.Buffer -import okio.ForwardingSource -import okio.Source - -class CancellableSource( - private val job: Job?, - delegate: Source, -) : ForwardingSource(delegate) { - - override fun read(sink: Buffer, byteCount: Long): Long { - job?.ensureActive() - return super.read(sink, byteCount) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex.kt deleted file mode 100644 index 2c8920f3a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import androidx.collection.ArrayMap -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.coroutineContext - -@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2")) -class CompositeMutex : Set { - - private val state = ArrayMap>() - private val mutex = Mutex() - - override val size: Int - get() = state.size - - override fun contains(element: T): Boolean { - return state.containsKey(element) - } - - override fun containsAll(elements: Collection): Boolean { - return elements.all { x -> state.containsKey(x) } - } - - override fun isEmpty(): Boolean { - return state.isEmpty() - } - - override fun iterator(): Iterator { - return state.keys.iterator() - } - - suspend fun lock(element: T) { - while (coroutineContext.isActive) { - waitForRemoval(element) - mutex.withLock { - if (state[element] == null) { - state[element] = MutableStateFlow(false) - return - } - } - } - } - - fun unlock(element: T) { - checkNotNull(state.remove(element)) { - "CompositeMutex is not locked for $element" - }.value = true - } - - private suspend fun waitForRemoval(element: T) { - val flow = state[element] ?: return - flow.first { it } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex2.kt deleted file mode 100644 index 643fe2996..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeMutex2.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import androidx.collection.ArrayMap -import kotlinx.coroutines.sync.Mutex - -class CompositeMutex2 : Set { - - private val delegates = ArrayMap() - - override val size: Int - get() = delegates.size - - override fun contains(element: T): Boolean { - return delegates.containsKey(element) - } - - override fun containsAll(elements: Collection): Boolean { - return elements.all { x -> delegates.containsKey(x) } - } - - override fun isEmpty(): Boolean { - return delegates.isEmpty() - } - - override fun iterator(): Iterator { - return delegates.keys.iterator() - } - - suspend fun lock(element: T) { - val mutex = synchronized(delegates) { - delegates.getOrPut(element) { - Mutex() - } - } - mutex.lock() - } - - fun unlock(element: T) { - synchronized(delegates) { - delegates.remove(element)?.unlock() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt deleted file mode 100644 index fe56ffc3c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeRunnable.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.util - -class CompositeRunnable( - private val children: List, -) : Runnable, Collection by children { - - override fun run() { - for (child in children) { - child.run() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt deleted file mode 100644 index 5eb098c86..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ContinuationResumeRunnable.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -class ContinuationResumeRunnable( - private val continuation: Continuation, -) : Runnable { - - override fun run() { - continuation.resume(Unit) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt deleted file mode 100644 index 1fd768af1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Event.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import kotlinx.coroutines.flow.FlowCollector - -class Event( - private val data: T, -) { - private var isConsumed = false - - suspend fun consume(collector: FlowCollector) { - if (!isConsumed) { - collector.emit(data) - isConsumed = true - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Event<*> - - if (data != other.data) return false - return isConsumed == other.isConsumed - } - - override fun hashCode(): Int { - var result = data?.hashCode() ?: 0 - result = 31 * result + isConsumed.hashCode() - return result - } - - override fun toString(): String { - return "Event(data=$data, isConsumed=$isConsumed)" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/GoneOnInvisibleListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/GoneOnInvisibleListener.kt deleted file mode 100644 index 25ab3717f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/GoneOnInvisibleListener.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.view.View -import android.view.ViewTreeObserver - -/** - * ProgressIndicator become INVISIBLE instead of GONE by hide() call. - * It`s final so we need this workaround - */ -class GoneOnInvisibleListener( - private val view: View, -) : ViewTreeObserver.OnGlobalLayoutListener { - - override fun onGlobalLayout() { - if (view.visibility == View.INVISIBLE) { - view.visibility = View.GONE - } - } - - fun attach() { - view.viewTreeObserver.addOnGlobalLayoutListener(this) - onGlobalLayout() - } - - fun detach() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IdlingDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IdlingDetector.kt deleted file mode 100644 index d9cbedfdc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IdlingDetector.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.os.Handler -import android.os.Looper -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner - -class IdlingDetector( - private val timeoutMs: Long, - private val callback: Callback, -) : DefaultLifecycleObserver { - - private val handler = Handler(Looper.getMainLooper()) - private val idleRunnable = Runnable { - callback.onIdle() - } - - fun bindToLifecycle(owner: LifecycleOwner) { - owner.lifecycle.addObserver(this) - } - - fun onUserInteraction() { - handler.removeCallbacks(idleRunnable) - handler.postDelayed(idleRunnable, timeoutMs) - } - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - owner.lifecycle.removeObserver(this) - handler.removeCallbacks(idleRunnable) - } - - fun interface Callback { - - fun onIdle() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IncognitoModeIndicator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IncognitoModeIndicator.kt deleted file mode 100644 index 7dc2ee1ac..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/IncognitoModeIndicator.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.app.Activity -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class IncognitoModeIndicator @Inject constructor( - private val settings: AppSettings, -) : DefaultActivityLifecycleCallbacks { - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - if (activity !is AppCompatActivity) { - return - } - settings.observeAsFlow( - key = AppSettings.KEY_INCOGNITO_MODE, - valueProducer = { isIncognitoModeEnabled }, - ).flowOn(Dispatchers.IO) - .flowWithLifecycle(activity.lifecycle) - .onEach { updateStatusBar(activity, it) } - .launchIn(activity.lifecycleScope) - } - - private fun updateStatusBar(activity: AppCompatActivity, isIncognitoModeEnabled: Boolean) { - activity.window.statusBarColor = if (isIncognitoModeEnabled) { - ContextCompat.getColor(activity, R.color.status_bar_incognito) - } else { - activity.getThemeColor(android.R.attr.statusBarColor) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt deleted file mode 100644 index eac57d93d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import androidx.core.os.LocaleListCompat -import org.koitharu.kotatsu.core.util.ext.map -import java.util.Locale - -class LocaleComparator : Comparator { - - private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context) - .map { it.language } - .distinct() - - override fun compare(a: Locale, b: Locale): Int { - val indexA = deviceLocales.indexOf(a.language) - val indexB = deviceLocales.indexOf(b.language) - return when { - indexA < 0 && indexB < 0 -> compareValues(a.language, b.language) - indexA < 0 -> 1 - indexB < 0 -> -1 - else -> compareValues(indexA, indexB) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MediatorStateFlow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MediatorStateFlow.kt deleted file mode 100644 index 7bee7ffc2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MediatorStateFlow.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.atomic.AtomicInteger - -abstract class MediatorStateFlow(initialValue: T) : StateFlow { - - private val delegate = MutableStateFlow(initialValue) - private val collectors = AtomicInteger(0) - - final override val replayCache: List - get() = delegate.replayCache - - final override val value: T - get() = delegate.value - - final override suspend fun collect(collector: FlowCollector): Nothing { - try { - if (collectors.getAndIncrement() == 0) { - onActive() - } - delegate.collect(collector) - } finally { - if (collectors.decrementAndGet() == 0) { - onInactive() - } - } - } - - protected fun publishValue(v: T) { - delegate.value = v - } - - protected abstract fun onActive() - - protected abstract fun onInactive() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt deleted file mode 100644 index b14e842c7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import dagger.hilt.android.lifecycle.RetainedLifecycle -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlin.coroutines.CoroutineContext - -class RetainedLifecycleCoroutineScope( - val lifecycle: RetainedLifecycle, -) : CoroutineScope, RetainedLifecycle.OnClearedListener { - - override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate - - init { - lifecycle.addOnClearedListener(this) - } - - override fun onCleared() { - coroutineContext.cancel() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt deleted file mode 100644 index c55aaa121..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/TaggedActivityResult.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.app.Activity - -class TaggedActivityResult( - val tag: String, - val result: Int, -) { - - val isSuccess: Boolean - get() = result == Activity.RESULT_OK -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Throttler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Throttler.kt deleted file mode 100644 index 5748c79bb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/Throttler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.os.SystemClock - -class Throttler( - private val timeoutMs: Long, -) { - - private var lastTick = 0L - - fun throttle(): Boolean { - val now = SystemClock.elapsedRealtime() - return if (lastTick + timeoutMs <= now) { - lastTick = now - true - } else { - false - } - } - - fun reset() { - lastTick = 0L - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt deleted file mode 100644 index fd7554006..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.view.View -import androidx.annotation.OptIn -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.badge.BadgeUtils -import com.google.android.material.badge.ExperimentalBadgeUtils - -@OptIn(ExperimentalBadgeUtils::class) -class ViewBadge( - private val anchor: View, - lifecycleOwner: LifecycleOwner, -) : View.OnLayoutChangeListener, DefaultLifecycleObserver { - - private var badgeDrawable: BadgeDrawable? = null - private var maxCharacterCount: Int = -1 - - var counter: Int - get() = badgeDrawable?.number ?: 0 - set(value) { - val badge = badgeDrawable ?: initBadge() - if (maxCharacterCount != 0) { - badge.number = value - } else { - badge.clearNumber() - } - badge.isVisible = value > 0 - } - - init { - lifecycleOwner.lifecycle.addObserver(this) - } - - override fun onLayoutChange( - v: View?, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - val badge = badgeDrawable ?: return - BadgeUtils.setBadgeDrawableBounds(badge, anchor, null) - } - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - clearBadge() - } - - fun setMaxCharacterCount(value: Int) { - maxCharacterCount = value - badgeDrawable?.let { - if (value == 0) { - it.clearNumber() - } else { - it.maxCharacterCount = value - } - } - } - - private fun initBadge(): BadgeDrawable { - val badge = BadgeDrawable.create(anchor.context) - if (maxCharacterCount > 0) { - badge.maxCharacterCount = maxCharacterCount - } - anchor.addOnLayoutChangeListener(this) - BadgeUtils.attachBadgeDrawable(badge, anchor) - badgeDrawable = badge - return badge - } - - private fun clearBadge() { - val badge = badgeDrawable ?: return - anchor.removeOnLayoutChangeListener(this) - BadgeUtils.detachBadgeDrawable(badge, anchor) - badgeDrawable = null - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/WorkServiceStopHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/WorkServiceStopHelper.kt deleted file mode 100644 index 09e706895..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/WorkServiceStopHelper.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.core.util - -import android.annotation.SuppressLint -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkQuery -import androidx.work.impl.foreground.SystemForegroundService -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import javax.inject.Provider - -/** - * Workaround for issue - * https://issuetracker.google.com/issues/270245927 - * https://issuetracker.google.com/issues/280504155 - */ -class WorkServiceStopHelper( - private val workManagerProvider: Provider, -) { - - fun setup() { - processLifecycleScope.launch(Dispatchers.Default) { - workManagerProvider.get() - .getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING)) - .map { it.isEmpty() } - .distinctUntilChanged() - .collectLatest { - if (it) { - delay(1_000) - stopWorkerService() - } - } - } - } - - @SuppressLint("RestrictedApi") - private fun stopWorkerService() { - SystemForegroundService.getInstance()?.stop() - } -} - 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 deleted file mode 100644 index b237755a0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ /dev/null @@ -1,253 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.Manifest -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.Context -import android.content.Context.ACTIVITY_SERVICE -import android.content.Context.POWER_SERVICE -import android.content.ContextWrapper -import android.content.OperationApplicationException -import android.content.SharedPreferences -import android.content.SyncResult -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo -import android.database.SQLException -import android.graphics.Bitmap -import android.graphics.Color -import android.net.Uri -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.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.annotation.IntegerRes -import androidx.annotation.WorkerThread -import androidx.core.app.ActivityOptionsCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import androidx.work.CoroutineWorker -import com.google.android.material.elevation.ElevationOverlayProvider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible -import okio.IOException -import okio.use -import org.json.JSONException -import org.jsoup.internal.StringUtil.StringJoiner -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException -import java.io.File -import kotlin.math.roundToLong - -val Context.activityManager: ActivityManager? - get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager - -val Context.powerManager: PowerManager? - get() = getSystemService(POWER_SERVICE) as? PowerManager - -fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) - -suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable { - val info = getForegroundInfo() - setForeground(info) -}.isSuccess - -fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { - val pm = context.packageManager - val intent = contract.createIntent(context, input) - return pm.resolveActivity(intent, 0) -} - -fun ActivityResultLauncher.tryLaunch( - input: I, - options: ActivityOptionsCompat? = null, -): Boolean = runCatching { - launch(input, options) -}.onFailure { e -> - e.printStackTraceDebug() -}.isSuccess - -fun SharedPreferences.observe(): Flow = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - trySendBlocking(key) - } - registerOnSharedPreferenceChangeListener(listener) - awaitClose { - unregisterOnSharedPreferenceChangeListener(listener) - } -} - -fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { - emit(valueProducer()) - observe().collect { upstreamKey -> - if (upstreamKey == key) { - emit(valueProducer()) - } - } -}.distinctUntilChanged() - -fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) { - coroutineScope.launch { - delay(delay) - runnable.run() - } -} - -fun SyncResult.onError(error: Throwable) { - when (error) { - is IOException -> stats.numIoExceptions++ - is OperationApplicationException, - is SQLException, - -> databaseError = true - - is JSONException -> stats.numParseExceptions++ - else -> if (BuildConfig.DEBUG) throw error - } - error.printStackTraceDebug() -} - -fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, alphaFactor: Float = 0.7f) { - navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - !context.getSystemBoolean("config_navBarNeedsScrim", true) - ) { - Color.TRANSPARENT - } else { - // Set navbar scrim 70% of navigationBarColor - ElevationOverlayProvider(context).compositeOverlayIfNeeded( - context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor), - elevation, - ) - } -} - -val Context.animatorDurationScale: Float - get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) - -val Context.isAnimationsEnabled: Boolean - get() = animatorDurationScale > 0f - -fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply { - this.duration = (this.duration * context.animatorDurationScale).toLong() -} - -fun Context.getAnimationDuration(@IntegerRes resId: Int): Long { - return (resources.getInteger(resId) * animatorDurationScale).roundToLong() -} - -fun Context.isLowRamDevice(): Boolean { - return activityManager?.isLowRamDevice ?: false -} - -fun Context.isPowerSaveMode(): Boolean { - return powerManager?.isPowerSaveMode == true -} - -val Context.ramAvailable: Long - get() { - val result = MemoryInfo() - activityManager?.getMemoryInfo(result) - 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) { - LocaleConfig(this).supportedLocales?.let { - return LocaleListCompat.wrap(it) - } - } - val tagsList = StringJoiner(",") - try { - val resId = resources.getIdentifier("_generated_res_locale_config", "xml", packageName) - val xpp: XmlPullParser = resources.getXml(resId) - while (xpp.eventType != XmlPullParser.END_DOCUMENT) { - if (xpp.eventType == XmlPullParser.START_TAG) { - if (xpp.name == "locale") { - tagsList.add(xpp.getAttributeValue(0)) - } - } - xpp.next() - } - } catch (e: XmlPullParserException) { - e.printStackTraceDebug() - } catch (e: IOException) { - e.printStackTraceDebug() - } - return LocaleListCompat.forLanguageTags(tagsList.complete()) -} - -fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - -inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean { - return try { - block() - true - } catch (e: Exception) { - if (e.isWebViewUnavailable()) { - Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show() - finishAfterTransition() - false - } else { - throw e - } - } -} - -fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED -} else { - NotificationManagerCompat.from(this).areNotificationsEnabled() -} - -@WorkerThread -suspend fun Bitmap.compressToPNG(output: File) = runInterruptible(Dispatchers.IO) { - output.outputStream().use { os -> - if (!compress(Bitmap.CompressFormat.PNG, 100, os)) { - throw IOException("Failed to encode bitmap into PNG format") - } - } -} - -fun Context.ensureRamAtLeast(requiredSize: Long) { - if (ramAvailable < requiredSize) { - throw IllegalStateException("Not enough free memory") - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt deleted file mode 100644 index faba08d87..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/BottomSheet.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.view.View -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback - -fun BottomSheetBehavior<*>.doOnExpansionsChanged(callback: (isExpanded: Boolean) -> Unit) { - var isExpended = state == BottomSheetBehavior.STATE_EXPANDED - callback(isExpended) - addBottomSheetCallback( - object : BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - val expanded = newState == BottomSheetBehavior.STATE_EXPANDED - if (expanded != isExpended) { - isExpended = expanded - callback(expanded) - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - }, - ) -} 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 deleted file mode 100644 index 5c6258ea4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ /dev/null @@ -1,56 +0,0 @@ -@file:Suppress("DEPRECATION") - -package org.koitharu.kotatsu.core.util.ext - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.Parcel -import android.os.Parcelable -import androidx.core.content.IntentCompat -import androidx.core.os.BundleCompat -import androidx.core.os.ParcelCompat -import androidx.lifecycle.SavedStateHandle -import java.io.Serializable - -// https://issuetracker.google.com/issues/240585930 - -inline fun Bundle.getParcelableCompat(key: String): T? { - return BundleCompat.getParcelable(this, key, T::class.java) -} - -inline fun Intent.getParcelableExtraCompat(key: String): T? { - return IntentCompat.getParcelableExtra(this, key, T::class.java) -} - -inline fun Intent.getSerializableExtraCompat(key: String): T? { - return getSerializableExtra(key) as T? -} - -inline fun Bundle.getSerializableCompat(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getSerializable(key, T::class.java) - } else { - getSerializable(key) as T? - } -} - -inline fun Parcel.readParcelableCompat(): T? { - return ParcelCompat.readParcelable(this, T::class.java.classLoader, T::class.java) -} - -inline fun Parcel.readSerializableCompat(): T? { - return ParcelCompat.readSerializable(this, T::class.java.classLoader, T::class.java) -} - -inline fun Bundle.requireSerializable(key: String): T { - return checkNotNull(getSerializableCompat(key)) { - "Serializable of type \"${T::class.java.name}\" not found at \"$key\"" - } -} - -fun SavedStateHandle.require(key: String): T { - return checkNotNull(get(key)) { - "Value $key not found in SavedStateHandle or has a wrong type" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt deleted file mode 100644 index d3448ccfe..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt +++ /dev/null @@ -1,113 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.content.Context -import android.widget.ImageView -import androidx.core.graphics.drawable.toBitmap -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.ImageResult -import coil.request.SuccessResult -import coil.util.CoilUtils -import com.google.android.material.progressindicator.BaseProgressIndicator -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder -import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener -import org.koitharu.kotatsu.parsers.model.MangaSource - -fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? { - val current = CoilUtils.result(this) - if (current?.request?.lifecycle === lifecycleOwner.lifecycle) { - if (current is SuccessResult && current.request.data == data) { - return null - } - } - // disposeImageRequest() - return ImageRequest.Builder(context) - .data(data) - .lifecycle(lifecycleOwner) - .crossfade(context) - .target(this) -} - -fun ImageView.disposeImageRequest() { - CoilUtils.dispose(this) - setImageDrawable(null) -} - -fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) - -fun ImageResult.getDrawableOrThrow() = when (this) { - is SuccessResult -> drawable - is ErrorResult -> throw throwable -} - -@Deprecated( - "", - ReplaceWith( - "getDrawableOrThrow().toBitmap()", - "androidx.core.graphics.drawable.toBitmap", - ), -) -fun ImageResult.requireBitmap() = getDrawableOrThrow().toBitmap() - -fun ImageResult.toBitmapOrNull() = when (this) { - is SuccessResult -> try { - drawable.toBitmap() - } catch (_: Throwable) { - null - } - - is ErrorResult -> null -} - -fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { - return addListener(ImageRequestIndicatorListener(listOf(indicator))) -} - -fun ImageRequest.Builder.indicator(indicators: List>): ImageRequest.Builder { - return addListener(ImageRequestIndicatorListener(indicators)) -} - -fun ImageRequest.Builder.decodeRegion( - scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, -): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory()) - .setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll) - -@Suppress("SpellCheckingInspection") -fun ImageRequest.Builder.crossfade(context: Context): ImageRequest.Builder { - val duration = context.resources.getInteger(R.integer.config_defaultAnimTime) * context.animatorDurationScale - return crossfade(duration.toInt()) -} - -fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder { - return tag(MangaSource::class.java, source) -} - -fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder { - val existing = build().listener - return listener( - when (existing) { - null -> listener - is CompositeImageRequestListener -> existing + listener - else -> CompositeImageRequestListener(arrayOf(existing, listener)) - }, - ) -} - -private class CompositeImageRequestListener( - private val delegates: Array, -) : ImageRequest.Listener { - - override fun onCancel(request: ImageRequest) = delegates.forEach { it.onCancel(request) } - - override fun onError(request: ImageRequest, result: ErrorResult) = delegates.forEach { it.onError(request, result) } - - override fun onStart(request: ImageRequest) = delegates.forEach { it.onStart(request) } - - override fun onSuccess(request: ImageRequest, result: SuccessResult) = - delegates.forEach { it.onSuccess(request, result) } - - operator fun plus(other: ImageRequest.Listener) = CompositeImageRequestListener(delegates + other) -} 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 deleted file mode 100644 index dcbecf41e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.collection.ArrayMap -import androidx.collection.ArraySet -import org.koitharu.kotatsu.BuildConfig -import java.util.Collections -import java.util.EnumSet - -inline fun MutableSet(size: Int, init: (index: Int) -> T): MutableSet { - val set = ArraySet(size) - repeat(size) { index -> set.add(init(index)) } - return set -} - -inline fun Set(size: Int, init: (index: Int) -> T): Set = when (size) { - 0 -> emptySet() - 1 -> Collections.singleton(init(0)) - else -> MutableSet(size, init) -} - -fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { - this as ArrayList -} else { - ArrayList(this) -} - -fun Map.findKeyByValue(value: V): K? { - for ((k, v) in entries) { - if (v == value) { - return k - } - } - return null -} - -fun Sequence.toListSorted(comparator: Comparator): List { - return toMutableList().apply { sortWith(comparator) } -} - -fun List.takeMostFrequent(limit: Int): List { - val map = ArrayMap(size) - for (item in this) { - map[item] = map.getOrDefault(item, 0) + 1 - } - val entries = map.entries.sortedByDescending { it.value } - val count = minOf(limit, entries.size) - return buildList(count) { - repeat(count) { i -> - add(entries[i].key) - } - } -} - -inline fun > Collection.toEnumSet(): EnumSet = if (isEmpty()) { - EnumSet.noneOf(E::class.java) -} else { - EnumSet.copyOf(this) -} - -fun > Collection.sortedByOrdinal() = sortedBy { it.ordinal } - -fun Iterable.sortedWithSafe(comparator: Comparator): List = try { - sortedWith(comparator) -} catch (e: IllegalArgumentException) { - if (BuildConfig.DEBUG) { - throw e - } else { - toList() - } -} 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 deleted file mode 100644 index 3f273a060..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.annotation.TargetApi -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.storage.StorageManager -import android.provider.DocumentsContract -import org.koitharu.kotatsu.parsers.util.removeSuffix -import java.io.File -import java.lang.reflect.Array as ArrayReflect - -private const val PRIMARY_VOLUME_NAME = "primary" - -fun Uri.resolveFile(context: Context): File? { - val volumeId = getVolumeIdFromTreeUri(this) ?: return null - val volumePath = getVolumePath(volumeId, context)?.removeSuffix(File.separatorChar) ?: return null - val documentPath = getDocumentPathFromTreeUri(this)?.removeSuffix(File.separatorChar) ?: return null - - return File( - if (documentPath.isNotEmpty()) { - if (documentPath.startsWith(File.separator)) { - volumePath + documentPath - } else { - volumePath + File.separator + documentPath - } - } else { - volumePath - }, - ) -} - -private fun getVolumePath(volumeId: String, context: Context): String? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - getVolumePathForAndroid11AndAbove(volumeId, context) - } else { - getVolumePathBeforeAndroid11(volumeId, context) - } -} - - -private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): String? = runCatching { - val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager - val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") - val getVolumeList = mStorageManager.javaClass.getMethod("getVolumeList") - val getUuid = storageVolumeClazz.getMethod("getUuid") - val getPath = storageVolumeClazz.getMethod("getPath") - val isPrimary = storageVolumeClazz.getMethod("isPrimary") - val result = getVolumeList.invoke(mStorageManager) - val length = ArrayReflect.getLength(checkNotNull(result)) - (0 until length).firstNotNullOfOrNull { i -> - val storageVolumeElement = ArrayReflect.get(result, i) - val uuid = getUuid.invoke(storageVolumeElement) as String? - val primary = isPrimary.invoke(storageVolumeElement) as Boolean - when { - primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String - uuid == volumeId -> getPath.invoke(storageVolumeElement) as String - else -> null - } - } -}.onFailure { - it.printStackTraceDebug() -}.getOrNull() - -@TargetApi(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 -> - if (volume.isPrimary && volumeId == PRIMARY_VOLUME_NAME) { - volume.directory?.path - } else { - val uuid = volume.uuid - if (uuid != null && uuid == volumeId) volume.directory?.path else null - } - } -}.onFailure { - it.printStackTraceDebug() -}.getOrNull() - -private fun getVolumeIdFromTreeUri(treeUri: Uri): String? { - val docId = DocumentsContract.getTreeDocumentId(treeUri) - val split = docId.split(":".toRegex()) - return split.firstOrNull()?.takeUnless { it.isEmpty() } -} - -private fun getDocumentPathFromTreeUri(treeUri: Uri): String? { - val docId = DocumentsContract.getTreeDocumentId(treeUri) - val split: Array = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - return if (split.size >= 2 && split[1] != null) split[1] else File.separator -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt deleted file mode 100644 index 3eed0b6a8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.LifecycleDestroyedException -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.lifecycle.RetainedLifecycle -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine -import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -val processLifecycleScope: LifecycleCoroutineScope - inline get() = ProcessLifecycleOwner.get().lifecycleScope - -val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope - inline get() = RetainedLifecycleCoroutineScope(this) - -suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) { - if (currentState.isAtLeast(state)) { - return - } - suspendCancellableCoroutine { cont -> - val observer = ContinuationLifecycleObserver(this, cont, state) - addObserverFromAnyThread(observer) - cont.invokeOnCancellation { - removeObserverFromAnyThread(observer) - } - } -} - -private class ContinuationLifecycleObserver( - private val lifecycle: Lifecycle, - private val continuation: CancellableContinuation, - private val targetState: Lifecycle.State, -) : LifecycleEventObserver { - - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == Lifecycle.Event.upTo(targetState)) { - lifecycle.removeObserver(this) - continuation.resume(Unit) - } else if (event == Lifecycle.Event.ON_DESTROY) { - lifecycle.removeObserver(this) - continuation.resumeWithException(LifecycleDestroyedException()) - } - } -} - -private fun Lifecycle.addObserverFromAnyThread(observer: LifecycleObserver) { - val dispatcher = Dispatchers.Main.immediate - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - dispatcher.dispatch(EmptyCoroutineContext) { addObserver(observer) } - } else { - addObserver(observer) - } -} - -private fun Lifecycle.removeObserverFromAnyThread(observer: LifecycleObserver) { - val dispatcher = Dispatchers.Main.immediate - if (dispatcher.isDispatchNeeded(EmptyCoroutineContext)) { - dispatcher.dispatch(EmptyCoroutineContext) { removeObserver(observer) } - } else { - removeObserver(observer) - } -} - -fun Deferred.getCompletionResultOrNull(): Result? = if (isCompleted) { - getCompletionExceptionOrNull()?.let { error -> - Result.failure(error) - } ?: Result.success(getCompleted()) -} else { - null -} - -fun Deferred.peek(): T? = if (isCompleted) { - runCatchingCancellable { - getCompleted() - }.getOrNull() -} else { - null -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt deleted file mode 100644 index 3cec3da3b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.content.ContentValues -import android.database.Cursor -import org.json.JSONObject - -fun Cursor.toJson(): JSONObject { - val jo = JSONObject() - for (i in 0 until columnCount) { - val name = getColumnName(i) - when (getType(i)) { - Cursor.FIELD_TYPE_STRING -> jo.put(name, getString(i)) - Cursor.FIELD_TYPE_FLOAT -> jo.put(name, getDouble(i)) - Cursor.FIELD_TYPE_INTEGER -> jo.put(name, getLong(i)) - Cursor.FIELD_TYPE_NULL -> jo.put(name, null) - Cursor.FIELD_TYPE_BLOB -> jo.put(name, getBlob(i)) - } - } - return jo -} - -fun JSONObject.toContentValues(): ContentValues { - val cv = ContentValues(length()) - for (key in keys()) { - val name = key.escapeName() - when (val value = get(key)) { - JSONObject.NULL, "null", null -> cv.putNull(name) - is String -> cv.put(name, value) - is Float -> cv.put(name, value) - is Double -> cv.put(name, value) - is Int -> cv.put(name, value) - is Long -> cv.put(name, value) - else -> throw IllegalArgumentException("Value $value cannot be putted in ContentValues") - } - } - return cv -} - -private fun String.escapeName() = "`$this`" 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 deleted file mode 100644 index be2f2e04d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.temporal.ChronoUnit - -fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo { - // TODO: Use Java 9's LocalDate.ofInstant(). - val localDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate() - val now = LocalDate.now() - val diffDays = localDate.until(now, ChronoUnit.DAYS) - - return when { - diffDays == 0L -> { - if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow - else DateTimeAgo.Today - } - diffDays == 1L -> DateTimeAgo.Yesterday - diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt()) - else -> { - val diffMonths = localDate.until(now, ChronoUnit.MONTHS) - if (showMonths && diffMonths <= 6) { - DateTimeAgo.MonthsAgo(diffMonths.toInt()) - } else { - DateTimeAgo.Absolute(localDate) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Display.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Display.kt deleted file mode 100644 index b8ca902d4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Display.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.app.Activity -import android.graphics.Rect -import android.os.Build -import android.util.DisplayMetrics -import android.view.Display - -@Suppress("DEPRECATION") -val Activity.displayCompat: Display - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - display ?: windowManager.defaultDisplay - } else { - windowManager.defaultDisplay - } - -fun Activity.getDisplaySize(): Rect { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - windowManager.currentWindowMetrics.bounds - } else { - val dm = DisplayMetrics() - displayCompat.getRealMetrics(dm) - Rect(0, 0, dm.widthPixels, dm.heightPixels) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt deleted file mode 100644 index 11fc25beb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/EventFlow.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.annotation.AnyThread -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.core.util.Event - -@Suppress("FunctionName") -fun MutableEventFlow() = MutableStateFlow?>(null) - -typealias EventFlow = StateFlow?> - -typealias MutableEventFlow = MutableStateFlow?> - -@AnyThread -fun MutableEventFlow.call(data: T) { - value = Event(data) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt deleted file mode 100644 index 0422b48c8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.os.SystemClock -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.transformLatest -import org.koitharu.kotatsu.R - -fun Flow.onFirst(action: suspend (T) -> Unit): Flow { - var isFirstCall = true - return onEach { - if (isFirstCall) { - action(it) - isFirstCall = false - } - }.onCompletion { - isFirstCall = true - } -} - -fun Flow.onEachWhile(action: suspend (T) -> Boolean): Flow { - var isCalled = false - return onEach { - if (!isCalled) { - isCalled = action(it) - } - }.onCompletion { - isCalled = false - } -} - -inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { - return map { list -> list.map(transform) } -} - -fun Flow.throttle(timeoutMillis: (T) -> Long): Flow { - var lastEmittedAt = 0L - return transformLatest { value -> - val delay = timeoutMillis(value) - val now = SystemClock.elapsedRealtime() - if (delay > 0L) { - if (lastEmittedAt + delay < now) { - delay(lastEmittedAt + delay - now) - } - } - emit(value) - lastEmittedAt = now - } -} - -fun StateFlow.requireValue(): T = checkNotNull(value) { - "StateFlow value is null" -} - -fun Flow>.flatten(): Flow = flow { - collect { value -> - for (item in value) { - emit(item) - } - } -} - -fun Flow.zipWithPrevious(): Flow> = flow { - var previous: T? = null - collect { value -> - val result = previous to value - previous = value - emit(result) - } -} - -@Suppress("UNCHECKED_CAST") -fun combine( - flow: Flow, - flow2: Flow, - flow3: Flow, - flow4: Flow, - flow5: Flow, - flow6: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6) -> R, -): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> - transform( - args[0] as T1, - args[1] as T2, - args[2] as T3, - args[3] as T4, - args[4] as T5, - args[5] as T6, - ) -} - -suspend fun Flow.firstNotNull(): T = checkNotNull(first { x -> x != null }) - -suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x != null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt deleted file mode 100644 index f5e5b68a1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/FlowObserver.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.util.Event - -fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { - val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT - owner.lifecycleScope.launch(start = start) { - collect(collector) - } -} - -fun Flow.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector) { - owner.lifecycleScope.launch { - owner.lifecycle.repeatOnLifecycle(minState) { - collect(collector) - } - } -} - -fun Flow?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector) { - owner.lifecycleScope.launch { - owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - collect { - it?.consume(collector) - } - } - } -} 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 deleted file mode 100644 index 62de07110..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.os.Bundle -import androidx.annotation.MainThread -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.LifecycleOwner -import androidx.lifecycle.Observer -import androidx.lifecycle.coroutineScope -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { - val b = Bundle(size) - b.block() - this.arguments = b - return this -} - -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) -} - -@MainThread -suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner { - val liveData = viewLifecycleOwnerLiveData - liveData.value?.let { return it } - return suspendCancellableCoroutine { cont -> - val observer = object : Observer { - override fun onChanged(value: LifecycleOwner?) { - if (value != null) { - liveData.removeObserver(this) - cont.resume(value) - } - } - } - liveData.observeForever(observer) - cont.invokeOnCancellation { - liveData.removeObserver(observer) - } - } -} - -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) -} 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 deleted file mode 100644 index 2e59b582f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.graphics.Rect -import kotlin.math.roundToInt - -fun Rect.scale(factor: Double) { - val newWidth = (width() * factor).roundToInt() - val newHeight = (height() * factor).roundToInt() - inset( - (width() - newWidth) / 2, - (height() - newHeight) / 2, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt deleted file mode 100644 index 988e06920..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Http.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import okhttp3.Cookie -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import okhttp3.internal.closeQuietly -import okio.IOException -import org.json.JSONObject -import org.jsoup.HttpStatusException -import java.net.HttpURLConnection - -private val TYPE_JSON = "application/json".toMediaType() - -fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) - -fun Response.parseJsonOrNull(): JSONObject? { - return try { - when { - !isSuccessful -> throw IOException(body?.string()) - code == HttpURLConnection.HTTP_NO_CONTENT -> null - else -> JSONObject(body?.string() ?: return null) - } - } finally { - closeQuietly() - } -} - -val HttpUrl.isHttpOrHttps: Boolean - get() { - val s = scheme.lowercase() - return s == "https" || s == "http" - } - -fun Response.ensureSuccess() = apply { - if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) { - closeQuietly() - throw HttpStatusException(message, code, request.url.toString()) - } -} - -fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> - c.name(name) - c.value(value) - if (persistent) { - c.expiresAt(expiresAt) - } - if (hostOnly) { - c.hostOnlyDomain(domain) - } else { - c.domain(domain) - } - c.path(path) - if (secure) { - c.secure() - } - if (httpOnly) { - c.httpOnly() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt deleted file mode 100644 index d41e0ba38..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/IO.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import okhttp3.ResponseBody -import okio.BufferedSink -import okio.Source -import org.koitharu.kotatsu.core.util.CancellableSource -import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody - -fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { - return ProgressResponseBody(this, progressState) -} - -suspend fun Source.cancellable(): Source { - val job = currentCoroutineContext()[Job] - return CancellableSource(job, this) -} - -suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { - writeAll(source.cancellable()) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt deleted file mode 100644 index eef3a3b45..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.view.View -import androidx.core.graphics.Insets - -fun Insets.end(view: View): Int { - return if (view.isRtl) left else right -} - -fun Insets.start(view: View): Int { - return if (view.isRtl) right else left -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Network.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Network.kt deleted file mode 100644 index 9f73ecfbf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Network.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Build - -val Context.connectivityManager: ConnectivityManager - get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - -fun ConnectivityManager.isOnline(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - activeNetwork?.let { isOnline(it) } ?: false - } else { - @Suppress("DEPRECATION") - activeNetworkInfo?.isConnected == true - } -} - -private fun ConnectivityManager.isOnline(network: Network): Boolean { - val capabilities = getNetworkCapabilities(network) - return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -} 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 deleted file mode 100644 index 046bd94ca..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import org.koitharu.kotatsu.core.util.CompositeRunnable - -@Suppress("UNCHECKED_CAST") -fun Class.castOrNull(obj: Any?): T? { - if (obj == null || !isInstance(obj)) { - return null - } - return obj as T -} - -/* CompositeRunnable */ - -operator fun Runnable.plus(other: Runnable): Runnable { - val list = ArrayList(this.size + other.size) - if (this is CompositeRunnable) list.addAll(this) else list.add(this) - if (other is CompositeRunnable) list.addAll(other) else list.add(other) - return CompositeRunnable(list) -} - -private val Runnable.size: Int - get() = if (this is CompositeRunnable) size else 1 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt deleted file mode 100644 index 85fe52e38..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Primitive.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this - -fun longOf(a: Int, b: Int): Long { - return a.toLong() shl 32 or (b.toLong() and 0xffffffffL) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt deleted file mode 100644 index 0dd4d0cf2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.StaggeredGridLayoutManager -import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder -import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewHolder - -fun RecyclerView.clearItemDecorations() { - suppressLayout(true) - while (itemDecorationCount > 0) { - removeItemDecorationAt(0) - } - suppressLayout(false) -} - -fun RecyclerView.removeItemDecoration(cls: Class) { - repeat(itemDecorationCount) { i -> - if (cls.isInstance(getItemDecorationAt(i))) { - removeItemDecorationAt(i) - return - } - } -} - -var RecyclerView.firstVisibleItemPosition: Int - get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() - ?: RecyclerView.NO_POSITION - set(value) { - if (value != RecyclerView.NO_POSITION) { - (layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(value, 0) - } - } - -val RecyclerView.visibleItemCount: Int - get() = (layoutManager as? LinearLayoutManager)?.run { - findLastVisibleItemPosition() - findFirstVisibleItemPosition() - } ?: 0 - -fun RecyclerView.findCenterViewPosition(): Int { - val centerX = width / 2f - val centerY = height / 2f - val view = findChildViewUnder(centerX, centerY) ?: return RecyclerView.NO_POSITION - return getChildAdapterPosition(view) -} - -fun RecyclerView.ViewHolder.getItem(clazz: Class): T? { - val rawItem = when (this) { - is AdapterDelegateViewBindingViewHolder<*, *> -> item - is AdapterDelegateViewHolder<*> -> item - else -> null - } ?: return null - return if (clazz.isAssignableFrom(rawItem.javaClass)) { - clazz.cast(rawItem) - } else { - null - } -} - -val RecyclerView.isScrolledToTop: Boolean - get() { - if (childCount == 0) { - return true - } - val holder = findViewHolderForAdapterPosition(0) - return holder != null && holder.itemView.top >= 0 - } - -val RecyclerView.LayoutManager?.firstVisibleItemPosition - get() = when (this) { - is LinearLayoutManager -> findFirstVisibleItemPosition() - is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null)[0] - else -> 0 - } - -val RecyclerView.LayoutManager?.isLayoutReversed - get() = when (this) { - is LinearLayoutManager -> reverseLayout - is StaggeredGridLayoutManager -> reverseLayout - else -> false - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt deleted file mode 100644 index 4cfa36ecf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Resources -import androidx.annotation.Px -import androidx.core.util.TypedValueCompat -import kotlin.math.roundToInt - -@Px -fun Resources.resolveDp(dp: Int) = resolveDp(dp.toFloat()).roundToInt() - -@Px -fun Resources.resolveDp(dp: Float) = TypedValueCompat.dpToPx(dp, displayMetrics) - -@Px -fun Resources.resolveSp(sp: Float) = TypedValueCompat.spToPx(sp, displayMetrics) - -@SuppressLint("DiscouragedApi") -fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean { - val id = Resources.getSystem().getIdentifier(resName, "bool", "android") - return if (id != 0) { - createPackageContext("android", 0).resources.getBoolean(id) - } else { - fallback - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt deleted file mode 100644 index 43e5a3d1e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/StaticLayout.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.graphics.Canvas -import android.text.StaticLayout -import androidx.core.graphics.withTranslation - -fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) { - canvas.withTranslation(x, y) { - draw(this) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt deleted file mode 100644 index 5fe420064..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.annotation.FloatRange -import org.koitharu.kotatsu.parsers.util.levenshteinDistance -import java.util.UUID - -inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { - return if (this.isNullOrEmpty()) defaultValue() else this -} - -fun String.longHashCode(): Long { - var h = 1125899906842597L - val len: Int = this.length - for (i in 0 until len) { - h = 31 * h + this[i].code - } - return h -} - -fun String.toUUIDOrNull(): UUID? = try { - UUID.fromString(this) -} catch (e: IllegalArgumentException) { - e.printStackTraceDebug() - null -} - -/** - * @param threshold 0 = exact match - */ -fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean { - if (threshold == 0f) { - return equals(other, ignoreCase = true) - } - val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f) - return diff < threshold -} - -fun CharSequence.sanitize(): CharSequence { - return filterNot { c -> c.isReplacement() } -} - -fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/TextView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/TextView.kt deleted file mode 100644 index 038619b66..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/TextView.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.view.View -import android.widget.TextView -import androidx.annotation.AttrRes -import androidx.annotation.StringRes -import androidx.annotation.StyleRes -import androidx.core.content.res.use -import androidx.core.view.isGone -import androidx.core.widget.TextViewCompat - - -var TextView.textAndVisible: CharSequence? - get() = text?.takeIf { visibility == View.VISIBLE } - set(value) { - text = value - isGone = value.isNullOrEmpty() - } - -var TextView.drawableStart: Drawable? - inline get() = compoundDrawablesRelative[0] - set(value) { - val dr = compoundDrawablesRelative - setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3]) - } - -var TextView.drawableEnd: Drawable? - inline get() = compoundDrawablesRelative[2] - set(value) { - val dr = compoundDrawablesRelative - setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], dr[1], value, dr[3]) - } - -var TextView.drawableTop: Drawable? - inline get() = compoundDrawablesRelative[1] - set(value) { - val dr = compoundDrawablesRelative - setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], value, dr[2], dr[3]) - } - -fun TextView.setTextAndVisible(@StringRes textResId: Int) { - if (textResId == 0) { - text = null - isGone = true - } else { - setText(textResId) - isGone = text.isNullOrEmpty() - } -} - -fun TextView.setTextColorAttr(@AttrRes attrResId: Int) { - setTextColor(context.getThemeColorStateList(attrResId)) -} - -var TextView.isBold: Boolean - get() = typeface.isBold - set(value) { - var style = typeface.style - style = if (value) { - style or Typeface.BOLD - } else { - style and Typeface.BOLD.inv() - } - setTypeface(typeface, style) - } - -fun TextView.setThemeTextAppearance(@AttrRes resId: Int, @StyleRes fallback: Int) { - context.obtainStyledAttributes(intArrayOf(resId)).use { - TextViewCompat.setTextAppearance(this, it.getResourceId(0, fallback)) - } -} - -val TextView.isTextTruncated: Boolean - get() { - val l = layout ?: return false - if (maxLines in 0 until l.lineCount) { - return true - } - val layoutLines = l.lineCount - return layoutLines > 0 && l.getEllipsisCount(layoutLines - 1) > 0 - } 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 deleted file mode 100644 index e5b713d8f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.content.Context -import android.content.res.TypedArray -import android.graphics.Color -import android.graphics.drawable.Drawable -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt -import androidx.annotation.FloatRange -import androidx.annotation.Px -import androidx.core.content.ContextCompat -import androidx.core.content.res.use -import androidx.core.graphics.ColorUtils - -fun Context.getThemeDrawable( - @AttrRes resId: Int, -) = obtainStyledAttributes(intArrayOf(resId)).use { - it.getDrawable(0) -} - -@ColorInt -fun Context.getThemeColor( - @AttrRes resId: Int, - @ColorInt fallback: Int = Color.TRANSPARENT, -) = obtainStyledAttributes(intArrayOf(resId)).use { - it.getColor(0, fallback) -} - -@Px -fun Context.getThemeDimensionPixelSize( - @AttrRes resId: Int, - @ColorInt fallback: Int = 0, -) = obtainStyledAttributes(intArrayOf(resId)).use { - it.getDimensionPixelSize(0, fallback) -} - -@Px -fun Context.getThemeDimensionPixelOffset( - @AttrRes resId: Int, - @ColorInt fallback: Int = 0, -) = obtainStyledAttributes(intArrayOf(resId)).use { - it.getDimensionPixelOffset(0, fallback) -} - -@ColorInt -fun Context.getThemeColor( - @AttrRes resId: Int, - @FloatRange(from = 0.0, to = 1.0) alphaFactor: Float, - @ColorInt fallback: Int = Color.TRANSPARENT, -): Int { - if (alphaFactor <= 0f) { - return Color.TRANSPARENT - } - val color = getThemeColor(resId, fallback) - if (alphaFactor >= 1f) { - return color - } - return ColorUtils.setAlphaComponent(color, (0xFF * alphaFactor).toInt()) -} - -fun Context.getThemeColorStateList( - @AttrRes resId: Int, -) = obtainStyledAttributes(intArrayOf(resId)).use { - it.getColorStateList(0) -} - -fun Context.getThemeResId( - @AttrRes resId: Int, - fallback: Int -): Int = obtainStyledAttributes(intArrayOf(resId)).use { - it.getResourceId(0, fallback) -} - -fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? { - val resId = getResourceId(index, 0) - return if (resId != 0) ContextCompat.getDrawable(context, resId) else null -} 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 deleted file mode 100644 index 21e6e838e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ /dev/null @@ -1,123 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.content.ActivityNotFoundException -import android.content.res.Resources -import android.util.AndroidRuntimeException -import androidx.annotation.DrawableRes -import androidx.collection.arraySetOf -import coil.network.HttpException -import okio.FileNotFoundException -import okio.IOException -import org.acra.ktx.sendWithAcra -import org.json.JSONException -import org.jsoup.HttpStatusException -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.CaughtException -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException -import org.koitharu.kotatsu.core.exceptions.SyncApiException -import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions -import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException -import org.koitharu.kotatsu.core.exceptions.WrongPasswordException -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 -import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED -import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED -import org.koitharu.kotatsu.parsers.exception.AuthRequiredException -import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException -import org.koitharu.kotatsu.parsers.exception.NotFoundException -import org.koitharu.kotatsu.parsers.exception.ParseException -import java.net.SocketTimeoutException -import java.net.UnknownHostException - -private const val MSG_NO_SPACE_LEFT = "No space left on device" -private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" - -fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { - is AuthRequiredException -> resources.getString(R.string.auth_required) - is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) - is ActivityNotFoundException, - is UnsupportedOperationException, - -> resources.getString(R.string.operation_not_supported) - - is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message) - is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) - is FileNotFoundException -> resources.getString(R.string.file_not_found) - is AccessDeniedException -> resources.getString(R.string.no_access_to_file) - is EmptyHistoryException -> resources.getString(R.string.history_is_empty) - is SyncApiException, - is ContentUnavailableException, - -> message - - is ParseException -> shortMessage - is UnknownHostException, - is SocketTimeoutException, - -> resources.getString(R.string.network_error) - - is WrongPasswordException -> resources.getString(R.string.wrong_password) - is NotFoundException -> resources.getString(R.string.not_found_404) - - is HttpException -> getHttpDisplayMessage(response.code, resources) - is HttpStatusException -> getHttpDisplayMessage(statusCode, resources) - - else -> getDisplayMessage(message, resources) ?: localizedMessage -}.ifNullOrEmpty { - resources.getString(R.string.error_occurred) -} - -@DrawableRes -fun Throwable.getDisplayIcon() = when (this) { - is AuthRequiredException -> R.drawable.ic_auth_key_large - is CloudFlareProtectedException -> R.drawable.ic_bot_large - is UnknownHostException, - is SocketTimeoutException, - -> R.drawable.ic_plug_large - - else -> R.drawable.ic_error_large -} - -private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) { - 404 -> resources.getString(R.string.not_found_404) - in 500..599 -> resources.getString(R.string.server_error, statusCode) - else -> null -} - -private fun getDisplayMessage(msg: String?, resources: Resources): String? = when { - msg.isNullOrEmpty() -> null - msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) - msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) - msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) - msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) - msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) - msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported) - msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported) - else -> null -} - -fun Throwable.isReportable(): Boolean { - return this is Error || this.javaClass in reportableExceptions -} - -fun Throwable.report() { - val exception = CaughtException(this, "${javaClass.simpleName}($message)") - exception.sendWithAcra() -} - -private val reportableExceptions = arraySetOf>( - ParseException::class.java, - JSONException::class.java, - RuntimeException::class.java, - IllegalStateException::class.java, - IllegalArgumentException::class.java, - ConcurrentModificationException::class.java, - UnsupportedOperationException::class.java, -) - -fun Throwable.isWebViewUnavailable(): Boolean { - return (this is AndroidRuntimeException && message?.contains("WebView") == true) || - cause?.isWebViewUnavailable() == true -} - -@Suppress("FunctionName") -fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Toolbar.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Toolbar.kt deleted file mode 100644 index 05ad9c7f1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Toolbar.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import androidx.annotation.DrawableRes -import androidx.appcompat.widget.Toolbar - -fun Toolbar.setNavigationIconSafe(@DrawableRes iconRes: Int, retry: Boolean = true) { - try { - setNavigationIcon(iconRes) - } catch (e: IllegalStateException) { - if (retry) { - post { setNavigationIconSafe(iconRes, retry = false) } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt deleted file mode 100644 index f6db7443e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.net.Uri -import androidx.core.net.toFile -import okio.Source -import okio.source -import okio.use -import org.koitharu.kotatsu.local.data.util.withExtraCloseable -import java.io.File -import java.util.zip.ZipFile - -const val URI_SCHEME_FILE = "file" -const val URI_SCHEME_ZIP = "file+zip" - -fun Uri.exists(): Boolean = when (scheme) { - URI_SCHEME_FILE -> toFile().exists() - URI_SCHEME_ZIP -> { - val file = File(requireNotNull(schemeSpecificPart)) - file.exists() && ZipFile(file).use { it.getEntry(fragment) != null } - } - - else -> unsupportedUri(this) -} - -fun Uri.isTargetNotEmpty(): Boolean = when (scheme) { - URI_SCHEME_FILE -> toFile().isNotEmpty() - URI_SCHEME_ZIP -> { - val file = File(requireNotNull(schemeSpecificPart)) - file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L } - } - - else -> unsupportedUri(this) -} - -fun Uri.source(): Source = when (scheme) { - URI_SCHEME_FILE -> toFile().source() - URI_SCHEME_ZIP -> { - val zip = ZipFile(schemeSpecificPart) - val entry = zip.getEntry(fragment) - zip.getInputStream(entry).source().withExtraCloseable(zip) - } - - else -> unsupportedUri(this) -} - -fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName") - -private fun unsupportedUri(uri: Uri): Nothing { - throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported") -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt deleted file mode 100644 index 793e2d102..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.app.Activity -import android.graphics.Rect -import android.os.Build -import android.view.View -import android.view.View.MeasureSpec -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.Checkable -import androidx.appcompat.widget.ActionMenuView -import androidx.appcompat.widget.Toolbar -import androidx.core.view.children -import androidx.core.view.descendants -import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.progressindicator.BaseProgressIndicator -import com.google.android.material.slider.Slider -import com.google.android.material.tabs.TabLayout -import kotlin.math.roundToInt - -fun View.hideKeyboard() { - val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(this.windowToken, 0) -} - -fun View.showKeyboard() { - val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(this, 0) -} - -fun View.hasGlobalPoint(x: Int, y: Int): Boolean { - if (visibility != View.VISIBLE) { - return false - } - val rect = Rect() - getGlobalVisibleRect(rect) - return rect.contains(x, y) -} - -fun View.measureHeight(): Int { - val vh = height - return if (vh == 0) { - measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) - measuredHeight - } else vh -} - -fun View.measureWidth(): Int { - val vw = width - return if (vw == 0) { - measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) - measuredWidth - } else vw -} - -inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) { - registerOnPageChangeCallback( - object : ViewPager2.OnPageChangeCallback() { - - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - callback(position) - } - }, - ) -} - -val ViewPager2.recyclerView: RecyclerView? - get() = children.firstNotNullOfOrNull { it as? RecyclerView } - -fun ViewPager2.findCurrentViewHolder(): ViewHolder? { - return recyclerView?.findViewHolderForAdapterPosition(currentItem) -} - -fun View.resetTransformations() { - alpha = 1f - translationX = 0f - translationY = 0f - translationZ = 0f - scaleX = 1f - scaleY = 1f - rotation = 0f - rotationX = 0f - rotationY = 0f -} - -fun Slider.setValueRounded(newValue: Float) { - val step = stepSize - val roundedValue = if (step <= 0f) { - newValue - } else { - (newValue / step).roundToInt() * step - } - value = roundedValue.coerceIn(valueFrom, valueTo) -} - -fun RecyclerView.invalidateNestedItemDecorations() { - descendants.filterIsInstance().forEach { - it.invalidateItemDecorations() - } -} - -val View.parentView: ViewGroup? - get() = parent as? ViewGroup - -@Suppress("UnusedReceiverParameter") -fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { - var result: Int - val specMode = MeasureSpec.getMode(measureSpec) - val specSize = MeasureSpec.getSize(measureSpec) - if (specMode == MeasureSpec.EXACTLY) { - result = specSize - } else { - result = desiredSize - if (specMode == MeasureSpec.AT_MOST) { - result = result.coerceAtMost(specSize) - } - } - return result -} - -fun V.setChecked(checked: Boolean, animate: Boolean) where V : View, V : Checkable { - val skipAnimation = !animate && checked != isChecked - isChecked = checked - if (skipAnimation) { - jumpDrawablesToCurrentState() - } -} - -var View.isRtl: Boolean - get() = layoutDirection == View.LAYOUT_DIRECTION_RTL - set(value) { - layoutDirection = if (value) View.LAYOUT_DIRECTION_RTL else View.LAYOUT_DIRECTION_LTR - } - -fun TabLayout.setTabsEnabled(enabled: Boolean) { - for (i in 0 until tabCount) { - getTabAt(i)?.view?.isEnabled = enabled - } -} - -fun BaseProgressIndicator<*>.showOrHide(value: Boolean) { - if (value) { - if (!isVisible) show() - } else { - if (isVisible) hide() - } -} - -fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnContextClickListener(listener::onLongClick) - } -} - -val Toolbar.menuView: ActionMenuView? - get() { - menu // to call ensureMenu() - return children.firstNotNullOfOrNull { it as? ActionMenuView } - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt deleted file mode 100644 index 8fe5f8f47..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.annotation.SuppressLint -import androidx.annotation.MainThread -import androidx.fragment.app.Fragment -import androidx.fragment.app.createViewModelLazy -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.viewmodel.CreationExtras - -@MainThread -inline fun Fragment.parentFragmentViewModels( - noinline extrasProducer: (() -> CreationExtras)? = null, - noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null, -): Lazy = createViewModelLazy( - viewModelClass = VM::class, - storeProducer = { requireParentFragment().viewModelStore }, - extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, - factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, -) - -val ViewModelStore.values: Collection - @SuppressLint("RestrictedApi") - get() = this.keys().mapNotNull { get(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt deleted file mode 100644 index a879f2f39..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/WorkManager.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.core.util.ext - -import android.annotation.SuppressLint -import androidx.work.Data -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkQuery -import androidx.work.WorkRequest -import androidx.work.await -import androidx.work.impl.WorkManagerImpl -import androidx.work.impl.model.WorkSpec -import java.util.UUID -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.deleteWork(id: UUID) = suspendCoroutine { cont -> - workManagerImpl.workTaskExecutor.executeOnTaskThread { - try { - workManagerImpl.workDatabase.workSpecDao().delete(id.toString()) - cont.resume(Unit) - } catch (e: Exception) { - cont.resumeWithException(e) - } - } -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.deleteWorks(ids: Collection) = suspendCoroutine { cont -> - workManagerImpl.workTaskExecutor.executeOnTaskThread { - try { - val db = workManagerImpl.workDatabase - db.runInTransaction { - for (id in ids) { - db.workSpecDao().delete(id.toString()) - } - } - cont.resume(Unit) - } catch (e: Exception) { - cont.resumeWithException(e) - } - } -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.awaitWorkInfosByTag(tag: String): List { - return getWorkInfosByTag(tag).await() -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.awaitFinishedWorkInfosByTag(tag: String): List { - val query = WorkQuery.Builder.fromTags(listOf(tag)) - .addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED)) - .build() - return getWorkInfos(query).await() -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.awaitWorkInfoById(id: UUID): WorkInfo? { - return getWorkInfoById(id).await() -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List { - return getWorkInfosForUniqueWork(name).await().orEmpty() -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.UpdateResult { - return updateWork(request).await() -} - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont -> - workManagerImpl.workTaskExecutor.executeOnTaskThread { - try { - val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString()) - cont.resume(spec) - } catch (e: Exception) { - cont.resumeWithException(e) - } - } -} - - -@SuppressLint("RestrictedApi") -suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input - -val Data.isEmpty: Boolean - get() = this == Data.EMPTY - -private val WorkManager.workManagerImpl - @SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt deleted file mode 100644 index da59d5efc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/CloseableIterator.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.core.util.iterator - -import okhttp3.internal.closeQuietly -import okio.Closeable - -class CloseableIterator( - private val upstream: Iterator, - private val closeable: Closeable, -) : Iterator, Closeable { - - private var isClosed = false - - override fun hasNext(): Boolean { - val result = upstream.hasNext() - if (!result) { - close() - } - return result - } - - override fun next(): T { - try { - return upstream.next() - } catch (e: NoSuchElementException) { - close() - throw e - } - } - - override fun close() { - if (!isClosed) { - closeable.closeQuietly() - isClosed = true - } - } -} 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 deleted file mode 100644 index 8ac9f9d41..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.core.util.iterator - -import org.koitharu.kotatsu.R - -class MappingIterator( - private val upstream: Iterator, - private val mapper: (T) -> R, -) : Iterator { - - override fun hasNext(): Boolean = upstream.hasNext() - - override fun next(): R = mapper(upstream.next()) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt deleted file mode 100644 index b66e5cd2a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressResponseBody.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.util.progress - -import kotlinx.coroutines.flow.MutableStateFlow -import okhttp3.MediaType -import okhttp3.ResponseBody -import okio.Buffer -import okio.BufferedSource -import okio.ForwardingSource -import okio.Source -import okio.buffer - -class ProgressResponseBody( - private val delegate: ResponseBody, - private val progressState: MutableStateFlow, -) : ResponseBody() { - - private var bufferedSource: BufferedSource? = null - - override fun close() { - super.close() - delegate.close() - } - - override fun contentLength(): Long = delegate.contentLength() - - override fun contentType(): MediaType? = delegate.contentType() - - override fun source(): BufferedSource { - return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { - bufferedSource = it - } - } - - private class ProgressSource( - delegate: Source, - private val contentLength: Long, - private val progressState: MutableStateFlow, - ) : ForwardingSource(delegate) { - - private var totalBytesRead = 0L - - override fun read(sink: Buffer, byteCount: Long): Long { - val bytesRead = super.read(sink, byteCount) - if (contentLength > 0) { - totalBytesRead += if (bytesRead != -1L) bytesRead else 0 - progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat() - } - return bytesRead - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt deleted file mode 100644 index 30c814b96..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.koitharu.kotatsu.details.data - -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.reader.data.filterChapters - -data class MangaDetails( - private val manga: Manga, - private val localManga: LocalManga?, - val description: CharSequence?, - val isLoaded: Boolean, -) { - - val id: Long - get() = manga.id - - val chapters: Map> = manga.chapters?.groupBy { it.branch }.orEmpty() - - val branches: Set - get() = chapters.keys - - val allChapters: List by lazy { mergeChapters() } - - val isLocal - get() = manga.isLocal - - val local: LocalManga? - get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null - - fun toManga() = manga - - fun filterChapters(branch: String?) = MangaDetails( - manga = manga.filterChapters(branch), - localManga = localManga?.run { - copy(manga = manga.filterChapters(branch)) - }, - description = description, - isLoaded = isLoaded, - ) - - private fun mergeChapters(): List { - val chapters = manga.chapters - val localChapters = local?.manga?.chapters.orEmpty() - if (chapters.isNullOrEmpty()) { - return localChapters - } - val localMap = if (localChapters.isNotEmpty()) { - localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } - } else { - null - } - val result = ArrayList(chapters.size) - for (chapter in chapters) { - val local = localMap?.remove(chapter.id) - result += local ?: chapter - } - if (!localMap.isNullOrEmpty()) { - result.addAll(localMap.values) - } - return result - } -} 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 deleted file mode 100644 index 4d93a464c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.details.domain - -import org.koitharu.kotatsu.details.ui.model.MangaBranch - -class BranchComparator : Comparator { - - override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt deleted file mode 100644 index 76df746e0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.koitharu.kotatsu.details.domain - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -/* TODO: remove */ -class DetailsInteractor @Inject constructor( - private val historyRepository: HistoryRepository, - private val favouritesRepository: FavouritesRepository, - private val localMangaRepository: LocalMangaRepository, - private val trackingRepository: TrackingRepository, - private val settings: AppSettings, - private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, -) { - - fun observeIsFavourite(mangaId: Long): Flow { - return favouritesRepository.observeCategoriesIds(mangaId) - .map { it.isNotEmpty() } - } - - fun observeNewChapters(mangaId: Long): Flow { - return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } - .flatMapLatest { isEnabled -> - if (isEnabled) { - trackingRepository.observeNewChaptersCount(mangaId) - } else { - flowOf(0) - } - } - } - - fun observeScrobblingInfo(mangaId: Long): Flow> { - return combine( - scrobblers.map { it.observeScrobblingInfo(mangaId) }, - ) { scrobblingInfo -> - scrobblingInfo.filterNotNull() - } - } - - fun observeIncognitoMode(mangaFlow: Flow): Flow { - return mangaFlow - .distinctUntilChangedBy { it?.isNsfw } - .flatMapLatest { manga -> - if (manga != null) { - historyRepository.observeShouldSkip(manga) - } else { - settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled } - } - } - } - - suspend fun updateLocal(subject: MangaDetails?, localManga: LocalManga): MangaDetails? { - subject ?: return null - return if (subject.id == localManga.manga.id) { - if (subject.isLocal) { - subject.copy( - manga = localManga.manga, - ) - } else { - subject.copy( - localManga = runCatchingCancellable { - localManga.copy( - manga = localMangaRepository.getDetails(localManga.manga), - ) - }.getOrNull() ?: subject.local, - ) - } - } else { - subject - } - } - - suspend fun findRemote(seed: Manga) = localMangaRepository.getRemoteManga(seed) -} 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 deleted file mode 100644 index 70d910265..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.koitharu.kotatsu.details.domain - -import android.text.Html -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ForegroundColorSpan -import androidx.core.text.getSpans -import androidx.core.text.parseAsHtml -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.runInterruptible -import okio.IOException -import org.koitharu.kotatsu.core.model.isLocal -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.sanitize -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.exception.NotFoundException -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.recoverNotNull -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import javax.inject.Inject - -class DetailsLoadUseCase @Inject constructor( - private val mangaDataRepository: MangaDataRepository, - private val localMangaRepository: LocalMangaRepository, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val recoverUseCase: RecoverMangaUseCase, - private val imageGetter: Html.ImageGetter, -) { - - operator fun invoke(intent: MangaIntent): Flow = channelFlow { - val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) { - "Cannot resolve intent $intent" - } - val local = if (!manga.isLocal) { - async { - localMangaRepository.findSavedManga(manga) - } - } else { - null - } - send(MangaDetails(manga, null, null, false)) - try { - val details = getDetails(manga) - send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false)) - send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true)) - } catch (e: IOException) { - local?.await()?.manga?.also { localManga -> - send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true)) - } ?: throw e - } - } - - private suspend fun getDetails(seed: Manga) = runCatchingCancellable { - val repository = mangaRepositoryFactory.create(seed.source) - repository.getDetails(seed) - }.recoverNotNull { e -> - if (e is NotFoundException) { - recoverUseCase(seed) - } else { - null - } - }.getOrThrow() - - private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? { - return if (withImages) { - runInterruptible(Dispatchers.IO) { - parseAsHtml(imageGetter = imageGetter) - }.filterSpans() - } else { - runInterruptible(Dispatchers.Default) { - parseAsHtml() - }.filterSpans().sanitize() - }.takeUnless { it.isBlank() } - } - - private fun Spanned.filterSpans(): Spanned { - val spannable = SpannableString.valueOf(this) - val spans = spannable.getSpans() - for (span in spans) { - spannable.removeSpan(span) - } - return spannable - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ProgressUpdateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ProgressUpdateUseCase.kt deleted file mode 100644 index bdec62aae..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ProgressUpdateUseCase.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.koitharu.kotatsu.details.domain - -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.model.findChapter -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -class ProgressUpdateUseCase @Inject constructor( - private val mangaRepositoryFactory: MangaRepository.Factory, - private val database: MangaDatabase, - private val localMangaRepository: LocalMangaRepository, - private val networkState: NetworkState, -) { - - suspend operator fun invoke(manga: Manga): Float { - val history = database.getHistoryDao().find(manga.id) ?: return PROGRESS_NONE - val seed = if (manga.isLocal) { - localMangaRepository.getRemoteManga(manga) ?: manga - } else { - manga - } - if (!seed.isLocal && !networkState.value) { - return PROGRESS_NONE - } - val repo = mangaRepositoryFactory.create(seed.source) - val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) { - repo.getDetails(seed) - } else { - seed - } - val chapter = details.findChapter(history.chapterId) ?: return PROGRESS_NONE - val chapters = details.getChapters(chapter.branch) ?: return PROGRESS_NONE - val chaptersCount = chapters.size - if (chaptersCount == 0) { - return PROGRESS_NONE - } - val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId } - val pagesCount = repo.getPages(chapter).size - if (pagesCount == 0) { - return PROGRESS_NONE - } - val pagePercent = (history.page + 1) / pagesCount.toFloat() - val ppc = 1f / chaptersCount - val result = ppc * chapterIndex + ppc * pagePercent - if (result != history.percent) { - database.getHistoryDao().update( - history.copy( - chapterId = chapter.id, - percent = result, - ), - ) - } - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/RelatedMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/RelatedMangaUseCase.kt deleted file mode 100644 index 15670f6ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/RelatedMangaUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.details.domain - -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import javax.inject.Inject - -class RelatedMangaUseCase @Inject constructor( - private val mangaRepositoryFactory: MangaRepository.Factory, -) { - - suspend operator fun invoke(seed: Manga) = runCatchingCancellable { - mangaRepositoryFactory.create(seed.source).getRelated(seed) - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt deleted file mode 100644 index 385c7e416..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.koitharu.kotatsu.details.service - -import android.content.Context -import android.content.Intent -import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.EntryPointAccessors -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.model.findById -import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.CoroutineIntentService -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.history.data.HistoryRepository -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.util.runCatchingCancellable -import javax.inject.Inject - -@AndroidEntryPoint -class MangaPrefetchService : CoroutineIntentService() { - - @Inject - lateinit var mangaRepositoryFactory: MangaRepository.Factory - - @Inject - lateinit var cache: ContentCache - - @Inject - lateinit var historyRepository: HistoryRepository - - override suspend fun processIntent(startId: Int, intent: Intent) { - when (intent.action) { - ACTION_PREFETCH_DETAILS -> prefetchDetails( - manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga - ?: return, - ) - - ACTION_PREFETCH_PAGES -> prefetchPages( - chapter = intent.getParcelableExtraCompat(EXTRA_CHAPTER)?.chapter - ?: return, - ) - - ACTION_PREFETCH_LAST -> prefetchLast() - } - } - - override fun onError(startId: Int, error: Throwable) = Unit - - private suspend fun prefetchDetails(manga: Manga) { - val source = mangaRepositoryFactory.create(manga.source) - runCatchingCancellable { source.getDetails(manga) } - } - - private suspend fun prefetchPages(chapter: MangaChapter) { - val source = mangaRepositoryFactory.create(chapter.source) - runCatchingCancellable { source.getPages(chapter) } - } - - private suspend fun prefetchLast() { - val last = historyRepository.getLastOrNull() ?: return - if (last.source == MangaSource.LOCAL) return - val repo = mangaRepositoryFactory.create(last.source) - val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return - val chapters = details.chapters - if (chapters.isNullOrEmpty()) { - return - } - val history = historyRepository.getOne(last) - val chapter = if (history == null) { - chapters.firstOrNull() - } else { - chapters.findById(history.chapterId) ?: chapters.firstOrNull() - } ?: return - runCatchingCancellable { repo.getPages(chapter) } - } - - companion object { - - private const val EXTRA_MANGA = "manga" - private const val EXTRA_CHAPTER = "manga" - private const val ACTION_PREFETCH_DETAILS = "details" - private const val ACTION_PREFETCH_PAGES = "pages" - private const val ACTION_PREFETCH_LAST = "last" - - fun prefetchDetails(context: Context, manga: Manga) { - if (!isPrefetchAvailable(context, manga.source)) return - val intent = Intent(context, MangaPrefetchService::class.java) - intent.action = ACTION_PREFETCH_DETAILS - intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) - tryStart(context, intent) - } - - fun prefetchPages(context: Context, chapter: MangaChapter) { - if (!isPrefetchAvailable(context, chapter.source)) return - val intent = Intent(context, MangaPrefetchService::class.java) - intent.action = ACTION_PREFETCH_PAGES - intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) - tryStart(context, intent) - } - - fun prefetchLast(context: Context) { - if (!isPrefetchAvailable(context, null)) return - val intent = Intent(context, MangaPrefetchService::class.java) - intent.action = ACTION_PREFETCH_LAST - tryStart(context, intent) - } - - private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { - if (source == MangaSource.LOCAL) { - return false - } - if (context.isPowerSaveMode()) { - return false - } - val entryPoint = EntryPointAccessors.fromApplication( - context, - PrefetchCompanionEntryPoint::class.java, - ) - return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled - } - - private fun tryStart(context: Context, intent: Intent) { - try { - context.startService(intent) - } catch (e: IllegalStateException) { - // probably app is in background - e.printStackTraceDebug() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/PrefetchCompanionEntryPoint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/PrefetchCompanionEntryPoint.kt deleted file mode 100644 index 57afeb770..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/PrefetchCompanionEntryPoint.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.details.service - -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.koitharu.kotatsu.core.cache.ContentCache -import org.koitharu.kotatsu.core.prefs.AppSettings - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface PrefetchCompanionEntryPoint { - val settings: AppSettings - val contentCache: ContentCache -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt deleted file mode 100644 index cc6a94502..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ButtonTip.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.transition.TransitionManager -import android.view.Gravity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.graphics.Insets -import androidx.core.view.setMargins -import androidx.core.view.updateLayoutParams -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate -import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.databinding.ItemTipBinding -import com.google.android.material.R as materialR - -class ButtonTip( - private val root: ViewGroup, - private val insetsDelegate: WindowInsetsDelegate, - private val viewModel: DetailsViewModel, -) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener { - - private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false) - private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize) - - init { - selfBinding.textView.setText(R.string.details_button_tip) - selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap) - selfBinding.root.id = R.id.layout_tip - selfBinding.buttonClose.setOnClickListener(this) - } - - override fun onClick(v: View?) { - remove() - } - - override fun onWindowInsetsChanged(insets: Insets) { - if (root is CoordinatorLayout) { - selfBinding.root.updateLayoutParams { - bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize - } - } - } - - fun addToRoot() { - val lp: ViewGroup.LayoutParams = when (root) { - is CoordinatorLayout -> CoordinatorLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - // anchorId = R.id.layout_bottom - // anchorGravity = Gravity.TOP - gravity = Gravity.BOTTOM - setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal)) - bottomMargin += actionBarSize - } - - is ConstraintLayout -> ConstraintLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width) - setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal)) - } - - else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - } - root.addView(selfBinding.root, lp) - if (root is ConstraintLayout) { - val cs = ConstraintSet() - cs.clone(root) - cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM) - cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START) - cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END) - cs.applyTo(root) - } - insetsDelegate.addInsetsListener(this) - } - - fun remove() { - if (root.context.isAnimationsEnabled) { - TransitionManager.beginDelayedTransition(root) - } - insetsDelegate.removeInsetsListener(this) - root.removeView(selfBinding.root) - viewModel.onButtonTipClosed() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt deleted file mode 100644 index 83f6a38b1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersBottomSheetMediator.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.view.InputDevice -import android.view.MotionEvent -import android.view.View -import android.view.View.OnLayoutChangeListener -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.view.ActionMode -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.bottomsheet.BottomSheetBehavior -import org.koitharu.kotatsu.core.ui.util.ActionModeListener -import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged - -class ChaptersBottomSheetMediator( - private val behavior: BottomSheetBehavior<*>, - private val pager: ViewPager2, -) : OnBackPressedCallback(false), - ActionModeListener, - OnLayoutChangeListener, View.OnGenericMotionListener { - - private var lockCounter = 0 - - init { - behavior.doOnExpansionsChanged { isExpanded -> - isEnabled = isExpanded - if (!isExpanded) { - unlock() - } - } - } - - override fun handleOnBackPressed() { - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - - override fun onActionModeStarted(mode: ActionMode) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - lock() - } - - override fun onActionModeFinished(mode: ActionMode) { - unlock() - } - - override fun onLayoutChange( - v: View?, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - val height = bottom - top - if (height != behavior.peekHeight) { - behavior.peekHeight = height - } - } - - override fun onGenericMotion(v: View?, event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - if (event.actionMasked == MotionEvent.ACTION_SCROLL) { - if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) { - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } else { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - return true - } - } - return false - } - - fun lock() { - lockCounter++ - behavior.isDraggable = lockCounter <= 0 - pager.isUserInputEnabled = lockCounter <= 0 - } - - fun unlock() { - lockCounter-- - if (lockCounter < 0) { - lockCounter = 0 - } - behavior.isDraggable = lockCounter <= 0 - pager.isUserInputEnabled = lockCounter <= 0 - } -} 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 deleted file mode 100644 index f525f7c8e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.details.ui.model.toListItem -import org.koitharu.kotatsu.parsers.util.mapToSet - -fun MangaDetails.mapChapters( - history: MangaHistory?, - newCount: Int, - branch: String?, - bookmarks: List, -): List { - val remoteChapters = chapters[branch].orEmpty() - val localChapters = local?.manga?.getChapters(branch).orEmpty() - if (remoteChapters.isEmpty() && localChapters.isEmpty()) { - return emptyList() - } - val bookmarked = bookmarks.mapToSet { it.chapterId } - val currentId = history?.chapterId ?: 0L - val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount - val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) { - remoteChapters.mapTo(this) { it.id } - localChapters.mapTo(this) { it.id } - } - val result = ArrayList(ids.size) - val localMap = if (localChapters.isNotEmpty()) { - localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } - } else { - null - } - var isUnread = currentId !in ids - for (chapter in remoteChapters) { - val local = localMap?.remove(chapter.id) - if (chapter.id == currentId) { - isUnread = true - } - result += (local ?: chapter).toListItem( - isCurrent = chapter.id == currentId, - isUnread = isUnread, - isNew = isUnread && result.size >= newFrom, - isDownloaded = local != null, - isBookmarked = chapter.id in bookmarked, - ) - } - if (!localMap.isNullOrEmpty()) { - for (chapter in localMap.values) { - if (chapter.id == currentId) { - isUnread = true - } - result += chapter.toListItem( - isCurrent = chapter.id == currentId, - isUnread = isUnread, - isNew = false, - isDownloaded = !isLocal, - isBookmarked = chapter.id in bookmarked, - ) - } - } - return result -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt deleted file mode 100644 index e42fa2ca8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMenuProvider.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.SearchView -import androidx.core.view.MenuProvider -import org.koitharu.kotatsu.R -import java.lang.ref.WeakReference - -class ChaptersMenuProvider( - private val viewModel: DetailsViewModel, - private val bottomSheetMediator: ChaptersBottomSheetMediator?, -) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener { - - private var searchItemRef: WeakReference? = null - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_chapters, menu) - val searchMenuItem = menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title - searchItemRef = WeakReference(searchMenuItem) - } - - override fun onPrepareMenu(menu: Menu) { - menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true - menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_reversed -> { - viewModel.setChaptersReversed(!menuItem.isChecked) - true - } - - else -> false - } - - override fun handleOnBackPressed() { - searchItemRef?.get()?.collapseActionView() - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - bottomSheetMediator?.lock() - isEnabled = true - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - isEnabled = false - (item.actionView as? SearchView)?.setQuery("", false) - viewModel.performChapterSearch(null) - bottomSheetMediator?.unlock() - return true - } - - override fun onQueryTextSubmit(query: String?): Boolean = false - - override fun onQueryTextChange(newText: String?): Boolean { - viewModel.performChapterSearch(newText) - return true - } -} 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 deleted file mode 100644 index afdbcd194..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ /dev/null @@ -1,421 +0,0 @@ -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.ForegroundColorSpan -import android.text.style.RelativeSizeSpan -import android.transition.AutoTransition -import android.transition.Slide -import android.transition.TransitionManager -import android.view.Gravity -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.view.animation.AccelerateDecelerateInterpolator -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.view.MenuHost -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayoutMediator -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.filterNotNull -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.util.MenuInvalidator -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.measureHeight -import org.koitharu.kotatsu.core.util.ext.menuView -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.recyclerView -import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat -import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe -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.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.DetailsPagerAdapter -import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet -import java.lang.ref.WeakReference -import javax.inject.Inject -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class DetailsActivity : - BaseActivity(), - View.OnClickListener, - NoModalBottomSheetOwner, - View.OnLongClickListener, - PopupMenu.OnMenuItemClickListener { - - @Inject - lateinit var appShortcutManager: AppShortcutManager - - @Inject - lateinit var settings: AppSettings - - private var buttonTip: WeakReference? = null - - private val viewModel: DetailsViewModel by viewModels() - - val secondaryMenuHost: MenuHost - get() = viewBinding.toolbarChapters ?: this - - var bottomSheetMediator: ChaptersBottomSheetMediator? = null - private set - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityDetailsBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setDisplayShowTitleEnabled(false) - } - viewBinding.buttonRead.setOnClickListener(this) - viewBinding.buttonRead.setOnLongClickListener(this) - viewBinding.buttonRead.setOnContextClickListenerCompat(this) - viewBinding.buttonDropdown.setOnClickListener(this) - - if (viewBinding.layoutBottom != null) { - val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom)) - val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager) - actionModeDelegate.addListener(bsMediator) - checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator) - onBackPressedDispatcher.addCallback(bsMediator) - bottomSheetMediator = bsMediator - behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged) - viewBinding.toolbarChapters?.setNavigationOnClickListener { - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } - viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator) - } - initPager() - - viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated) - viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) - viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) - viewModel.onError.observeEvent( - this, - SnackbarErrorObserver( - host = viewBinding.containerDetails, - fragment = null, - resolver = exceptionResolver, - onResolved = { isResolved -> - if (isResolved) { - viewModel.reload() - } - }, - ), - ) - viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails)) - viewModel.onShowTip.observeEvent(this) { showTip() } - viewModel.historyInfo.observe(this, ::onHistoryChanged) - viewModel.selectedBranch.observe(this) { - viewBinding.toolbarChapters?.subtitle = it - viewBinding.textViewSubtitle?.textAndVisible = it - } - val chaptersMenuInvalidator = MenuInvalidator(viewBinding.toolbarChapters ?: this) - viewModel.isChaptersReversed.observe(this, chaptersMenuInvalidator) - viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator) - val menuInvalidator = MenuInvalidator(this) - viewModel.favouriteCategories.observe(this, menuInvalidator) - viewModel.remoteManga.observe(this, menuInvalidator) - viewModel.branches.observe(this) { - viewBinding.buttonDropdown.isVisible = it.size > 1 - } - viewModel.chapters.observe(this, PrefetchObserver(this)) - viewModel.onDownloadStarted.observeEvent( - this, - DownloadStartedObserver(viewBinding.containerDetails), - ) - - addMenuProvider( - DetailsMenuProvider( - activity = this, - viewModel = viewModel, - snackbarHost = viewBinding.pager, - appShortcutManager = appShortcutManager, - ), - ) - } - - override fun getBottomSheetCollapsedHeight(): Int { - return viewBinding.layoutBsHeader?.measureHeight() ?: 0 - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_read -> openReader(isIncognitoMode = false) - R.id.button_dropdown -> showBranchPopupMenu(v) - } - } - - override fun onLongClick(v: View): Boolean = when (v.id) { - R.id.button_read -> { - buttonTip?.get()?.remove() - buttonTip = null - val menu = PopupMenu(v.context, v) - menu.inflate(R.menu.popup_read) - menu.setOnMenuItemClickListener(this) - menu.setForceShowIcon(true) - menu.show() - true - } - - else -> false - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_incognito -> { - openReader(isIncognitoMode = true) - true - } - - R.id.action_pages_thumbs -> { - val history = viewModel.historyInfo.value.history - PagesThumbnailsSheet.show( - fm = supportFragmentManager, - manga = viewModel.manga.value ?: return false, - chapterId = history?.chapterId - ?: viewModel.chapters.value.firstOrNull()?.chapter?.id - ?: return false, - currentPage = history?.page ?: 0, - ) - true - } - - else -> false - } - } - - private fun onChaptersSheetStateChanged(isExpanded: Boolean) { - val toolbar = viewBinding.toolbarChapters ?: return - if (isAnimationsEnabled) { - val transition = AutoTransition() - transition.duration = getAnimationDuration(R.integer.config_shorterAnimTime) - TransitionManager.beginDelayedTransition(toolbar, transition) - } - if (isExpanded) { - toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material) - } else { - toolbar.navigationIcon = null - } - toolbar.menuView?.isVisible = isExpanded - viewBinding.buttonRead.isGone = isExpanded - } - - private fun onMangaUpdated(manga: Manga) { - title = manga.title - val hasChapters = !manga.chapters.isNullOrEmpty() - viewBinding.buttonRead.isEnabled = hasChapters - invalidateOptionsMenu() - showBottomSheet(manga.chapters != null) - viewBinding.groupHeader?.isVisible = hasChapters - } - - private fun onMangaRemoved(manga: Manga) { - Toast.makeText( - this, - getString(R.string._s_deleted_from_local_storage, manga.title), - Toast.LENGTH_SHORT, - ).show() - finishAfterTransition() - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - if (insets.bottom > 0) { - window.setNavigationBarTransparentCompat( - this, - viewBinding.layoutBottom?.elevation ?: 0f, - 0.9f, - ) - } - viewBinding.cardChapters?.updateLayoutParams { - bottomMargin = insets.bottom + marginEnd - } - viewBinding.dragHandle?.updateLayoutParams { - bottomMargin = insets.top - } - } - - private fun onHistoryChanged(info: HistoryInfo) { - with(viewBinding.buttonRead) { - if (info.history != null) { - setText(R.string._continue) - setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play) - } else { - setText(R.string.read) - setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play) - } - } - val text = when { - !info.isValid -> getString(R.string.loading_) - info.currentChapter >= 0 -> getString( - R.string.chapter_d_of_d, - info.currentChapter + 1, - info.totalChapters, - ) - - info.totalChapters == 0 -> getString(R.string.no_chapters) - else -> resources.getQuantityString( - R.plurals.chapters, - info.totalChapters, - info.totalChapters, - ) - } - viewBinding.toolbarChapters?.title = text - viewBinding.textViewTitle?.text = text - } - - private fun onNewChaptersChanged(count: Int) { - val tab = viewBinding.tabs.getTabAt(0) ?: return - if (count == 0) { - tab.removeBadge() - } else { - val badge = tab.orCreateBadge - badge.horizontalOffsetWithText = -resources.getDimensionPixelOffset(R.dimen.margin_small) - badge.number = count - badge.isVisible = true - } - } - - private fun showBranchPopupMenu(v: View) { - val menu = PopupMenu(v.context, v) - val branches = viewModel.branches.value - for ((i, branch) in branches.withIndex()) { - val title = buildSpannedString { - 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()) - } - } - menu.menu.add(Menu.NONE, Menu.NONE, i, title) - } - menu.setOnMenuItemClickListener { - viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name) - true - } - menu.show() - } - - private fun openReader(isIncognitoMode: Boolean) { - val manga = viewModel.manga.value ?: return - val chapterId = viewModel.historyInfo.value.history?.chapterId - if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { - Snackbar.make(viewBinding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) - .show() - } else { - startActivity( - IntentBuilder(this) - .manga(manga) - .branch(viewModel.selectedBranchValue) - .incognito(isIncognitoMode) - .build(), - ) - if (isIncognitoMode) { - Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show() - } - } - } - - private fun initPager() { - viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false - val adapter = DetailsPagerAdapter(this) - viewBinding.pager.adapter = adapter - TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach() - viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false) - } - - private fun showBottomSheet(isVisible: Boolean) { - val view = viewBinding.layoutBottom ?: return - if (view.isVisible == isVisible) return - val transition = Slide(Gravity.BOTTOM) - transition.addTarget(view) - transition.interpolator = AccelerateDecelerateInterpolator() - TransitionManager.beginDelayedTransition(viewBinding.root as ViewGroup, transition) - view.isVisible = isVisible - } - - private class PrefetchObserver( - private val context: Context, - ) : FlowCollector?> { - - private var isCalled = false - - override suspend fun emit(value: List?) { - if (value.isNullOrEmpty()) { - return - } - if (!isCalled) { - isCalled = true - val item = value.find { it.isCurrent } ?: value.first() - MangaPrefetchService.prefetchPages(context, item.chapter) - } - } - } - - private fun showTip() { - val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel) - tip.addToRoot() - buttonTip = WeakReference(tip) - } - - companion object { - - const val TIP_BUTTON = "btn_read" - - 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/DetailsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt deleted file mode 100644 index af835d174..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ /dev/null @@ -1,403 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.os.Bundle -import android.transition.TransitionManager -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.Toast -import androidx.appcompat.widget.PopupMenu -import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.text.method.LinkMovementMethodCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.fragment.app.activityViewModels -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.util.CoilUtils -import com.google.android.material.chip.Chip -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.filterNotNull -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter -import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet -import org.koitharu.kotatsu.core.model.countChaptersByBranch -import org.koitharu.kotatsu.core.model.iconResId -import org.koitharu.kotatsu.core.model.titleResId -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.ext.crossfade -import org.koitharu.kotatsu.core.util.ext.drawableTop -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.isTextTruncated -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf -import org.koitharu.kotatsu.core.util.ext.showOrHide -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.FragmentDetailsBinding -import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.details.ui.model.HistoryInfo -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.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.image.ui.ImageActivity -import org.koitharu.kotatsu.list.domain.ListExtraProvider -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.MangaItemModel -import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver -import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner -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.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.search.ui.SearchActivity -import javax.inject.Inject - -@AndroidEntryPoint -class DetailsFragment : - BaseFragment(), - View.OnClickListener, - ChipsView.OnChipClickListener, - OnListItemClickListener, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var tagHighlighter: ListExtraProvider - - private val viewModel by activityViewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentDetailsBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentDetailsBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.textViewAuthor.setOnClickListener(this) - binding.imageViewCover.setOnClickListener(this) - binding.buttonDescriptionMore.setOnClickListener(this) - binding.buttonBookmarksMore.setOnClickListener(this) - binding.buttonScrobblingMore.setOnClickListener(this) - binding.buttonRelatedMore.setOnClickListener(this) - binding.infoLayout.textViewSource.setOnClickListener(this) - binding.textViewDescription.addOnLayoutChangeListener(this) - binding.textViewDescription.viewTreeObserver.addOnDrawListener(this) - binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() - binding.chipsTags.onChipClickListener = this - binding.recyclerViewRelated.addItemDecoration( - SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), - ) - TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView) - viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated) - viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) - viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged) - viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged) - viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) - viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) - viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged) - viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged) - viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged) - } - - override fun onItemClick(item: Bookmark, view: View) { - startActivity( - ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(), - ) - Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() - } - - override fun onItemLongClick(item: Bookmark, view: View): Boolean { - val menu = PopupMenu(view.context, view) - menu.inflate(R.menu.popup_bookmark) - menu.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_remove -> viewModel.removeBookmark(item) - } - true - } - menu.show() - return true - } - - override fun onDraw() { - viewBinding?.run { - buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE || - textViewDescription.isTextTruncated - } - } - - override fun onLayoutChange( - v: View?, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int - ) { - with(viewBinding ?: return) { - buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated - } - } - - private fun onMangaUpdated(manga: Manga) { - with(requireViewBinding()) { - // Main - loadCover(manga) - textViewTitle.text = manga.title - textViewSubtitle.textAndVisible = manga.altTitle - textViewAuthor.textAndVisible = manga.author - if (manga.hasRating) { - ratingBar.rating = manga.rating * ratingBar.numStars - ratingBar.isVisible = true - } else { - ratingBar.isVisible = false - } - - infoLayout.textViewState.apply { - manga.state?.let { state -> - textAndVisible = resources.getString(state.titleResId) - drawableTop = ContextCompat.getDrawable(context, state.iconResId) - } ?: run { - isVisible = false - } - } - if (manga.source == MangaSource.LOCAL) { - infoLayout.textViewSource.isVisible = false - } else { - infoLayout.textViewSource.text = manga.source.title - infoLayout.textViewSource.isVisible = true - } - - infoLayout.textViewNsfw.isVisible = manga.isNsfw - - // Chips - bindTags(manga) - } - } - - private fun onChaptersChanged(chapters: List?) { - val infoLayout = requireViewBinding().infoLayout - if (chapters.isNullOrEmpty()) { - infoLayout.textViewChapters.isVisible = false - } else { - val count = chapters.countChaptersByBranch() - infoLayout.textViewChapters.isVisible = true - val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count) - infoLayout.textViewChapters.text = chaptersText - } - } - - private fun onDescriptionChanged(description: CharSequence?) { - val tv = requireViewBinding().textViewDescription - if (description.isNullOrBlank()) { - tv.setText(R.string.no_description) - } else { - tv.text = description - } - } - - private fun onLocalSizeChanged(size: Long) { - val textView = requireViewBinding().infoLayout.textViewSize - if (size == 0L) { - textView.isVisible = false - } else { - textView.text = FileSize.BYTES.format(textView.context, size) - textView.isVisible = true - } - } - - private fun onRelatedMangaChanged(related: List) { - if (related.isEmpty()) { - requireViewBinding().groupRelated.isVisible = false - return - } - val rv = viewBinding?.recyclerViewRelated ?: return - - @Suppress("UNCHECKED_CAST") - val adapter = (rv.adapter as? BaseListAdapter) ?: BaseListAdapter() - .addDelegate( - ListItemType.MANGA_GRID, - mangaGridItemAD( - coil, viewLifecycleOwner, - StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), - ) { item, view -> - startActivity(DetailsActivity.newIntent(view.context, item)) - }, - ).also { rv.adapter = it } - adapter.items = related - requireViewBinding().groupRelated.isVisible = true - } - - private fun onHistoryChanged(history: HistoryInfo) { - requireViewBinding().progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true) - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - requireViewBinding().progressBar.showOrHide(isLoading) - } - - private fun onBookmarksChanged(bookmarks: List) { - var adapter = requireViewBinding().recyclerViewBookmarks.adapter as? BookmarksAdapter - requireViewBinding().groupBookmarks.isGone = bookmarks.isEmpty() - if (adapter != null) { - adapter.items = bookmarks - } else { - adapter = BookmarksAdapter(coil, viewLifecycleOwner, this) - adapter.items = bookmarks - requireViewBinding().recyclerViewBookmarks.adapter = adapter - val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing) - requireViewBinding().recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing)) - } - } - - private fun onScrobblingInfoChanged(scrobblings: List) { - var adapter = requireViewBinding().recyclerViewScrobbling.adapter as? ScrollingInfoAdapter - requireViewBinding().groupScrobbling.isGone = scrobblings.isEmpty() - if (adapter != null) { - adapter.items = scrobblings - } else { - adapter = ScrollingInfoAdapter(viewLifecycleOwner, coil, childFragmentManager) - adapter.items = scrobblings - requireViewBinding().recyclerViewScrobbling.adapter = adapter - requireViewBinding().recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration()) - } - } - - override fun onClick(v: View) { - val manga = viewModel.manga.value ?: return - when (v.id) { - R.id.textView_author -> { - startActivity( - SearchActivity.newIntent( - context = v.context, - source = manga.source, - query = manga.author ?: return, - ), - ) - } - - R.id.textView_source -> { - startActivity( - MangaListActivity.newIntent( - context = v.context, - source = manga.source, - ), - ) - } - - R.id.imageView_cover -> { - startActivity( - ImageActivity.newIntent( - v.context, - manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }, - manga.source, - ), - scaleUpActivityOptionsOf(v), - ) - } - - R.id.button_description_more -> { - val tv = requireViewBinding().textViewDescription - TransitionManager.beginDelayedTransition(tv.parentView) - if (tv.maxLines in 1 until Integer.MAX_VALUE) { - tv.maxLines = Integer.MAX_VALUE - } else { - tv.maxLines = resources.getInteger(R.integer.details_description_lines) - } - } - - R.id.button_scrobbling_more -> { - ScrobblingSelectorSheet.show(parentFragmentManager, manga, null) - } - - R.id.button_bookmarks_more -> { - BookmarksSheet.show(parentFragmentManager, manga) - } - - R.id.button_related_more -> { - startActivity(RelatedMangaActivity.newIntent(v.context, manga)) - } - } - } - - override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag ?: return - startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) - } - - override fun onWindowInsetsChanged(insets: Insets) { - requireViewBinding().root.updatePadding( - bottom = ( - (activity as? NoModalBottomSheetOwner)?.getBottomSheetCollapsedHeight() - ?.plus(insets.bottom)?.plus(resources.resolveDp(16)) - ) - ?: insets.bottom, - ) - } - - private fun bindTags(manga: Manga) { - requireViewBinding().chipsTags.setChips( - manga.tags.map { tag -> - ChipsView.ChipModel( - title = tag.title, - tint = tagHighlighter.getTagTint(tag), - icon = 0, - data = tag, - isCheckable = false, - isChecked = false, - ) - }, - ) - } - - private fun loadCover(manga: Manga) { - val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } - val lastResult = CoilUtils.result(requireViewBinding().imageViewCover) - if (lastResult is SuccessResult && lastResult.request.data == imageUrl) { - return - } - val request = ImageRequest.Builder(context ?: return) - .target(requireViewBinding().imageViewCover) - .size(CoverSizeResolver(requireViewBinding().imageViewCover)) - .data(imageUrl) - .tag(manga.source) - .crossfade(requireContext()) - .lifecycle(viewLifecycleOwner) - .placeholderMemoryCacheKey(manga.coverUrl) - val previousDrawable = lastResult?.drawable - if (previousDrawable != null) { - request.fallback(previousDrawable) - .placeholder(previousDrawable) - .error(previousDrawable) - } else { - request.fallback(R.drawable.ic_placeholder) - .placeholder(R.drawable.ic_placeholder) - .error(R.drawable.ic_error_placeholder) - } - request.enqueueWith(coil) - } -} 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 deleted file mode 100644 index 2f9f2e667..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.net.toFile -import androidx.core.net.toUri -import androidx.core.view.MenuProvider -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.BrowserActivity -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.download.ui.dialog.DownloadOption -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet -import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity - -class DetailsMenuProvider( - private val activity: FragmentActivity, - private val viewModel: DetailsViewModel, - private val snackbarHost: View, - private val appShortcutManager: AppShortcutManager, -) : MenuProvider, OnListItemClickListener { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_details, menu) - } - - override fun onPrepareMenu(menu: Menu) { - val manga = viewModel.manga.value - menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL - menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL - menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL - menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) - menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable - menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null - menu.findItem(R.id.action_favourite).setIcon( - if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, - ) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.action_share -> { - viewModel.manga.value?.let { - val shareHelper = ShareHelper(activity) - if (it.source == MangaSource.LOCAL) { - shareHelper.shareCbz(listOf(it.url.toUri().toFile())) - } else { - shareHelper.shareMangaLink(it) - } - } - } - - R.id.action_favourite -> { - viewModel.manga.value?.let { - FavoriteSheet.show(activity.supportFragmentManager, it) - } - } - - R.id.action_delete -> { - val title = viewModel.manga.value?.title.orEmpty() - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.delete_manga) - .setMessage(activity.getString(R.string.text_delete_local_manga, title)) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.deleteLocal() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - R.id.action_save -> { - DownloadDialogHelper(snackbarHost, viewModel).show(this) - } - - R.id.action_browser -> { - viewModel.manga.value?.let { - activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title)) - } - } - - R.id.action_online -> { - viewModel.remoteManga.value?.let { - activity.startActivity(DetailsActivity.newIntent(activity, it)) - } - } - - R.id.action_related -> { - viewModel.manga.value?.let { - activity.startActivity(MultiSearchActivity.newIntent(activity, it.title)) - } - } - - R.id.action_scrobbling -> { - viewModel.manga.value?.let { - ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, 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() - } - } - } - } - - else -> return false - } - return true - } - - override fun onItemClick(item: DownloadOption, view: View) { - val chaptersIds: Set? = when (item) { - is DownloadOption.WholeManga -> null - is DownloadOption.SelectionHint -> { - viewModel.startChaptersSelection() - return - } - - else -> item.chaptersIds - } - viewModel.download(chaptersIds) - } -} 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 deleted file mode 100644 index bf15a0ac4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ /dev/null @@ -1,373 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.plus -import okio.FileNotFoundException -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.parser.MangaIntent -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode -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.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.computeSize -import org.koitharu.kotatsu.core.util.ext.onEachWhile -import org.koitharu.kotatsu.core.util.ext.requireValue -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.details.domain.BranchComparator -import org.koitharu.kotatsu.details.domain.DetailsInteractor -import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase -import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase -import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase -import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.details.ui.model.HistoryInfo -import org.koitharu.kotatsu.details.ui.model.MangaBranch -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.model.MangaItemModel -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.local.data.LocalStorageChanges -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.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import javax.inject.Inject - -@HiltViewModel -class DetailsViewModel @Inject constructor( - private val historyRepository: HistoryRepository, - private val bookmarksRepository: BookmarksRepository, - private val settings: AppSettings, - private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, - @LocalStorageChanges private val localStorageChanges: SharedFlow, - private val downloadScheduler: DownloadWorker.Scheduler, - private val interactor: DetailsInteractor, - savedStateHandle: SavedStateHandle, - private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, - private val relatedMangaUseCase: RelatedMangaUseCase, - private val extraProvider: ListExtraProvider, - private val detailsLoadUseCase: DetailsLoadUseCase, - private val progressUpdateUseCase: ProgressUpdateUseCase, -) : BaseViewModel() { - - private val intent = MangaIntent(savedStateHandle) - private val mangaId = intent.mangaId - private var loadingJob: Job - - val onActionDone = MutableEventFlow() - val onShowTip = MutableEventFlow() - val onSelectChapter = MutableEventFlow() - val onDownloadStarted = MutableEventFlow() - - val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) - val manga = details.map { x -> x?.toManga() } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val history = historyRepository.observeOne(mangaId) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val favouriteCategories = interactor.observeIsFavourite(mangaId) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - - val remoteManga = MutableStateFlow(null) - - val newChaptersCount = details.flatMapLatest { d -> - if (d?.isLocal == false) { - interactor.observeNewChapters(mangaId) - } else { - flowOf(0) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) - - private val chaptersQuery = MutableStateFlow("") - val selectedBranch = MutableStateFlow(null) - - val isChaptersReversed = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_REVERSE_CHAPTERS, - valueProducer = { chaptersReverse }, - ) - - val historyInfo: StateFlow = combine( - manga, - selectedBranch, - 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 bookmarks = manga.flatMapLatest { - if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) - - val localSize = details - .map { it?.local } - .distinctUntilChanged() - .map { local -> - if (local != null) { - runCatchingCancellable { - local.file.computeSize() - }.getOrDefault(0L) - } else { - 0L - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L) - - @Deprecated("") - val description = details - .map { it?.description } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) - - val onMangaRemoved = MutableEventFlow() - val isScrobblingAvailable: Boolean - get() = scrobblers.any { it.isAvailable } - - val scrobblingInfo: StateFlow> = interactor.observeScrobblingInfo(mangaId) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - val relatedManga: StateFlow> = manga.mapLatest { - if (it != null && settings.isRelatedMangaEnabled) { - relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() - } else { - emptyList() - } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - val branches: StateFlow> = combine( - details, - selectedBranch, - ) { m, b -> - (m?.chapters ?: return@combine emptyList()) - .map { x -> MangaBranch(x.key, x.value.size, x.key == b) } - .sortedWith(BranchComparator()) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - val isChaptersEmpty: StateFlow = details.map { - it != null && it.isLoaded && it.allChapters.isEmpty() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - val chapters = combine( - combine( - details, - history, - selectedBranch, - newChaptersCount, - bookmarks, - ) { manga, history, branch, news, bookmarks -> - manga?.mapChapters( - history, - news, - branch, - bookmarks, - ).orEmpty() - }, - isChaptersReversed, - chaptersQuery, - ) { list, reversed, query -> - (if (reversed) list.asReversed() else list).filterSearch(query) - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - - val selectedBranchValue: String? - get() = selectedBranch.value - - init { - loadingJob = doLoad() - launchJob(Dispatchers.Default) { - localStorageChanges - .collect { onDownloadComplete(it) } - } - launchJob(Dispatchers.Default) { - if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) { - manga.filterNot { it?.chapters.isNullOrEmpty() }.first() - onShowTip.call(Unit) - } - } - launchJob(Dispatchers.Default) { - val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob - val h = history.firstOrNull() - if (h != null) { - progressUpdateUseCase(manga.toManga()) - } - } - launchJob(Dispatchers.Default) { - val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob - remoteManga.value = interactor.findRemote(manga.toManga()) - } - } - - fun reload() { - loadingJob.cancel() - loadingJob = doLoad() - } - - fun deleteLocal() { - val m = details.value?.local?.manga - if (m == null) { - errorEvent.call(FileNotFoundException()) - return - } - launchLoadingJob(Dispatchers.Default) { - deleteLocalMangaUseCase(m) - onMangaRemoved.call(m) - } - } - - fun removeBookmark(bookmark: Bookmark) { - launchJob(Dispatchers.Default) { - bookmarksRepository.removeBookmark(bookmark) - onActionDone.call(ReversibleAction(R.string.bookmark_removed, null)) - } - } - - fun setChaptersReversed(newValue: Boolean) { - settings.chaptersReverse = newValue - } - - fun setSelectedBranch(branch: String?) { - selectedBranch.value = branch - } - - fun performChapterSearch(query: String?) { - chaptersQuery.value = query?.trim().orEmpty() - } - - fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { - val scrobbler = getScrobbler(index) ?: return - launchJob(Dispatchers.Default) { - scrobbler.updateScrobblingInfo( - mangaId = mangaId, - rating = rating, - status = status, - comment = null, - ) - } - } - - fun unregisterScrobbling(index: Int) { - val scrobbler = getScrobbler(index) ?: return - launchJob(Dispatchers.Default) { - scrobbler.unregisterScrobbling( - mangaId = mangaId, - ) - } - } - - fun markChapterAsCurrent(chapterId: Long) { - launchJob(Dispatchers.Default) { - val manga = checkNotNull(details.value) - val chapters = checkNotNull(manga.chapters[selectedBranchValue]) - val chapterIndex = chapters.indexOfFirst { it.id == chapterId } - check(chapterIndex in chapters.indices) { "Chapter not found" } - val percent = chapterIndex / chapters.size.toFloat() - historyRepository.addOrUpdate( - manga = manga.toManga(), - chapterId = chapterId, - page = 0, - scroll = 0, - percent = percent, - ) - } - } - - fun download(chaptersIds: Set?) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule( - details.requireValue().toManga(), - chaptersIds, - ) - onDownloadStarted.call(Unit) - } - } - - fun startChaptersSelection() { - val chapters = chapters.value - val chapter = chapters.find { - it.isUnread && !it.isDownloaded - } ?: chapters.firstOrNull() ?: return - onSelectChapter.call(chapter.chapter.id) - } - - fun onButtonTipClosed() { - settings.closeTip(DetailsActivity.TIP_BUTTON) - } - - private fun doLoad() = launchLoadingJob(Dispatchers.Default) { - detailsLoadUseCase.invoke(intent) - .onEachWhile { - if (it.allChapters.isEmpty()) { - return@onEachWhile false - } - val manga = it.toManga() - // find default branch - val hist = historyRepository.getOne(manga) - selectedBranch.value = manga.getPreferredBranch(hist) - true - }.collect { - details.value = it - } - } - - private fun List.filterSearch(query: String): List { - if (query.isEmpty() || this.isEmpty()) { - return this - } - return filter { - it.chapter.name.contains(query, ignoreCase = true) - } - } - - private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { - downloadedManga ?: return - launchJob { - details.update { - interactor.updateLocal(it, downloadedManga) - } - } - } - - private fun getScrobbler(index: Int): Scrobbler? { - val info = scrobblingInfo.value.getOrNull(index) - val scrobbler = if (info != null) { - scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable } - } else { - null - } - if (scrobbler == null) { - errorEvent.call(IllegalStateException("Scrobbler [$index] is not available")) - } - return scrobbler - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt deleted file mode 100644 index df456252f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DownloadDialogHelper.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.content.DialogInterface -import android.view.View -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.ids -import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.download.ui.dialog.DownloadOption -import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD - -class DownloadDialogHelper( - private val host: View, - private val viewModel: DetailsViewModel, -) { - - fun show(callback: OnListItemClickListener) { - val branch = viewModel.selectedBranchValue - val allChapters = viewModel.manga.value?.chapters ?: return - val branchChapters = viewModel.manga.value?.getChapters(branch).orEmpty() - val history = viewModel.history.value - - val options = buildList { - add(DownloadOption.WholeManga(allChapters.ids())) - if (branch != null && branchChapters.isNotEmpty()) { - add(DownloadOption.AllChapters(branch, branchChapters.ids())) - } - - if (history != null) { - val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId } - if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) { - add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch)) - if (unreadChapters.size > 5) { - add(DownloadOption.NextUnreadChapters(unreadChapters.take(5).ids())) - if (unreadChapters.size > 10) { - add(DownloadOption.NextUnreadChapters(unreadChapters.take(10).ids())) - } - } - } - } else { - if (branchChapters.size > 5) { - add(DownloadOption.FirstChapters(branchChapters.take(5).ids())) - if (branchChapters.size > 10) { - add(DownloadOption.FirstChapters(branchChapters.take(10).ids())) - } - } - } - add(DownloadOption.SelectionHint()) - } - var dialog: DialogInterface? = null - val listener = OnListItemClickListener { item, _ -> - callback.onItemClick(item, host) - dialog?.dismiss() - } - dialog = RecyclerViewAlertDialog.Builder(host.context) - .addAdapterDelegate(downloadOptionAD(listener)) - .setCancelable(true) - .setTitle(R.string.download) - .setNegativeButton(android.R.string.cancel) - .setItems(options) - .create() - .also { it.show() } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt deleted file mode 100644 index 364357531..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleScrollCoordinator.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.content.Context -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.doOnLayout -import androidx.core.widget.NestedScrollView -import org.koitharu.kotatsu.core.util.ext.findActivity -import java.lang.ref.WeakReference - -class TitleScrollCoordinator( - private val titleView: TextView, -) : NestedScrollView.OnScrollChangeListener { - - private val location = IntArray(2) - private var activityRef: WeakReference? = null - - override fun onScrollChange(v: NestedScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) { - val actionBar = getActivity(v.context)?.supportActionBar ?: return - titleView.getLocationOnScreen(location) - var top = location[1] + titleView.height - v.getLocationOnScreen(location) - top -= location[1] - actionBar.setDisplayShowTitleEnabled(top < 0) - } - - fun attach(scrollView: NestedScrollView) { - scrollView.setOnScrollChangeListener(this) - scrollView.doOnLayout { - onScrollChange(scrollView, 0, 0, 0, 0) - } - } - - private fun getActivity(context: Context): AppCompatActivity? { - activityRef?.get()?.let { - if (!it.isDestroyed) return it - } - val activity = context.findActivity() as? AppCompatActivity - if (activity == null || activity.isDestroyed) { - return null - } - activityRef = WeakReference(activity) - return activity - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt deleted file mode 100644 index 2cc6f1f9f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.koitharu.kotatsu.details.ui.adapter - -import android.content.Context -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.details.ui.model.ChapterListItem - -class ChaptersAdapter( - onItemClickListener: OnListItemClickListener, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - setHasStableIds(true) - delegatesManager.addDelegate(chapterListItemAD(onItemClickListener)) - } - - override fun getItemId(position: Int): Long { - return items[position].chapter.id - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - val item = items.getOrNull(position) ?: return null - return item.chapter.number.toString() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt deleted file mode 100644 index 609f6ad09..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.details.ui.model - -import android.text.format.DateUtils -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.MangaChapter - -data class ChapterListItem( - val chapter: MangaChapter, - val flags: Int, - private val uploadDateMs: Long, -) : ListModel { - - var uploadDate: CharSequence? = null - private set - get() { - if (field != null) return field - if (uploadDateMs == 0L) return null - field = DateUtils.getRelativeTimeSpanString( - uploadDateMs, - System.currentTimeMillis(), - DateUtils.DAY_IN_MILLIS, - ) - return field - } - - val isCurrent: Boolean - get() = hasFlag(FLAG_CURRENT) - - val isUnread: Boolean - get() = hasFlag(FLAG_UNREAD) - - val isDownloaded: Boolean - get() = hasFlag(FLAG_DOWNLOADED) - - val isBookmarked: Boolean - get() = hasFlag(FLAG_BOOKMARKED) - - val isNew: Boolean - get() = hasFlag(FLAG_NEW) - - fun description(): CharSequence? { - val scanlator = chapter.scanlator?.takeUnless { it.isBlank() } - return when { - uploadDate != null && scanlator != null -> "$uploadDate • $scanlator" - scanlator != null -> scanlator - else -> uploadDate - } - } - - private fun hasFlag(flag: Int): Boolean { - return (flags and flag) == flag - } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ChapterListItem && chapter.id == other.chapter.id - } - - override fun getChangePayload(previousState: ListModel): Any? { - if (previousState !is ChapterListItem) { - return super.getChangePayload(previousState) - } - return if (chapter == previousState.chapter && flags != previousState.flags) { - flags - } else { - super.getChangePayload(previousState) - } - } - - companion object { - - const val FLAG_UNREAD = 2 - const val FLAG_CURRENT = 4 - const val FLAG_NEW = 8 - const val FLAG_BOOKMARKED = 16 - const val FLAG_DOWNLOADED = 32 - } -} 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 deleted file mode 100644 index 66fa2d922..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.details.ui.model - -import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.parsers.model.Manga - -data class HistoryInfo( - val totalChapters: Int, - val currentChapter: Int, - val history: MangaHistory?, - val isIncognitoMode: Boolean, -) { - val isValid: Boolean - get() = totalChapters >= 0 -} - -fun HistoryInfo( - manga: Manga?, - branch: String?, - history: MangaHistory?, - isIncognitoMode: Boolean -): HistoryInfo { - val chapters = manga?.getChapters(branch) - return HistoryInfo( - totalChapters = chapters?.size ?: -1, - currentChapter = if (history != null && !chapters.isNullOrEmpty()) { - chapters.indexOfFirst { it.id == history.chapterId } - } else { - -1 - }, - history = history, - isIncognitoMode = isIncognitoMode, - ) -} 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 deleted file mode 100644 index 841621277..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.details.ui.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class MangaBranch( - val name: String?, - val count: Int, - val isSelected: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaBranch && other.name == name - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is MangaBranch && previousState.isSelected != isSelected) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } - - override fun toString(): String { - return "$name: $count" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt deleted file mode 100644 index b916a1471..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.details.ui.pager - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment -import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment - -class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity), - TabLayoutMediator.TabConfigurationStrategy { - - override fun getItemCount(): Int = 2 - - override fun createFragment(position: Int): Fragment = when (position) { - 0 -> ChaptersFragment() - 1 -> PagesFragment() - else -> throw IllegalArgumentException("Invalid position $position") - } - - override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - tab.setText( - when (position) { - 0 -> R.string.chapters - 1 -> R.string.pages - else -> 0 - }, - ) - } -} 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 deleted file mode 100644 index 768fb7201..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ /dev/null @@ -1,224 +0,0 @@ -package org.koitharu.kotatsu.details.ui.pager.chapters - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -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.util.RecyclerViewScrollCallback -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.ChaptersMenuProvider -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.details.ui.DetailsViewModel -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.local.ui.LocalChaptersRemoveService -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.reader.ui.ReaderState -import kotlin.math.roundToInt - -class ChaptersFragment : - BaseFragment(), - OnListItemClickListener, - ListSelectionController.Callback2 { - - private val viewModel by activityViewModels() - - private var chaptersAdapter: ChaptersAdapter? = null - private var selectionController: ListSelectionController? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentChaptersBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentChaptersBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - chaptersAdapter = ChaptersAdapter(this) - selectionController = ListSelectionController( - activity = requireActivity(), - decoration = ChaptersSelectionDecoration(binding.root.context), - registryOwner = this, - callback = this, - ) - with(binding.recyclerViewChapters) { - checkNotNull(selectionController).attachToRecyclerView(this) - setHasFixedSize(true) - adapter = chaptersAdapter - } - viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) - viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged) - viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { - binding.textViewHolder.isVisible = it - } - viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) { - selectionController?.onItemLongClick(it) - } - val detailsActivity = activity as? DetailsActivity - if (detailsActivity != null) { - val menuProvider = ChaptersMenuProvider(viewModel, detailsActivity.bottomSheetMediator) - activity?.onBackPressedDispatcher?.addCallback(menuProvider) - detailsActivity.secondaryMenuHost.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - } - - override fun onDestroyView() { - chaptersAdapter = null - selectionController = null - super.onDestroyView() - } - - override fun onItemClick(item: ChapterListItem, view: View) { - if (selectionController?.onItemClick(item.chapter.id) == true) { - return - } - startActivity( - IntentBuilder(view.context) - .manga(viewModel.manga.value ?: return) - .state(ReaderState(item.chapter.id, 0, 0)) - .build(), - ) - } - - override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { - return selectionController?.onItemLongClick(item.chapter.id) ?: false - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_save -> { - viewModel.download(selectionController?.snapshot()) - mode.finish() - true - } - - R.id.action_delete -> { - val ids = selectionController?.peekCheckedIds() - val manga = viewModel.manga.value - when { - ids.isNullOrEmpty() || manga == null -> Unit - ids.size == manga.chapters?.size -> viewModel.deleteLocal() - else -> { - LocalChaptersRemoveService.start(requireContext(), manga, ids) - Snackbar.make( - requireViewBinding().recyclerViewChapters, - R.string.chapters_will_removed_background, - Snackbar.LENGTH_LONG, - ).show() - } - } - mode.finish() - true - } - - R.id.action_select_range -> { - val items = chaptersAdapter?.items ?: return false - val ids = HashSet(controller.peekCheckedIds()) - val buffer = HashSet() - var isAdding = false - for (x in items) { - if (x.chapter.id in ids) { - isAdding = true - if (buffer.isNotEmpty()) { - ids.addAll(buffer) - buffer.clear() - } - } else if (isAdding) { - buffer.add(x.chapter.id) - } - } - controller.addAll(ids) - true - } - - R.id.action_select_all -> { - val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false - controller.addAll(ids) - true - } - - R.id.action_mark_current -> { - val id = controller.peekCheckedIds().singleOrNull() ?: return false - viewModel.markChapterAsCurrent(id) - mode.finish() - true - } - - else -> false - } - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_chapters, menu) - return true - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - val selectedIds = selectionController?.peekCheckedIds() ?: return false - val allItems = chaptersAdapter?.items.orEmpty() - val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds } - var canSave = true - var canDelete = true - items.forEach { (_, x) -> - val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL - if (isLocal) canSave = false else canDelete = false - } - menu.findItem(R.id.action_save).isVisible = canSave - menu.findItem(R.id.action_delete).isVisible = canDelete - menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size - menu.findItem(R.id.action_mark_current).isVisible = items.size == 1 - mode.title = items.size.toString() - var hasGap = false - for (i in 0 until items.size - 1) { - if (items[i].index + 1 != items[i + 1].index) { - hasGap = true - break - } - } - menu.findItem(R.id.action_select_range).isVisible = hasGap - return true - } - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - requireViewBinding().recyclerViewChapters.invalidateItemDecorations() - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - private fun onChaptersChanged(list: List) { - val adapter = chaptersAdapter ?: return - if (adapter.itemCount == 0) { - val position = list.indexOfFirst { it.isCurrent } - 1 - if (position > 0) { - val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() - adapter.setItems( - list, - RecyclerViewScrollCallback(requireViewBinding().recyclerViewChapters, position, offset), - ) - } else { - adapter.items = list - } - } else { - adapter.items = list - } - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - requireViewBinding().progressBar.isVisible = isLoading - } -} 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 deleted file mode 100644 index f6d0ffa70..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt +++ /dev/null @@ -1,189 +0,0 @@ -package org.koitharu.kotatsu.details.ui.pager.pages - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.combine -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.BaseFragment -import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback -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.FragmentPagesBinding -import org.koitharu.kotatsu.details.ui.DetailsViewModel -import org.koitharu.kotatsu.list.ui.MangaListSpanResolver -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.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail -import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter -import javax.inject.Inject -import kotlin.math.roundToInt - -@AndroidEntryPoint -class PagesFragment : - BaseFragment(), - OnListItemClickListener { - - private val detailsViewModel by activityViewModels() - private val viewModel by viewModels() - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - private var thumbnailsAdapter: PageThumbnailAdapter? = null - private var spanResolver: MangaListSpanResolver? = null - private var scrollListener: ScrollListener? = null - - private val spanSizeLookup = SpanSizeLookup() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - combine( - detailsViewModel.details, - detailsViewModel.history, - detailsViewModel.selectedBranch, - ) { details, history, branch -> - if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) { - PagesViewModel.State(details, history, branch) - } else { - null - } - }.observe(this, viewModel::updateState) - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding { - return FragmentPagesBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - spanResolver = MangaListSpanResolver(binding.root.resources) - thumbnailsAdapter = PageThumbnailAdapter( - coil = coil, - lifecycleOwner = viewLifecycleOwner, - clickListener = this@PagesFragment, - ) - with(binding.recyclerView) { - addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = thumbnailsAdapter - setHasFixedSize(true) - addOnLayoutChangeListener(spanResolver) - spanResolver?.setGridSize(settings.gridSize / 100f, this) - addOnScrollListener(ScrollListener().also { scrollListener = it }) - (layoutManager as GridLayoutManager).let { - it.spanSizeLookup = spanSizeLookup - it.spanCount = checkNotNull(spanResolver).spanCount - } - } - detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) - viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } - viewModel.isLoadingUp.observe(viewLifecycleOwner) { binding.progressBarTop.showOrHide(it) } - viewModel.isLoadingDown.observe(viewLifecycleOwner) { binding.progressBarBottom.showOrHide(it) } - } - - override fun onDestroyView() { - spanResolver = null - scrollListener = null - thumbnailsAdapter = null - spanSizeLookup.invalidateCache() - super.onDestroyView() - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - override fun onItemClick(item: PageThumbnail, view: View) { - val manga = detailsViewModel.manga.value ?: return - val state = ReaderState(item.page.chapterId, item.page.index, 0) - val intent = IntentBuilder(view.context).manga(manga).state(state).build() - startActivity(intent) - } - - private suspend fun onThumbnailsChanged(list: List) { - val adapter = thumbnailsAdapter ?: return - if (adapter.itemCount == 0) { - var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } - if (position > 0) { - val spanCount = spanResolver?.spanCount ?: 0 - val offset = if (position > spanCount + 1) { - (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() - } else { - position = 0 - 0 - } - val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) - adapter.emit(list) - scrollCallback.run() - } else { - adapter.emit(list) - } - } else { - adapter.emit(list) - } - spanSizeLookup.invalidateCache() - viewBinding?.recyclerView?.let { - scrollListener?.postInvalidate(it) - } - } - - private fun onNoChaptersChanged(isNoChapters: Boolean) { - with(viewBinding ?: return) { - textViewHolder.isVisible = isNoChapters - recyclerView.isInvisible = isNoChapters - } - } - - private inner class ScrollListener : BoundsScrollListener(3, 3) { - - override fun onScrolledToStart(recyclerView: RecyclerView) { - viewModel.loadPrevChapter() - } - - override fun onScrolledToEnd(recyclerView: RecyclerView) { - viewModel.loadNextChapter() - } - } - - private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { - - init { - isSpanIndexCacheEnabled = true - isSpanGroupIndexCacheEnabled = true - } - - override fun getSpanSize(position: Int): Int { - val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 - return when (thumbnailsAdapter?.getItemViewType(position)) { - ListItemType.PAGE_THUMB.ordinal -> 1 - else -> total - } - } - - fun invalidateCache() { - invalidateSpanGroupIndexCache() - invalidateSpanIndexCache() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt deleted file mode 100644 index dd311149d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.koitharu.kotatsu.details.ui.pager.pages - -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.firstNotNull -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.domain.ChaptersLoader -import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail -import javax.inject.Inject - -@HiltViewModel -class PagesViewModel @Inject constructor( - private val chaptersLoader: ChaptersLoader, -) : BaseViewModel() { - - private var loadingJob: Job? = null - private var loadingPrevJob: Job? = null - private var loadingNextJob: Job? = null - - private val state = MutableStateFlow(null) - val thumbnails = MutableStateFlow>(emptyList()) - val isLoadingUp = MutableStateFlow(false) - val isLoadingDown = MutableStateFlow(false) - - init { - loadingJob = launchLoadingJob(Dispatchers.Default) { - val firstState = state.firstNotNull() - doInit(firstState) - launchJob(Dispatchers.Default) { - state.collectLatest { - if (it != null) { - doInit(it) - } - } - } - } - } - - fun updateState(newState: State?) { - if (newState != null) { - state.value = newState - } - } - - fun loadPrevChapter() { - if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { - return - } - loadingPrevJob = loadPrevNextChapter(isNext = false) - } - - fun loadNextChapter() { - if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) { - return - } - loadingNextJob = loadPrevNextChapter(isNext = true) - } - - private suspend fun doInit(state: State) { - chaptersLoader.init(state.details) - val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return - if (!chaptersLoader.hasPages(initialChapterId)) { - chaptersLoader.loadSingleChapter(initialChapterId) - } - updateList(state.history) - } - - private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) { - val indicator = if (isNext) isLoadingDown else isLoadingUp - indicator.value = true - try { - val currentState = state.firstNotNull() - val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId - chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext) - updateList(currentState.history) - } finally { - indicator.value = false - } - } - - private fun updateList(history: MangaHistory?) { - val snapshot = chaptersLoader.snapshot() - val pages = buildList(snapshot.size + chaptersLoader.size + 2) { - var previousChapterId = 0L - for (page in snapshot) { - if (page.chapterId != previousChapterId) { - chaptersLoader.peekChapter(page.chapterId)?.let { - add(ListHeader(it.name)) - } - previousChapterId = page.chapterId - } - this += PageThumbnail( - isCurrent = history?.let { - page.chapterId == it.chapterId && page.index == it.page - } ?: false, - page = page, - ) - } - } - thumbnails.value = pages - } - - data class State( - val details: MangaDetails, - val history: MangaHistory?, - val branch: String? - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt deleted file mode 100644 index 8ec35ac90..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListFragment.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.details.ui.related - -import android.view.Menu -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.ui.list.ListSelectionController -import org.koitharu.kotatsu.list.ui.MangaListFragment - -@AndroidEntryPoint -class RelatedListFragment : MangaListFragment() { - - override val viewModel by viewModels() - override val isSwipeRefreshEnabled = false - - override fun onScrolledToEnd() = Unit - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) - } -} - 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 deleted file mode 100644 index 8cc83be4b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.details.ui.related - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -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.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -@HiltViewModel -class RelatedListViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - settings: AppSettings, - private val extraProvider: ListExtraProvider, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { - - private val seed = savedStateHandle.require(MangaIntent.KEY_MANGA).manga - private val repository = mangaRepositoryFactory.create(seed.source) - private val mangaList = MutableStateFlow?>(null) - private val listError = MutableStateFlow(null) - private var loadingJob: Job? = null - - override val content = combine( - mangaList, - listMode, - listError, - ) { list, mode, error -> - when { - list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) - list == null -> listOf(LoadingState) - list.isEmpty() -> listOf(createEmptyState()) - else -> list.toUi(mode, extraProvider) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - init { - loadList() - } - - override fun onRefresh() { - loadList() - } - - override fun onRetry() { - loadList() - } - - private fun loadList(): Job { - loadingJob?.let { - if (it.isActive) return it - } - return launchLoadingJob(Dispatchers.Default) { - try { - listError.value = null - mangaList.value = repository.getRelated(seed) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - listError.value = e - if (!mangaList.value.isNullOrEmpty()) { - errorEvent.call(e) - } - } - }.also { loadingJob = it } - } - - private fun createEmptyState() = EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = 0, - actionStringRes = 0, - ) -} - 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 deleted file mode 100644 index fc8806f64..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.details.ui.related - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.Manga - -@AndroidEntryPoint -class RelatedMangaActivity : BaseActivity(), AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, RelatedListFragment::class.java, intent.extras) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed)) - } -} 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 deleted file mode 100644 index 1a42bd0bd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.details.ui.scrobbling - -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo - -fun scrobblingInfoAD( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - fragmentManager: FragmentManager, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) }, -) { - binding.root.setOnClickListener { - ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition) - } - - bind { - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - enqueueWith(coil) - } - binding.textViewTitle.setText(item.scrobbler.titleResId) - binding.imageViewIcon.setImageResource(item.scrobbler.iconResId) - binding.ratingBar.rating = item.rating * binding.ratingBar.numStars - binding.textViewStatus.text = item.status?.let { - context.resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal) - } - } -} 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 deleted file mode 100644 index cc3f71d6a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt +++ /dev/null @@ -1,173 +0,0 @@ -package org.koitharu.kotatsu.details.ui.scrobbling - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -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 coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -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 -class ScrobblingInfoSheet : - BaseAdaptiveSheet(), - AdapterView.OnItemSelectedListener, - RatingBar.OnRatingBarChangeListener, - View.OnClickListener, - PopupMenu.OnMenuItemClickListener { - - private val viewModel by activityViewModels() - private var scrobblerIndex: Int = -1 - - @Inject - lateinit var coil: ImageLoader - - private var menu: PopupMenu? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex) - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding { - return SheetScrobblingBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetScrobblingBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged) - viewModel.onError.observeEvent(viewLifecycleOwner) { - Toast.makeText(binding.root.context, it.getDisplayMessage(binding.root.resources), Toast.LENGTH_SHORT) - .show() - } - - binding.spinnerStatus.onItemSelectedListener = this - binding.ratingBar.onRatingBarChangeListener = this - binding.buttonMenu.setOnClickListener(this) - binding.imageViewCover.setOnClickListener(this) - binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() - - menu = PopupMenu(binding.root.context, binding.buttonMenu).apply { - inflate(R.menu.opt_scrobbling) - setOnMenuItemClickListener(this@ScrobblingInfoSheet) - } - } - - override fun onDestroyView() { - super.onDestroyView() - menu = null - } - - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - viewModel.updateScrobbling( - index = scrobblerIndex, - rating = requireViewBinding().ratingBar.rating / requireViewBinding().ratingBar.numStars, - status = ScrobblingStatus.entries.getOrNull(position), - ) - } - - override fun onNothingSelected(parent: AdapterView<*>?) = Unit - - override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { - if (fromUser) { - viewModel.updateScrobbling( - index = scrobblerIndex, - rating = rating / ratingBar.numStars, - status = ScrobblingStatus.entries.getOrNull(requireViewBinding().spinnerStatus.selectedItemPosition), - ) - } - } - - 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) - } - } - } - - private fun onScrobblingInfoChanged(scrobblings: List) { - val scrobbling = scrobblings.getOrNull(scrobblerIndex) - if (scrobbling == null) { - dismissAllowingStateLoss() - return - } - val binding = viewBinding ?: return - binding.textViewTitle.text = scrobbling.title - binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars - binding.textViewDescription.text = scrobbling.description?.sanitize() - binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) - binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId) - binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId) - binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - enqueueWith(coil) - } - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - 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)), - ) - } - - R.id.action_unregister -> { - viewModel.unregisterScrobbling(scrobblerIndex) - dismiss() - } - - R.id.action_edit -> { - val manga = viewModel.manga.value ?: return false - val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler - ScrobblingSelectorSheet.show(parentFragmentManager, 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/ScrobblingItemDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingItemDecoration.kt deleted file mode 100644 index ce9523a8d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingItemDecoration.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.details.ui.scrobbling - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R - -class ScrobblingItemDecoration : RecyclerView.ItemDecoration() { - - private var spacing: Int = -1 - - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - if (spacing == -1) { - spacing = parent.context.resources.getDimensionPixelOffset(R.dimen.scrobbling_list_spacing) - } - outRect.set(0, spacing, 0, 0) - } -} 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 deleted file mode 100644 index aa36cb8c1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.details.ui.scrobbling - -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.list.ui.model.ListModel - -class ScrollingInfoAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - fragmentManager: FragmentManager, -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt deleted file mode 100644 index 6f392ee50..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.koitharu.kotatsu.download.domain - -import androidx.work.Data -import org.koitharu.kotatsu.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import java.time.Instant - -data class DownloadState( - val manga: Manga, - val isIndeterminate: Boolean, - val isPaused: Boolean = false, - val isStopped: Boolean = false, - val error: String? = null, - val totalChapters: Int = 0, - val currentChapter: Int = 0, - val totalPages: Int = 0, - val currentPage: Int = 0, - val eta: Long = -1L, - val localManga: LocalManga? = null, - val downloadedChapters: Int = 0, - val timestamp: Long = System.currentTimeMillis(), -) { - - val max: Int = totalChapters * totalPages - - val progress: Int = totalPages * currentChapter + currentPage + 1 - - val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE - - val isFinalState: Boolean - get() = localManga != null || (error != null && !isPaused) - - val isParticularProgress: Boolean - get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate - - fun toWorkData() = Data.Builder() - .putLong(DATA_MANGA_ID, manga.id) - .putInt(DATA_MAX, max) - .putInt(DATA_PROGRESS, progress) - .putLong(DATA_ETA, eta) - .putLong(DATA_TIMESTAMP, timestamp) - .putString(DATA_ERROR, error) - .putInt(DATA_CHAPTERS, downloadedChapters) - .putBoolean(DATA_INDETERMINATE, isIndeterminate) - .putBoolean(DATA_PAUSED, isPaused) - .build() - - companion object { - - private const val DATA_MANGA_ID = "manga_id" - private const val DATA_MAX = "max" - private const val DATA_PROGRESS = "progress" - private const val DATA_CHAPTERS = "chapter_cnt" - private const val DATA_ETA = "eta" - const val DATA_TIMESTAMP = "timestamp" - private const val DATA_ERROR = "error" - private const val DATA_INDETERMINATE = "indeterminate" - private const val DATA_PAUSED = "paused" - - fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L) - - fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false) - - fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false) - - fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0) - - fun getError(data: Data): String? = data.getString(DATA_ERROR) - - fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0) - - fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L) - - fun getTimestamp(data: Data): Instant = Instant.ofEpochMilli(data.getLong(DATA_TIMESTAMP, 0L)) - - fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt deleted file mode 100644 index ae9bf076a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOption.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.download.ui.dialog - -import android.content.res.Resources -import androidx.annotation.DrawableRes -import org.koitharu.kotatsu.R -import java.util.Locale -import com.google.android.material.R as materialR - -sealed interface DownloadOption { - - val chaptersIds: Set - - @get:DrawableRes - val iconResId: Int - - val chaptersCount: Int - get() = chaptersIds.size - - fun getLabel(resources: Resources): CharSequence - - class AllChapters( - val branch: String, - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_select_group - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_all_chapters, branch) - } - } - - class WholeManga( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = materialR.drawable.abc_ic_menu_selectall_mtrl_alpha - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_whole_manga) - } - } - - class FirstChapters( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_start - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString( - R.string.download_option_first_n_chapters, - resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) - .lowercase(Locale.getDefault()), - ) - } - } - - class AllUnreadChapters( - override val chaptersIds: Set, - val branch: String?, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_end - - override fun getLabel(resources: Resources): CharSequence { - return if (branch == null) { - resources.getString(R.string.download_option_all_unread) - } else { - resources.getString(R.string.download_option_all_unread_b, branch) - } - } - } - - class NextUnreadChapters( - override val chaptersIds: Set, - ) : DownloadOption { - - override val iconResId = R.drawable.ic_list_next - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString( - R.string.download_option_next_unread_n_chapters, - resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount) - .lowercase(Locale.getDefault()), - ) - } - } - - class SelectionHint : DownloadOption { - - override val chaptersIds: Set = emptySet() - override val iconResId = R.drawable.ic_tap - - override fun getLabel(resources: Resources): CharSequence { - return resources.getString(R.string.download_option_manual_selection) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt deleted file mode 100644 index 3a277787f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadOptionAD.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.download.ui.dialog - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.ItemDownloadOptionBinding - -fun downloadOptionAD( - onClickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemDownloadOptionBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener { v -> onClickListener.onItemClick(item, v) } - - bind { - with(binding.root) { - title = item.getLabel(resources) - subtitle = if (item.chaptersCount == 0) null else resources.getQuantityString( - R.plurals.chapters, - item.chaptersCount, - item.chaptersCount, - ) - setIconResource(item.iconResId) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt deleted file mode 100644 index 271b3090d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ /dev/null @@ -1,199 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import android.view.View -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.work.WorkInfo -import coil.ImageLoader -import coil.request.SuccessResult -import coil.util.CoilUtils -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.image.TrimTransformation -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemDownloadBinding -import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter -import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.util.format - -fun downloadItemAD( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - listener: DownloadItemListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, -) { - - val percentPattern = context.resources.getString(R.string.percent_string_pattern) - var chaptersJob: Job? = null - - val clickListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) { - when (v.id) { - R.id.button_cancel -> listener.onCancelClick(item) - R.id.button_resume -> listener.onResumeClick(item, skip = false) - R.id.button_skip -> listener.onResumeClick(item, skip = true) - R.id.button_pause -> listener.onPauseClick(item) - R.id.imageView_expand -> listener.onExpandClick(item) - else -> listener.onItemClick(item, v) - } - } - - override fun onLongClick(v: View): Boolean { - return listener.onItemLongClick(item, v) - } - } - val chaptersAdapter = BaseListAdapter() - .addDelegate(ListItemType.CHAPTER, downloadChapterAD()) - - binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) - binding.recyclerViewChapters.adapter = chaptersAdapter - binding.buttonCancel.setOnClickListener(clickListener) - binding.buttonPause.setOnClickListener(clickListener) - binding.buttonResume.setOnClickListener(clickListener) - binding.buttonSkip.setOnClickListener(clickListener) - binding.imageViewExpand.setOnClickListener(clickListener) - itemView.setOnClickListener(clickListener) - itemView.setOnLongClickListener(clickListener) - - fun scrollToCurrentChapter() { - val rv = binding.recyclerViewChapters - if (!rv.isVisible) { - return - } - val chapters = chaptersAdapter.items - if (chapters.isEmpty()) { - return - } - val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices) - (rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3) - } - - bind { payloads -> - binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown) - if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) { - binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - transformations(TrimTransformation()) - memoryCacheKey(item.coverCacheKey) - source(item.manga?.source) - enqueueWith(coil) - } - } - if (chaptersJob == null || payloads.isEmpty()) { - chaptersJob?.cancel() - chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) { - item.chapters.collect { chapters -> - binding.imageViewExpand.isGone = chapters.isNullOrEmpty() - chaptersAdapter.emit(chapters) - scrollToCurrentChapter() - } - } - } else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) { - binding.recyclerViewChapters.post { - scrollToCurrentChapter() - } - } - binding.imageViewExpand.isChecked = item.isExpanded - binding.recyclerViewChapters.isVisible = item.isExpanded - when (item.workState) { - WorkInfo.State.ENQUEUED, - WorkInfo.State.BLOCKED -> { - binding.textViewStatus.setText(R.string.queued) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.progressBar.isEnabled = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = false - binding.buttonSkip.isVisible = false - binding.buttonPause.isVisible = false - } - - WorkInfo.State.RUNNING -> { - binding.textViewStatus.setText( - if (item.isPaused) R.string.paused else R.string.manga_downloading_, - ) - binding.progressBar.isIndeterminate = item.isIndeterminate - binding.progressBar.isVisible = true - binding.progressBar.max = item.max - binding.progressBar.isEnabled = !item.isPaused - binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty()) - binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1)) - binding.textViewPercent.isVisible = true - binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString() - binding.buttonCancel.isVisible = true - binding.buttonResume.isVisible = item.isPaused - binding.buttonSkip.isVisible = item.isPaused && item.error != null - binding.buttonPause.isVisible = item.canPause - } - - WorkInfo.State.SUCCEEDED -> { - binding.textViewStatus.setText(R.string.download_complete) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.progressBar.isEnabled = true - binding.textViewPercent.isVisible = false - if (item.chaptersDownloaded > 0) { - binding.textViewDetails.text = context.resources.getQuantityString( - R.plurals.chapters, - item.chaptersDownloaded, - item.chaptersDownloaded, - ) - binding.textViewDetails.isVisible = true - } else { - binding.textViewDetails.isVisible = false - } - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - binding.buttonSkip.isVisible = false - binding.buttonPause.isVisible = false - } - - WorkInfo.State.FAILED -> { - binding.textViewStatus.setText(R.string.error_occurred) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.progressBar.isEnabled = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.textAndVisible = item.error - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - binding.buttonSkip.isVisible = false - binding.buttonPause.isVisible = false - } - - WorkInfo.State.CANCELLED -> { - binding.textViewStatus.setText(R.string.canceled) - binding.progressBar.isIndeterminate = false - binding.progressBar.isVisible = false - binding.progressBar.isEnabled = true - binding.textViewPercent.isVisible = false - binding.textViewDetails.isVisible = false - binding.buttonCancel.isVisible = false - binding.buttonResume.isVisible = false - binding.buttonSkip.isVisible = false - binding.buttonPause.isVisible = false - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt deleted file mode 100644 index 449911419..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener - -interface DownloadItemListener : OnListItemClickListener { - - fun onCancelClick(item: DownloadItemModel) - - fun onPauseClick(item: DownloadItemModel) - - fun onResumeClick(item: DownloadItemModel, skip: Boolean) - - fun onExpandClick(item: DownloadItemModel) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt deleted file mode 100644 index 2cd973695..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import android.text.format.DateUtils -import androidx.work.WorkInfo -import coil.memory.MemoryCache -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga -import java.time.Instant -import java.util.UUID - -data class DownloadItemModel( - val id: UUID, - val workState: WorkInfo.State, - val isIndeterminate: Boolean, - val isPaused: Boolean, - val manga: Manga?, - val error: String?, - val max: Int, - val progress: Int, - val eta: Long, - val timestamp: Instant, - val chaptersDownloaded: Int, - val isExpanded: Boolean, - val chapters: StateFlow?>, -) : ListModel, Comparable { - - val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), mapOf("dl" to "1")) - - val percent: Float - get() = if (max > 0) progress / max.toFloat() else 0f - - val hasEta: Boolean - get() = workState == WorkInfo.State.RUNNING && !isPaused && eta > 0L - - val canPause: Boolean - get() = workState == WorkInfo.State.RUNNING && !isPaused && error == null - - val canResume: Boolean - get() = workState == WorkInfo.State.RUNNING && isPaused - - fun getEtaString(): CharSequence? = if (hasEta) { - DateUtils.getRelativeTimeSpanString( - eta, - System.currentTimeMillis(), - DateUtils.SECOND_IN_MILLIS, - ) - } else { - null - } - - override fun compareTo(other: DownloadItemModel): Int { - return timestamp.compareTo(other.timestamp) - } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is DownloadItemModel && other.id == id - } - - override fun getChangePayload(previousState: ListModel): Any? = when { - previousState !is DownloadItemModel -> super.getChangePayload(previousState) - workState != previousState.workState -> null - isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED - } -} 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 deleted file mode 100644 index f0b51a621..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper -import org.koitharu.kotatsu.core.ui.util.MenuInvalidator -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.PausingReceiver -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import javax.inject.Inject - -@AndroidEntryPoint -class DownloadsActivity : BaseActivity(), - DownloadItemListener, - ListSelectionController.Callback2 { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel by viewModels() - private lateinit var selectionController: ListSelectionController - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val downloadsAdapter = DownloadsAdapter(this, coil, this) - val decoration = TypedListSpacingDecoration(this, false) - selectionController = ListSelectionController( - activity = this, - decoration = DownloadsSelectionDecoration(this), - registryOwner = this, - callback = this, - ) - with(viewBinding.recyclerView) { - setHasFixedSize(true) - addItemDecoration(decoration) - adapter = downloadsAdapter - selectionController.attachToRecyclerView(this) - RecyclerScrollKeeper(this).attach() - } - addMenuProvider(DownloadsMenuProvider(this, viewModel)) - viewModel.items.observe(this) { - downloadsAdapter.items = it - } - viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView)) - val menuInvalidator = MenuInvalidator(this) - viewModel.hasActiveWorks.observe(this, menuInvalidator) - viewModel.hasPausedWorks.observe(this, menuInvalidator) - viewModel.hasCancellableWorks.observe(this, menuInvalidator) - } - - override fun onWindowInsetsChanged(insets: Insets) { - val rv = viewBinding.recyclerView - rv.updatePadding( - left = insets.left + rv.paddingTop, - right = insets.right + rv.paddingTop, - bottom = insets.bottom, - ) - viewBinding.toolbar.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - override fun onItemClick(item: DownloadItemModel, view: View) { - if (selectionController.onItemClick(item.id.mostSignificantBits)) { - return - } - startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return)) - } - - override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { - return selectionController.onItemLongClick(item.id.mostSignificantBits) - } - - override fun onExpandClick(item: DownloadItemModel) { - if (!selectionController.onItemClick(item.id.mostSignificantBits)) { - viewModel.expandCollapse(item) - } - } - - override fun onCancelClick(item: DownloadItemModel) { - viewModel.cancel(item.id) - } - - override fun onPauseClick(item: DownloadItemModel) { - sendBroadcast(PausingReceiver.getPauseIntent(this, item.id)) - } - - override fun onResumeClick(item: DownloadItemModel, skip: Boolean) { - sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip)) - } - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - viewBinding.recyclerView.invalidateItemDecorations() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_downloads, menu) - return true - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_resume -> { - viewModel.resume(controller.snapshot()) - mode.finish() - true - } - - R.id.action_pause -> { - viewModel.pause(controller.snapshot()) - mode.finish() - true - } - - R.id.action_cancel -> { - viewModel.cancel(controller.snapshot()) - mode.finish() - true - } - - R.id.action_remove -> { - viewModel.remove(controller.snapshot()) - mode.finish() - true - } - - R.id.action_select_all -> { - controller.addAll(viewModel.allIds()) - true - } - - else -> false - } - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - val snapshot = viewModel.snapshot(controller.peekCheckedIds()) - var canPause = true - var canResume = true - var canCancel = true - var canRemove = true - for (item in snapshot) { - canPause = canPause and item.canPause - canResume = canResume and item.canResume - canCancel = canCancel and !item.workState.isFinished - canRemove = canRemove and item.workState.isFinished - } - menu.findItem(R.id.action_pause)?.isVisible = canPause - menu.findItem(R.id.action_resume)?.isVisible = canResume - menu.findItem(R.id.action_cancel)?.isVisible = canCancel - menu.findItem(R.id.action_remove)?.isVisible = canRemove - return super.onPrepareActionMode(controller, mode, menu) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt deleted file mode 100644 index ea30e09f7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsAdapter.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD -import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD -import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD -import org.koitharu.kotatsu.list.ui.model.ListModel - -class DownloadsAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - listener: DownloadItemListener, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.DOWNLOAD, downloadItemAD(lifecycleOwner, coil, listener)) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null)) - addDelegate(ListItemType.HEADER, listHeaderAD(null)) - } -} 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 deleted file mode 100644 index 3429ee2c9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt +++ /dev/null @@ -1,68 +0,0 @@ -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 com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.settings.SettingsActivity - -class DownloadsMenuProvider( - private val context: Context, - private val viewModel: DownloadsViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_downloads, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.action_pause -> viewModel.pauseAll() - 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)) - } - - else -> return false - } - return true - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - menu.findItem(R.id.action_pause)?.isVisible = viewModel.hasActiveWorks.value == true - menu.findItem(R.id.action_resume)?.isVisible = viewModel.hasPausedWorks.value == true - menu.findItem(R.id.action_cancel_all)?.isVisible = viewModel.hasCancellableWorks.value == true - } - - private fun confirmCancelAll() { - MaterialAlertDialogBuilder( - context, - com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered, - ).setTitle(R.string.cancel_all) - .setMessage(R.string.cancel_all_downloads_confirm) - .setIcon(R.drawable.ic_cancel_multiple) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.confirm) { _, _ -> - viewModel.cancelAll() - }.show() - } - - private fun confirmRemoveCompleted() { - MaterialAlertDialogBuilder( - context, - com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered, - ).setTitle(R.string.remove_completed) - .setMessage(R.string.remove_completed_downloads_confirm) - .setIcon(R.drawable.ic_clear_all) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.removeCompleted() - }.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt deleted file mode 100644 index eb47bc515..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsSelectionDecoration.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.View -import androidx.cardview.widget.CardView -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_ID -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration -import org.koitharu.kotatsu.core.util.ext.getItem -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import com.google.android.material.R as materialR - -class DownloadsSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) - private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_offset) - private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.card_indicator_size) - private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) - private val fillColor = ColorUtils.setAlphaComponent( - ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), - 0x74, - ) - private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner) - - init { - hasBackground = false - hasForeground = true - isIncludeDecorAndMargins = false - - paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) - checkIcon?.setTint(strokeColor) - } - - override fun getItemId(parent: RecyclerView, child: View): Long { - val holder = parent.getChildViewHolder(child) ?: return NO_ID - val item = holder.getItem(DownloadItemModel::class.java) ?: return NO_ID - return item.id.mostSignificantBits - } - - override fun onDrawForeground( - canvas: Canvas, - parent: RecyclerView, - child: View, - bounds: RectF, - state: RecyclerView.State, - ) { - val isCard = child is CardView - val radius = (child as? CardView)?.radius ?: defaultRadius - paint.color = fillColor - paint.style = Paint.Style.FILL - canvas.drawRoundRect(bounds, radius, radius, paint) - paint.color = strokeColor - paint.style = Paint.Style.STROKE - canvas.drawRoundRect(bounds, radius, radius, paint) - if (isCard) { - checkIcon?.run { - setBounds( - (bounds.right - iconSize - iconOffset).toInt(), - (bounds.top + iconOffset).toInt(), - (bounds.right - iconOffset).toInt(), - (bounds.top + iconOffset + iconSize).toInt(), - ) - draw(canvas) - } - } - } -} 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 deleted file mode 100644 index 96a884748..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ /dev/null @@ -1,329 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list - -import androidx.collection.ArrayMap -import androidx.collection.LongSparseArray -import androidx.collection.getOrElse -import androidx.collection.set -import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.isEmpty -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.LinkedList -import java.util.UUID -import javax.inject.Inject - -@HiltViewModel -class DownloadsViewModel @Inject constructor( - private val workScheduler: DownloadWorker.Scheduler, - private val mangaDataRepository: MangaDataRepository, - private val mangaRepositoryFactory: MangaRepository.Factory, - @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, - private val localMangaRepository: LocalMangaRepository, -) : BaseViewModel() { - - private val mangaCache = LongSparseArray() - private val cacheMutex = Mutex() - private val expanded = MutableStateFlow(emptySet()) - private val chaptersCache = ArrayMap?>>() - - private val works = combine( - workScheduler.observeWorks(), - expanded, - ) { list, exp -> - list.toDownloadsList(exp) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val onActionDone = MutableEventFlow() - - val items = works.map { - it?.toUiList() ?: listOf(LoadingState) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - val hasPausedWorks = works.map { - it?.any { x -> x.canResume } == true - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) - - val hasActiveWorks = works.map { - it?.any { x -> x.canPause } == true - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) - - val hasCancellableWorks = works.map { - it?.any { x -> !x.workState.isFinished } == true - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), false) - - fun cancel(id: UUID) { - launchJob(Dispatchers.Default) { - workScheduler.cancel(id) - } - } - - fun cancel(ids: Set) { - launchJob(Dispatchers.Default) { - val snapshot = works.value ?: return@launchJob - for (work in snapshot) { - if (work.id.mostSignificantBits in ids) { - workScheduler.cancel(work.id) - } - } - onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) - } - } - - fun cancelAll() { - launchJob(Dispatchers.Default) { - workScheduler.cancelAll() - onActionDone.call(ReversibleAction(R.string.downloads_cancelled, null)) - } - } - - fun pause(ids: Set) { - val snapshot = works.value ?: return - for (work in snapshot) { - if (work.id.mostSignificantBits in ids) { - workScheduler.pause(work.id) - } - } - onActionDone.call(ReversibleAction(R.string.downloads_paused, null)) - } - - fun pauseAll() { - val snapshot = works.value ?: return - var isPaused = false - for (work in snapshot) { - if (work.canPause) { - workScheduler.pause(work.id) - isPaused = true - } - } - if (isPaused) { - onActionDone.call(ReversibleAction(R.string.downloads_paused, null)) - } - } - - fun resumeAll() { - val snapshot = works.value ?: return - var isResumed = false - for (work in snapshot) { - if (work.workState == WorkInfo.State.RUNNING && work.isPaused) { - workScheduler.resume(work.id, skipError = false) - isResumed = true - } - } - if (isResumed) { - onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) - } - } - - fun resume(ids: Set) { - val snapshot = works.value ?: return - for (work in snapshot) { - if (work.id.mostSignificantBits in ids) { - workScheduler.resume(work.id, skipError = false) - } - } - onActionDone.call(ReversibleAction(R.string.downloads_resumed, null)) - } - - fun remove(ids: Set) { - launchJob(Dispatchers.Default) { - val snapshot = works.value ?: return@launchJob - val uuids = HashSet(ids.size) - for (work in snapshot) { - if (work.id.mostSignificantBits in ids) { - uuids.add(work.id) - } - } - workScheduler.delete(uuids) - onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) - } - } - - fun removeCompleted() { - launchJob(Dispatchers.Default) { - workScheduler.removeCompleted() - onActionDone.call(ReversibleAction(R.string.downloads_removed, null)) - } - } - - fun snapshot(ids: Set): Collection { - return works.value?.filterTo(ArrayList(ids.size)) { x -> x.id.mostSignificantBits in ids }.orEmpty() - } - - fun allIds(): Set = works.value?.mapToSet { - it.id.mostSignificantBits - } ?: emptySet() - - fun expandCollapse(item: DownloadItemModel) { - expanded.update { - if (item.id in it) { - it - item.id - } else { - it + item.id - } - } - } - - private suspend fun List.toDownloadsList(exp: Set): List { - if (isEmpty()) { - return emptyList() - } - val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) } - list.sortByDescending { it.timestamp } - return list - } - - private fun List.toUiList(): List { - if (isEmpty()) { - return emptyStateList() - } - val queued = LinkedList() - val running = LinkedList() - val destination = ArrayDeque((size * 1.4).toInt()) - var prevDate: DateTimeAgo? = null - for (item in this) { - when (item.workState) { - WorkInfo.State.RUNNING -> running += item - WorkInfo.State.BLOCKED, - WorkInfo.State.ENQUEUED -> queued += item - - else -> { - val date = calculateTimeAgo(item.timestamp) - if (prevDate != date) { - destination += ListHeader(date) - } - prevDate = date - destination += item - } - } - } - if (running.isNotEmpty()) { - running.addFirst(ListHeader(R.string.in_progress)) - } - destination.addAll(0, running) - if (queued.isNotEmpty()) { - queued.addFirst(ListHeader(R.string.queued)) - } - destination.addAll(0, queued) - return destination - } - - private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? { - val workData = outputData.takeUnless { it.isEmpty } - ?: progress.takeUnless { it.isEmpty } - ?: workScheduler.getInputData(id) - ?: return null - val mangaId = DownloadState.getMangaId(workData) - if (mangaId == 0L) return null - val manga = getManga(mangaId) ?: return null - val chapters = synchronized(chaptersCache) { - chaptersCache.getOrPut(id) { - observeChapters(manga, id) - } - } - return DownloadItemModel( - id = id, - workState = state, - manga = manga, - error = DownloadState.getError(workData), - isIndeterminate = DownloadState.isIndeterminate(workData), - isPaused = DownloadState.isPaused(workData), - max = DownloadState.getMax(workData), - progress = DownloadState.getProgress(workData), - eta = DownloadState.getEta(workData), - timestamp = DownloadState.getTimestamp(workData), - chaptersDownloaded = DownloadState.getDownloadedChapters(workData), - isExpanded = isExpanded, - chapters = chapters, - ) - } - - private fun emptyStateList() = listOf( - EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.text_downloads_list_holder, - textSecondary = 0, - actionStringRes = 0, - ), - ) - - private suspend fun getManga(mangaId: Long): Manga? { - mangaCache[mangaId]?.let { - return it - } - return cacheMutex.withLock { - mangaCache.getOrElse(mangaId) { - mangaDataRepository.findMangaById(mangaId)?.also { - mangaCache[mangaId] = it - } ?: return null - } - } - } - - private fun observeChapters(manga: Manga, workId: UUID): StateFlow?> = flow { - val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet() - val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow - - suspend fun mapChapters(): List { - val size = chapterIds?.size ?: chapters.size - val localChapters = - localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty() - return chapters.mapNotNullTo(ArrayList(size)) { - if (chapterIds == null || it.id in chapterIds) { - DownloadChapter( - number = it.number, - name = it.name, - isDownloaded = it.id in localChapters, - ) - } else { - null - } - } - } - emit(mapChapters()) - localStorageChanges.collect { - if (it?.manga?.id == manga.id) { - emit(mapChapters()) - } - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - private suspend fun tryLoad(manga: Manga) = runCatchingCancellable { - (mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga) - }.getOrNull() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt deleted file mode 100644 index a9e2b3577..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list.chapters - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class DownloadChapter( - val number: Int, - val name: String, - val isDownloaded: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is DownloadChapter && other.name == name - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) { - ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt deleted file mode 100644 index 30ebff4ef..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/chapters/DownloadChapterAD.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.download.ui.list.chapters - -import androidx.core.content.ContextCompat -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.drawableEnd -import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding - -fun downloadChapterAD() = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) }, -) { - - val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check) - - bind { - binding.textViewNumber.text = item.number.toString() - binding.textViewTitle.text = item.name - binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else 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 deleted file mode 100644 index aa23dc794..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ /dev/null @@ -1,274 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -import android.app.Notification -import android.app.PendingIntent -import android.content.Context -import android.graphics.drawable.Drawable -import android.text.format.DateUtils -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.work.WorkManager -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow -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.model.MangaSource -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 - -private const val CHANNEL_ID = "download" -private const val GROUP_ID = "downloads" - -class DownloadNotificationFactory @AssistedInject constructor( - @ApplicationContext private val context: Context, - private val workManager: WorkManager, - private val coil: ImageLoader, - @Assisted private val uuid: UUID, -) { - - private val covers = HashMap() - private val builder = NotificationCompat.Builder(context, CHANNEL_ID) - private val mutex = Mutex() - - private val coverWidth = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_width, - ) - private val coverHeight = context.resources.getDimensionPixelSize( - androidx.core.R.dimen.compat_notification_large_icon_max_height, - ) - private val queueIntent = PendingIntentCompat.getActivity( - context, - 0, - DownloadsActivity.newIntent(context), - 0, - false, - ) - - private val actionCancel by lazy { - NotificationCompat.Action( - materialR.drawable.material_ic_clear_black_24dp, - context.getString(android.R.string.cancel), - workManager.createCancelPendingIntent(uuid), - ) - } - - private val actionPause by lazy { - NotificationCompat.Action( - R.drawable.ic_action_pause, - context.getString(R.string.pause), - PausingReceiver.createPausePendingIntent(context, uuid), - ) - } - - private val actionResume by lazy { - NotificationCompat.Action( - R.drawable.ic_action_resume, - context.getString(R.string.resume), - PausingReceiver.createResumePendingIntent(context, uuid, skipError = false), - ) - } - - private val actionSkip by lazy { - NotificationCompat.Action( - R.drawable.ic_action_skip, - context.getString(R.string.skip), - PausingReceiver.createResumePendingIntent(context, uuid, skipError = true), - ) - } - - init { - createChannel() - builder.setOnlyAlertOnce(true) - builder.setDefaults(0) - builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE - builder.setSilent(true) - builder.setGroup(GROUP_ID) - builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - builder.priority = NotificationCompat.PRIORITY_DEFAULT - } - - suspend fun create(state: DownloadState?): Notification = mutex.withLock { - if (state == null) { - builder.setContentTitle(context.getString(R.string.manga_downloading_)) - builder.setContentText(context.getString(R.string.preparing_)) - } else { - builder.setContentTitle(state.manga.title) - builder.setContentText(context.getString(R.string.manga_downloading_)) - } - builder.setProgress(1, 0, true) - builder.setSmallIcon(android.R.drawable.stat_sys_download) - builder.setContentIntent(queueIntent) - builder.setStyle(null) - builder.setLargeIcon(if (state != null) getCover(state.manga)?.toBitmap() else null) - builder.clearActions() - builder.setSubText(null) - builder.setShowWhen(false) - builder.setVisibility( - if (state != null && state.manga.isNsfw) { - NotificationCompat.VISIBILITY_PRIVATE - } else { - NotificationCompat.VISIBILITY_PUBLIC - }, - ) - when { - state == null -> Unit - state.localManga != null -> { // downloaded, final state - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.download_complete)) - builder.setContentIntent(createMangaIntent(context, state.localManga.manga)) - builder.setAutoCancel(true) - builder.setSmallIcon(android.R.drawable.stat_sys_download_done) - builder.setCategory(null) - builder.setStyle(null) - builder.setOngoing(false) - builder.setShowWhen(true) - builder.setWhen(System.currentTimeMillis()) - } - - state.isStopped -> { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.queued)) - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - builder.setStyle(null) - builder.setOngoing(true) - builder.setSmallIcon(R.drawable.ic_stat_paused) - builder.addAction(actionCancel) - } - - state.isPaused -> { // paused (with error or manually) - builder.setProgress(state.max, state.progress, false) - val percent = if (state.percent >= 0) { - context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - } else { - null - } - if (state.error != null) { - builder.setContentText(context.getString(R.string.download_summary_pattern, percent, state.error)) - } else { - builder.setContentText(percent) - } - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - builder.setStyle(null) - builder.setOngoing(true) - builder.setSmallIcon(R.drawable.ic_stat_paused) - builder.addAction(actionCancel) - builder.addAction(actionResume) - if (state.error != null) { - builder.addAction(actionSkip) - } - } - - state.error != null -> { // error, final state - builder.setProgress(0, 0, false) - builder.setSmallIcon(android.R.drawable.stat_notify_error) - builder.setSubText(context.getString(R.string.error)) - builder.setContentText(state.error) - builder.setAutoCancel(true) - builder.setOngoing(false) - builder.setCategory(NotificationCompat.CATEGORY_ERROR) - builder.setShowWhen(true) - builder.setWhen(System.currentTimeMillis()) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error)) - } - - else -> { - builder.setProgress(state.max, state.progress, false) - builder.setContentText(getProgressString(state.percent, state.eta)) - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(actionCancel) - builder.addAction(actionPause) - } - } - return builder.build() - } - - private fun getProgressString(percent: Float, eta: Long): CharSequence? { - val percentString = if (percent >= 0f) { - context.getString(R.string.percent_string_pattern, (percent * 100).format()) - } else { - null - } - val etaString = if (eta > 0L) { - DateUtils.getRelativeTimeSpanString( - eta, - System.currentTimeMillis(), - DateUtils.SECOND_IN_MILLIS, - ) - } else { - null - } - return when { - percentString == null && etaString == null -> null - percentString != null && etaString == null -> percentString - percentString == null && etaString != null -> etaString - else -> context.getString(R.string.download_summary_pattern, percentString, etaString) - } - } - - private fun createMangaIntent(context: Context, manga: Manga?) = PendingIntentCompat.getActivity( - context, - manga.hashCode(), - if (manga != null) { - DetailsActivity.newIntent(context, manga) - } else { - MangaListActivity.newIntent(context, MangaSource.LOCAL) - }, - PendingIntent.FLAG_CANCEL_CURRENT, - false, - ) - - private suspend fun getCover(manga: Manga) = covers[manga] ?: run { - runCatchingCancellable { - coil.execute( - ImageRequest.Builder(context) - .data(manga.coverUrl) - .allowHardware(false) - .tag(manga.source) - .size(coverWidth, coverHeight) - .scale(Scale.FILL) - .build(), - ).getDrawableOrThrow() - }.onSuccess { - covers[manga] = it - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - } - - private fun createChannel() { - val manager = NotificationManagerCompat.from(context) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(R.string.downloads)) - .setVibrationEnabled(false) - .setLightsEnabled(false) - .setSound(null, null) - .build() - manager.createNotificationChannel(channel) - } - - @AssistedFactory - interface Factory { - - fun create(uuid: UUID): DownloadNotificationFactory - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt deleted file mode 100644 index e59722045..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadSlowdownDispatcher.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -import kotlinx.coroutines.delay -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.parsers.model.MangaSource - -class DownloadSlowdownDispatcher( - private val mangaRepositoryFactory: MangaRepository.Factory, - private val defaultDelay: Long, -) { - private val timeMap = HashMap() - - suspend fun delay(source: MangaSource) { - val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return - if (!repo.isSlowdownEnabled()) { - return - } - val lastRequest = synchronized(timeMap) { - val res = timeMap[source] ?: 0L - timeMap[source] = System.currentTimeMillis() - res - } - if (lastRequest != 0L) { - delay(lastRequest + defaultDelay - System.currentTimeMillis()) - } - } -} 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 deleted file mode 100644 index 6c42b9275..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -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.util.ext.findActivity -import org.koitharu.kotatsu.download.ui.list.DownloadsActivity -import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner - -class DownloadStartedObserver( - private val snackbarHost: View, -) : FlowCollector { - - override suspend fun emit(value: Unit) { - val snackbar = Snackbar.make(snackbarHost, R.string.download_started, Snackbar.LENGTH_LONG) - (snackbarHost.context.findActivity() as? BottomNavOwner)?.let { - snackbar.anchorView = it.bottomNav - } - snackbar.setAction(R.string.details) { - it.context.startActivity(DownloadsActivity.newIntent(it.context)) - } - 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 deleted file mode 100644 index 64b5ef7de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ /dev/null @@ -1,528 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -import android.annotation.SuppressLint -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 -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.await -import dagger.Reusable -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.internal.closeQuietly -import okio.IOException -import okio.buffer -import okio.sink -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions -import org.koitharu.kotatsu.core.model.ids -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.network.MangaHttpClient -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.Throttler -import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag -import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork -import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag -import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.core.util.ext.deleteWork -import org.koitharu.kotatsu.core.util.ext.deleteWorks -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.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.core.util.progress.TimeLeftEstimator -import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.data.TempFileFilter -import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.local.data.output.LocalMangaOutput -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.io.File -import java.util.UUID -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import javax.inject.Inject - -@HiltWorker -class DownloadWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - @MangaHttpClient private val okHttp: OkHttpClient, - private val cache: PagesCache, - private val localMangaRepository: LocalMangaRepository, - private val mangaDataRepository: MangaDataRepository, - private val mangaRepositoryFactory: MangaRepository.Factory, - @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, - notificationFactoryFactory: DownloadNotificationFactory.Factory, -) : CoroutineWorker(appContext, params) { - - private val notificationFactory = notificationFactoryFactory.create(params.id) - private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY) - - @Volatile - private var lastPublishedState: DownloadState? = null - private val currentState: DownloadState - get() = checkNotNull(lastPublishedState) - - private val timeLeftEstimator = TimeLeftEstimator() - private val notificationThrottler = Throttler(400) - - override suspend fun doWork(): Result { - setForeground(getForegroundInfo()) - val mangaId = inputData.getLong(MANGA_ID, 0L) - val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() - lastPublishedState = DownloadState(manga, isIndeterminate = true) - publishState(DownloadState(manga, isIndeterminate = true)) - val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } - val downloadedIds = getDoneChapters(manga) - return try { - withContext(PausingHandle()) { - downloadMangaImpl(manga, chaptersIds, downloadedIds) - } - Result.success(currentState.toWorkData()) - } catch (e: CancellationException) { - withContext(NonCancellable) { - val notification = notificationFactory.create(currentState.copy(isStopped = true)) - notificationManager.notify(id.hashCode(), notification) - } - Result.failure( - currentState.copy(eta = -1L).toWorkData(), - ) - } catch (e: IOException) { - e.printStackTraceDebug() - Result.retry() - } catch (e: Exception) { - e.printStackTraceDebug() - Result.failure( - currentState.copy( - error = e.getDisplayMessage(applicationContext.resources), - eta = -1L, - ).toWorkData(), - ) - } finally { - notificationManager.cancel(id.hashCode()) - } - } - - override suspend fun getForegroundInfo() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo( - id.hashCode(), - notificationFactory.create(lastPublishedState), - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } else { - ForegroundInfo( - id.hashCode(), - notificationFactory.create(lastPublishedState), - ) - } - - private suspend fun downloadMangaImpl( - subject: Manga, - includedIds: LongArray?, - excludedIds: Set, - ) { - var manga = subject - val chaptersToSkip = excludedIds.toMutableSet() - val pausingReceiver = PausingReceiver(id, PausingHandle.current()) - withMangaLock(manga) { - ContextCompat.registerReceiver( - applicationContext, - pausingReceiver, - PausingReceiver.createIntentFilter(id), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - val destination = localMangaRepository.getOutputDir(manga) - checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } - var output: LocalMangaOutput? = null - try { - if (manga.source == MangaSource.LOCAL) { - manga = localMangaRepository.getRemoteManga(manga) - ?: error("Cannot obtain remote manga instance") - } - val repo = mangaRepositoryFactory.create(manga.source) - val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga - output = LocalMangaOutput.getOrCreate(destination, mangaDetails) - val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } - if (coverUrl.isNotEmpty()) { - downloadFile(coverUrl, destination, repo.source).let { file -> - output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) - file.deleteAwait() - } - } - val chapters = getChapters(mangaDetails, includedIds) - for ((chapterIndex, chapter) in chapters.withIndex()) { - checkIsPaused() - if (chaptersToSkip.remove(chapter.id)) { - publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) - continue - } - val pages = runFailsafe { - repo.getPages(chapter) - } ?: continue - val pageCounter = AtomicInteger(0) - channelFlow { - val semaphore = Semaphore(MAX_PAGES_PARALLELISM) - for ((pageIndex, page) in pages.withIndex()) { - checkIsPaused() - launch { - semaphore.withPermit { - runFailsafe { - val url = repo.getPageUrl(page) - val file = cache.get(url) - ?: downloadFile(url, destination, repo.source) - output.addPage( - chapter = chapter, - file = file, - pageNumber = pageIndex, - ext = MimeTypeMap.getFileExtensionFromUrl(url), - ) - if (file.extension == "tmp") { - file.deleteAwait() - } - } - send(pageIndex) - } - } - } - }.collect { - publishState( - currentState.copy( - totalChapters = chapters.size, - currentChapter = chapterIndex, - totalPages = pages.size, - currentPage = pageCounter.incrementAndGet(), - isIndeterminate = false, - eta = timeLeftEstimator.getEta(), - ), - ) - } - if (output.flushChapter(chapter)) { - runCatchingCancellable { - localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) - }.onFailure(Throwable::printStackTraceDebug) - } - publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) - } - publishState(currentState.copy(isIndeterminate = true, eta = -1L)) - output.mergeWithExisting() - output.finish() - val localManga = LocalMangaInput.of(output.rootFile).getManga() - localStorageChanges.emit(localManga) - publishState(currentState.copy(localManga = localManga, eta = -1L)) - } catch (e: Exception) { - if (e !is CancellationException) { - publishState(currentState.copy(error = e.getDisplayMessage(applicationContext.resources))) - } - throw e - } finally { - withContext(NonCancellable) { - applicationContext.unregisterReceiver(pausingReceiver) - output?.closeQuietly() - output?.cleanup() - destination.listFiles(TempFileFilter())?.forEach { - it.deleteAwait() - } - } - } - } - } - - private suspend fun runFailsafe( - block: suspend () -> R, - ): R? { - checkIsPaused() - var countDown = MAX_FAILSAFE_ATTEMPTS - failsafe@ while (true) { - try { - return block() - } catch (e: IOException) { - if (countDown <= 0) { - publishState( - currentState.copy( - isPaused = true, - error = e.getDisplayMessage(applicationContext.resources), - eta = -1L, - ), - ) - countDown = MAX_FAILSAFE_ATTEMPTS - val pausingHandle = PausingHandle.current() - pausingHandle.pause() - try { - pausingHandle.awaitResumed() - if (pausingHandle.skipCurrentError()) { - return null - } - } finally { - publishState(currentState.copy(isPaused = false, error = null)) - } - } else { - countDown-- - val retryDelay = if (e is TooManyRequestExceptions) { - e.retryAfter + DOWNLOAD_ERROR_DELAY - } else { - DOWNLOAD_ERROR_DELAY - } - delay(retryDelay) - } - } - } - } - - private suspend fun checkIsPaused() { - val pausingHandle = PausingHandle.current() - if (pausingHandle.isPaused) { - publishState(currentState.copy(isPaused = true, eta = -1L)) - try { - pausingHandle.awaitResumed() - } finally { - publishState(currentState.copy(isPaused = false)) - } - } - } - - private suspend fun downloadFile( - url: String, - destination: File, - source: MangaSource, - ): File { - val request = Request.Builder() - .url(url) - .tag(MangaSource::class.java, source) - .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) - .get() - .build() - slowdownDispatcher.delay(source) - val call = okHttp.newCall(request) - val file = File(destination, UUID.randomUUID().toString() + ".tmp") - try { - val response = call.clone().await() - checkNotNull(response.body).use { body -> - file.sink(append = false).buffer().use { - it.writeAllCancellable(body.source()) - } - } - } catch (e: CancellationException) { - file.delete() - throw e - } - return file - } - - private suspend fun publishState(state: DownloadState) { - val previousState = currentState - lastPublishedState = state - if (previousState.isParticularProgress && state.isParticularProgress) { - timeLeftEstimator.tick(state.progress, state.max) - } else { - timeLeftEstimator.emptyTick() - notificationThrottler.reset() - } - val notification = notificationFactory.create(state) - if (state.isFinalState) { - notificationManager.notify(id.toString(), id.hashCode(), notification) - } else if (notificationThrottler.throttle()) { - notificationManager.notify(id.hashCode(), notification) - } else { - return - } - setProgress(state.toWorkData()) - } - - private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable { - localMangaRepository.getDetails(manga).chapters?.ids() - }.getOrNull().orEmpty() - - private fun getChapters( - manga: Manga, - includedIds: LongArray?, - ): List { - val chapters = checkNotNull(manga.chapters) { - "Chapters list must not be null" - }.toMutableList() - if (includedIds != null) { - val chaptersIdsSet = includedIds.toMutableSet() - chapters.retainAll { x -> chaptersIdsSet.remove(x.id) } - check(chaptersIdsSet.isEmpty()) { - "${chaptersIdsSet.size} of ${includedIds.size} requested chapters not found in manga" - } - } - check(chapters.isNotEmpty()) { "Chapters list must not be empty" } - return chapters - } - - private suspend inline fun withMangaLock(manga: Manga, block: () -> T) = try { - localMangaRepository.lockManga(manga.id) - block() - } finally { - localMangaRepository.unlockManga(manga.id) - } - - @Reusable - class Scheduler @Inject constructor( - @ApplicationContext private val context: Context, - private val workManager: WorkManager, - private val dataRepository: MangaDataRepository, - private val settings: AppSettings, - ) { - - suspend fun schedule(manga: Manga, chaptersIds: Collection?) { - dataRepository.storeManga(manga) - val data = Data.Builder() - .putLong(MANGA_ID, manga.id) - if (!chaptersIds.isNullOrEmpty()) { - data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray()) - } - scheduleImpl(listOf(data.build())) - } - - suspend fun schedule(manga: Collection) { - val data = manga.map { - dataRepository.storeManga(it) - Data.Builder() - .putLong(MANGA_ID, it.id) - .build() - } - scheduleImpl(data) - } - - fun observeWorks(): Flow> = workManager - .getWorkInfosByTagFlow(TAG) - - @SuppressLint("RestrictedApi") - suspend fun getInputData(id: UUID): Data? { - val spec = workManager.getWorkSpec(id) ?: return null - return Data.Builder() - .putAll(spec.input) - .putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt) - .build() - } - - suspend fun getInputChaptersIds(workId: UUID): LongArray? { - return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } - } - - suspend fun cancel(id: UUID) { - workManager.cancelWorkById(id).await() - } - - suspend fun cancelAll() { - workManager.cancelAllWorkByTag(TAG).await() - } - - fun pause(id: UUID) { - val intent = PausingReceiver.getPauseIntent(context, id) - context.sendBroadcast(intent) - } - - fun resume(id: UUID, skipError: Boolean) { - val intent = PausingReceiver.getResumeIntent(context, id, skipError) - context.sendBroadcast(intent) - } - - suspend fun delete(id: UUID) { - workManager.deleteWork(id) - } - - suspend fun delete(ids: Collection) { - val wm = workManager - ids.forEach { id -> wm.cancelWorkById(id).await() } - workManager.deleteWorks(ids) - } - - suspend fun removeCompleted() { - val finishedWorks = workManager.awaitFinishedWorkInfosByTag(TAG) - workManager.deleteWorks(finishedWorks.mapToSet { it.id }) - } - - suspend fun updateConstraints() { - val constraints = createConstraints() - val works = workManager.awaitWorkInfosByTag(TAG) - for (work in works) { - if (work.state.isFinished) { - continue - } - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG) - .setId(work.id) - .build() - workManager.awaitUpdateWork(request) - } - } - - private suspend fun scheduleImpl(data: Collection) { - if (data.isEmpty()) { - return - } - val constraints = createConstraints() - val requests = data.map { inputData -> - OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG) - .keepResultsForAtLeast(30, TimeUnit.DAYS) - .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) - .setInputData(inputData) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - } - workManager.enqueue(requests).await() - } - - private fun createConstraints() = Constraints.Builder() - .setRequiredNetworkType(if (settings.isDownloadsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) - .build() - } - - private companion object { - - const val MAX_FAILSAFE_ATTEMPTS = 2 - const val MAX_PAGES_PARALLELISM = 4 - const val DOWNLOAD_ERROR_DELAY = 500L - const val SLOWDOWN_DELAY = 200L - const val MANGA_ID = "manga_id" - const val CHAPTERS_IDS = "chapters" - const val TAG = "download" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt deleted file mode 100644 index 0c9eb6653..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingHandle.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -import androidx.annotation.AnyThread -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext - -class PausingHandle : AbstractCoroutineContextElement(PausingHandle) { - - private val paused = MutableStateFlow(false) - private val isSkipError = MutableStateFlow(false) - - @get:AnyThread - val isPaused: Boolean - get() = paused.value - - @AnyThread - suspend fun awaitResumed() { - paused.first { !it } - } - - @AnyThread - fun pause() { - paused.value = true - } - - @AnyThread - fun resume(skipError: Boolean) { - isSkipError.value = skipError - paused.value = false - } - - suspend fun yield() { - if (paused.value) { - paused.first { !it } - } - } - - fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false) - - companion object : CoroutineContext.Key { - - suspend fun current() = checkNotNull(currentCoroutineContext()[this]) { - "PausingHandle not found in current context" - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt deleted file mode 100644 index c03313734..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.download.ui.worker - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.Uri -import android.os.PatternMatcher -import androidx.core.app.PendingIntentCompat -import org.koitharu.kotatsu.core.util.ext.toUUIDOrNull -import java.util.UUID - -class PausingReceiver( - private val id: UUID, - private val pausingHandle: PausingHandle, -) : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent?) { - val uuid = intent?.getStringExtra(EXTRA_UUID)?.toUUIDOrNull() - if (uuid != id) { - return - } - when (intent.action) { - ACTION_RESUME -> pausingHandle.resume(skipError = false) - ACTION_SKIP -> pausingHandle.resume(skipError = true) - ACTION_PAUSE -> pausingHandle.pause() - } - } - - companion object { - - private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" - private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME" - private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP" - private const val EXTRA_UUID = "uuid" - private const val SCHEME = "workuid" - - fun createIntentFilter(id: UUID) = IntentFilter().apply { - addAction(ACTION_PAUSE) - addAction(ACTION_RESUME) - addAction(ACTION_SKIP) - addDataScheme(SCHEME) - addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) - } - - fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE) - .setData(Uri.parse("$SCHEME://$id")) - .setPackage(context.packageName) - .putExtra(EXTRA_UUID, id.toString()) - - fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent( - if (skipError) ACTION_SKIP else ACTION_RESUME, - ).setData(Uri.parse("$SCHEME://$id")) - .setPackage(context.packageName) - .putExtra(EXTRA_UUID, id.toString()) - - fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( - context, - 0, - getPauseIntent(context, id), - 0, - false, - ) - - fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) = - PendingIntentCompat.getBroadcast( - context, - 0, - getResumeIntent(context, id, skipError), - 0, - false, - ) - } -} 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 deleted file mode 100644 index 6ec61efcd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ /dev/null @@ -1,210 +0,0 @@ -package org.koitharu.kotatsu.explore.data - -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.isNsfw -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mapToSet -import java.util.Collections -import java.util.EnumSet -import javax.inject.Inject - -@Reusable -class MangaSourcesRepository @Inject constructor( - private val db: MangaDatabase, - private val settings: AppSettings, -) { - - private val dao: MangaSourcesDao - get() = db.getSourcesDao() - - private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { - remove(MangaSource.LOCAL) - if (!BuildConfig.DEBUG) { - remove(MangaSource.DUMMY) - } - } - - val allMangaSources: Set - get() = Collections.unmodifiableSet(remoteSources) - - suspend fun getEnabledSources(): List { - val order = settings.sourcesSortOrder - return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order) - } - - suspend fun getDisabledSources(): List { - return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled, null) - } - - fun observeEnabledSourcesCount(): Flow { - return combine( - observeIsNsfwDisabled(), - dao.observeEnabled(SourcesSortOrder.MANUAL), - ) { skipNsfw, sources -> - sources.count { skipNsfw || !MangaSource(it.source).isNsfw() } - }.distinctUntilChanged() - } - - fun observeAvailableSourcesCount(): Flow { - return combine( - observeIsNsfwDisabled(), - dao.observeEnabled(SourcesSortOrder.MANUAL), - ) { skipNsfw, enabledSources -> - val enabled = enabledSources.mapToSet { it.source } - allMangaSources.count { x -> - x.name !in enabled && (!skipNsfw || !x.isNsfw()) - } - }.distinctUntilChanged() - } - - fun observeEnabledSources(): Flow> = combine( - observeIsNsfwDisabled(), - observeSortOrder(), - ) { skipNsfw, order -> - dao.observeEnabled(order).map { - it.toSources(skipNsfw, order) - } - }.flatMapLatest { it } - - fun observeAll(): Flow>> = dao.observeAll().map { entities -> - val result = ArrayList>(entities.size) - for (entity in entities) { - val source = MangaSource(entity.source) - if (source in remoteSources) { - result.add(source to entity.isEnabled) - } - } - result - } - - suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle { - dao.setEnabled(source.name, isEnabled) - return ReversibleHandle { - dao.setEnabled(source.name, !isEnabled) - } - } - - suspend fun setSourcesEnabledExclusive(sources: Set) { - db.withTransaction { - for (s in remoteSources) { - dao.setEnabled(s.name, s in sources) - } - } - } - - suspend fun disableAllSources() { - db.withTransaction { - assimilateNewSources() - dao.disableAllSources() - } - } - - suspend fun setPositions(sources: List) { - db.withTransaction { - for ((index, item) in sources.withIndex()) { - dao.setSortKey(item.name, index) - } - } - } - - fun observeNewSources(): Flow> = observeIsNewSourcesEnabled().flatMapLatest { - if (it) { - combine( - dao.observeAll(), - observeIsNsfwDisabled(), - ) { entities, skipNsfw -> - val result = EnumSet.copyOf(remoteSources) - for (e in entities) { - result.remove(MangaSource(e.source)) - } - if (skipNsfw) { - result.removeAll { x -> x.isNsfw() } - } - result - }.distinctUntilChanged() - } else { - flowOf(emptySet()) - } - } - - suspend fun assimilateNewSources(): Set { - val new = getNewSources() - if (new.isEmpty()) { - return emptySet() - } - var maxSortKey = dao.getMaxSortKey() - val entities = new.map { x -> - MangaSourceEntity( - source = x.name, - isEnabled = false, - sortKey = ++maxSortKey, - ) - } - dao.insertIfAbsent(entities) - if (settings.isNsfwContentDisabled) { - new.removeAll { x -> x.isNsfw() } - } - return new - } - - suspend fun isSetupRequired(): Boolean { - return dao.findAll().isEmpty() - } - - private suspend fun getNewSources(): MutableSet { - val entities = dao.findAll() - val result = EnumSet.copyOf(remoteSources) - for (e in entities) { - result.remove(MangaSource(e.source)) - } - return result - } - - private fun List.toSources( - skipNsfwSources: Boolean, - sortOrder: SourcesSortOrder?, - ): List { - val result = ArrayList(size) - for (entity in this) { - val source = MangaSource(entity.source) - if (skipNsfwSources && source.contentType == ContentType.HENTAI) { - continue - } - if (source in remoteSources) { - result.add(source) - } - } - if (sortOrder == SourcesSortOrder.ALPHABETIC) { - result.sortBy { it.title } - } - return result - } - - private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { - isNsfwContentDisabled - } - - private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) { - isNewSourcesTipEnabled - } - - private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) { - sourcesSortOrder - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt deleted file mode 100644 index 9c42be758..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/SourcesSortOrder.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.explore.data - -import androidx.annotation.StringRes -import org.koitharu.kotatsu.R - -enum class SourcesSortOrder( - @StringRes val titleResId: Int, -) { - ALPHABETIC(R.string.by_name), - POPULARITY(R.string.popular), - MANUAL(R.string.manual), -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt deleted file mode 100644 index 9a473aac6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.explore.domain - -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.almostEquals -import org.koitharu.kotatsu.core.util.ext.asArrayList -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.parsers.model.ContentType -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.runCatchingCancellable -import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist -import javax.inject.Inject - -class ExploreRepository @Inject constructor( - private val settings: AppSettings, - private val sourcesRepository: MangaSourcesRepository, - private val historyRepository: HistoryRepository, - private val mangaRepositoryFactory: MangaRepository.Factory, -) { - - suspend fun findRandomManga(tagsLimit: Int): Manga { - val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) - val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { - if (it in tagsBlacklist) null else it.title - } - val sources = sourcesRepository.getEnabledSources() - check(sources.isNotEmpty()) { "No sources available" } - for (i in 0..4) { - val list = getList(sources.random(), tags, tagsBlacklist) - val manga = list.randomOrNull() ?: continue - val details = runCatchingCancellable { - mangaRepositoryFactory.create(manga.source).getDetails(manga) - }.getOrNull() ?: continue - if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in tagsBlacklist) { - continue - } - return details - } - throw NoSuchElementException() - } - - suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga { - val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) - val skipNsfw = settings.isSuggestionsExcludeNsfw && source.contentType != ContentType.HENTAI - val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { - if (it in tagsBlacklist) null else it.title - } - for (i in 0..4) { - val list = getList(source, tags, tagsBlacklist) - val manga = list.randomOrNull() ?: continue - val details = runCatchingCancellable { - mangaRepositoryFactory.create(manga.source).getDetails(manga) - }.getOrNull() ?: continue - if ((skipNsfw && details.isNsfw) || details in tagsBlacklist) { - continue - } - return details - } - throw NoSuchElementException() - } - - private suspend fun getList( - source: MangaSource, - tags: List, - blacklist: TagsBlacklist, - ): List = runCatchingCancellable { - val repository = mangaRepositoryFactory.create(source) - val order = repository.sortOrders.random() - val availableTags = repository.getTags() - val tag = tags.firstNotNullOfOrNull { title -> - availableTags.find { x -> x.title.almostEquals(title, 0.4f) } - } - val list = repository.getList( - offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), - ).asArrayList() - if (settings.isSuggestionsExcludeNsfw) { - list.removeAll { it.isNsfw } - } - if (blacklist.isNotEmpty()) { - list.removeAll { manga -> manga in blacklist } - } - list.shuffle() - list - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(emptyList()) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt deleted file mode 100644 index fbdb0f045..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/domain/RecoverMangaUseCase.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.explore.domain - -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import javax.inject.Inject - -class RecoverMangaUseCase @Inject constructor( - private val mangaDataRepository: MangaDataRepository, - private val repositoryFactory: MangaRepository.Factory, -) { - - suspend operator fun invoke(manga: Manga): Manga? = runCatchingCancellable { - if (manga.isLocal) { - return@runCatchingCancellable null - } - val repository = repositoryFactory.create(manga.source) - val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title)) - val newManga = list.find { x -> x.title == manga.title }?.let { - repository.getDetails(it) - } ?: return@runCatchingCancellable null - val merged = merge(manga, newManga) - mangaDataRepository.storeManga(merged) - merged - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - - private fun merge( - broken: Manga, - current: Manga, - ) = Manga( - id = broken.id, - title = current.title, - altTitle = current.altTitle, - url = current.url, - publicUrl = current.publicUrl, - rating = current.rating, - isNsfw = current.isNsfw, - coverUrl = current.coverUrl, - tags = current.tags, - state = current.state, - author = current.author, - largeCoverUrl = current.largeCoverUrl, - description = current.description, - chapters = current.chapters, - source = current.source, - ) -} 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 deleted file mode 100644 index 562cddba5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ /dev/null @@ -1,219 +0,0 @@ -package org.koitharu.kotatsu.explore.ui - -import android.content.DialogInterface -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.PopupMenu -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver -import org.koitharu.kotatsu.core.ui.widgets.TipView -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.core.util.ext.viewLifecycleScope -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 -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.TipModel -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity -import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity -import javax.inject.Inject - -@AndroidEntryPoint -class ExploreFragment : - BaseFragment(), - RecyclerViewOwner, - ExploreListEventListener, - OnListItemClickListener, TipView.OnButtonClickListener { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var shortcutManager: AppShortcutManager - - private val viewModel by viewModels() - private var exploreAdapter: ExploreAdapter? = null - - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentExploreBinding { - return FragmentExploreBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view -> - startActivity(DetailsActivity.newIntent(view.context, manga)) - } - with(binding.recyclerView) { - adapter = exploreAdapter - setHasFixedSize(true) - SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() - addItemDecoration(TypedListSpacingDecoration(context, false)) - } - addMenuProvider(ExploreMenuProvider(binding.root.context)) - viewModel.content.observe(viewLifecycleOwner) { - exploreAdapter?.items = it - } - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) - viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) - viewModel.onShowSuggestionsTip.observeEvent(viewLifecycleOwner) { - showSuggestionsTip() - } - } - - override fun onDestroyView() { - super.onDestroyView() - exploreAdapter = null - } - - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - } - - override fun onListHeaderClick(item: ListHeader, view: View) { - startActivity(Intent(view.context, SourcesCatalogActivity::class.java)) - } - - override fun onPrimaryButtonClick(tipView: TipView) { - when ((tipView.tag as? TipModel)?.key) { - ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager) - } - } - - override fun onSecondaryButtonClick(tipView: TipView) { - when ((tipView.tag as? TipModel)?.key) { - ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources() - } - } - - override fun onClick(v: View) { - val intent = when (v.id) { - R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL) - R.id.button_bookmarks -> BookmarksActivity.newIntent(v.context) - R.id.button_more -> SuggestionsActivity.newIntent(v.context) - R.id.button_downloads -> DownloadsActivity.newIntent(v.context) - R.id.button_random -> { - viewModel.openRandom() - return - } - - else -> return - } - startActivity(intent) - } - - override fun onItemClick(item: MangaSourceItem, view: View) { - val intent = MangaListActivity.newIntent(view.context, item.source) - startActivity(intent) - } - - override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { - val menu = PopupMenu(view.context, view) - menu.inflate(R.menu.popup_source) - menu.menu.findItem(R.id.action_shortcut) - ?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context) - menu.setOnMenuItemClickListener(SourceMenuListener(item)) - menu.show() - return true - } - - override fun onRetryClick(error: Throwable) = Unit - - override fun onEmptyActionClick() { - startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java)) - } - - private fun onOpenManga(manga: Manga) { - val intent = DetailsActivity.newIntent(context ?: return, manga) - startActivity(intent) - } - - private fun onGridModeChanged(isGrid: Boolean) { - requireViewBinding().recyclerView.layoutManager = if (isGrid) { - GridLayoutManager(requireContext(), 4).also { lm -> - lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm) - } - } else { - LinearLayoutManager(requireContext()) - } - } - - private fun showSuggestionsTip() { - val listener = DialogInterface.OnClickListener { _, which -> - viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE) - } - TwoButtonsAlertDialog.Builder(requireContext()) - .setIcon(R.drawable.ic_suggestion) - .setTitle(R.string.suggestions_enable_prompt) - .setPositiveButton(R.string.enable, listener) - .setNegativeButton(R.string.no_thanks, listener) - .create() - .show() - } - - private inner class SourceMenuListener( - private val sourceItem: MangaSourceItem, - ) : PopupMenu.OnMenuItemClickListener { - - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_settings -> { - startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source)) - } - - R.id.action_hide -> { - viewModel.hideSource(sourceItem.source) - } - - R.id.action_shortcut -> { - viewLifecycleScope.launch { - shortcutManager.requestPinShortcut(sourceItem.source) - } - } - - else -> return false - } - return true - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt deleted file mode 100644 index 8601f9f9e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.koitharu.kotatsu.explore.ui - -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup -import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter -import org.koitharu.kotatsu.list.ui.adapter.ListItemType - -class ExploreGridSpanSizeLookup( - private val adapter: ExploreAdapter, - private val layoutManager: GridLayoutManager, -) : SpanSizeLookup() { - - override fun getSpanSize(position: Int): Int { - val itemType = adapter.getItemViewType(position) - return if (itemType == ListItemType.EXPLORE_SOURCE_GRID.ordinal) 1 else layoutManager.spanCount - } -} 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 deleted file mode 100644 index 98973d443..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ /dev/null @@ -1,172 +0,0 @@ -package org.koitharu.kotatsu.explore.ui - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -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 -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -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.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.explore.domain.ExploreRepository -import org.koitharu.kotatsu.explore.ui.model.ExploreButtons -import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem -import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem -import org.koitharu.kotatsu.list.ui.model.EmptyHint -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository -import javax.inject.Inject - -@HiltViewModel -class ExploreViewModel @Inject constructor( - private val settings: AppSettings, - private val suggestionRepository: SuggestionRepository, - private val exploreRepository: ExploreRepository, - private val sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - val isGrid = settings.observeAsStateFlow( - key = AppSettings.KEY_SOURCES_GRID, - scope = viewModelScope + Dispatchers.IO, - valueProducer = { isSourcesGridMode }, - ) - - private val isSuggestionsEnabled = settings.observeAsFlow( - key = AppSettings.KEY_SUGGESTIONS, - valueProducer = { isSuggestionsEnabled }, - ) - - val onOpenManga = MutableEventFlow() - val onActionDone = MutableEventFlow() - val onShowSuggestionsTip = MutableEventFlow() - private val isRandomLoading = MutableStateFlow(false) - - val content: StateFlow> = isLoading.flatMapLatest { loading -> - if (loading) { - flowOf(getLoadingStateList()) - } else { - createContentFlow() - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, getLoadingStateList()) - - init { - launchJob(Dispatchers.Default) { - if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) { - onShowSuggestionsTip.call(Unit) - } - } - } - - fun openRandom() { - if (isRandomLoading.value) { - return - } - launchJob(Dispatchers.Default) { - isRandomLoading.value = true - try { - val manga = exploreRepository.findRandomManga(tagsLimit = 8) - onOpenManga.call(manga) - } finally { - isRandomLoading.value = false - } - } - } - - fun hideSource(source: MangaSource) { - launchJob(Dispatchers.Default) { - val rollback = sourcesRepository.setSourceEnabled(source, isEnabled = false) - onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) - } - } - - fun discardNewSources() { - launchJob(Dispatchers.Default) { - sourcesRepository.assimilateNewSources() - } - } - - fun respondSuggestionTip(isAccepted: Boolean) { - settings.isSuggestionsEnabled = isAccepted - settings.closeTip(TIP_SUGGESTIONS) - } - - private fun createContentFlow() = combine( - sourcesRepository.observeEnabledSources(), - getSuggestionFlow(), - isGrid, - isRandomLoading, - sourcesRepository.observeNewSources(), - ) { content, suggestions, grid, randomLoading, newSources -> - buildList(content, suggestions, grid, randomLoading, newSources) - } - - private fun buildList( - sources: List, - recommendation: Manga?, - isGrid: Boolean, - randomLoading: Boolean, - newSources: Set, - ): List { - val result = ArrayList(sources.size + 3) - result += ExploreButtons(randomLoading) - if (recommendation != null) { - result += ListHeader(R.string.suggestions) - result += RecommendationsItem(recommendation) - } - if (sources.isNotEmpty()) { - result += ListHeader( - textRes = R.string.remote_sources, - buttonTextRes = R.string.catalog, - badge = if (newSources.isNotEmpty()) "" else null, - ) - sources.mapTo(result) { MangaSourceItem(it, isGrid) } - } else { - result += EmptyHint( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.no_manga_sources, - textSecondary = R.string.no_manga_sources_text, - actionStringRes = R.string.catalog, - ) - } - return result - } - - private fun getLoadingStateList() = listOf( - ExploreButtons(isRandomLoading.value), - LoadingState, - ) - - private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> - if (isEnabled) { - runCatchingCancellable { - suggestionRepository.getRandom() - }.getOrNull() - } else { - null - } - } - - companion object { - - private const val TIP_SUGGESTIONS = "suggestions" - const val TIP_NEW_SOURCES = "new_sources" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt deleted file mode 100644 index 83d7ee149..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.widgets.TipView -import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD -import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD -import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD -import org.koitharu.kotatsu.list.ui.adapter.tipAD -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga - -class ExploreAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: ExploreListEventListener, - tipClickListener: TipView.OnButtonClickListener, - clickListener: OnListItemClickListener, - mangaClickListener: OnListItemClickListener, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.EXPLORE_BUTTONS, exploreButtonsAD(listener)) - addDelegate( - ListItemType.EXPLORE_SUGGESTION, - exploreRecommendationItemAD(coil, listener, mangaClickListener, lifecycleOwner), - ) - addDelegate(ListItemType.HEADER, listHeaderAD(listener)) - addDelegate(ListItemType.EXPLORE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner)) - addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner)) - addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.TIP, tipAD(tipClickListener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt deleted file mode 100644 index 735f32df1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ /dev/null @@ -1,161 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.adapter - -import android.graphics.Color -import android.view.View -import androidx.lifecycle.LifecycleOwner -import androidx.swiperefreshlayout.widget.CircularProgressDrawable -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.getSummary -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.parser.favicon.faviconUri -import org.koitharu.kotatsu.core.ui.image.FaviconDrawable -import org.koitharu.kotatsu.core.ui.image.TrimTransformation -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding -import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding -import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding -import org.koitharu.kotatsu.databinding.ItemRecommendationBinding -import org.koitharu.kotatsu.explore.ui.model.ExploreButtons -import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem -import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga -import com.google.android.material.R as materialR - -fun exploreButtonsAD( - clickListener: View.OnClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.buttonBookmarks.setOnClickListener(clickListener) - binding.buttonDownloads.setOnClickListener(clickListener) - binding.buttonLocal.setOnClickListener(clickListener) - binding.buttonRandom.setOnClickListener(clickListener) - - bind { - if (item.isRandomLoading) { - val icon = CircularProgressDrawable(context) - icon.strokeWidth = context.resources.resolveDp(2f) - icon.setColorSchemeColors( - context.getThemeColor( - materialR.attr.colorPrimary, - Color.DKGRAY, - ), - ) - binding.buttonRandom.icon = icon - icon.start() - } else { - binding.buttonRandom.setIconResource(R.drawable.ic_dice) - } - binding.buttonRandom.isClickable = !item.isRandomLoading - } -} - -fun exploreRecommendationItemAD( - coil: ImageLoader, - clickListener: View.OnClickListener, - itemClickListener: OnListItemClickListener, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.buttonMore.setOnClickListener(clickListener) - binding.root.setOnClickListener { v -> - itemClickListener.onItemClick(item.manga, v) - } - - bind { - binding.textViewTitle.text = item.manga.title - binding.textViewSubtitle.textAndVisible = item.summary - binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - transformations(TrimTransformation()) - source(item.manga.source) - enqueueWith(coil) - } - } -} - -fun exploreSourceListItemAD( - coil: ImageLoader, - listener: OnListItemClickListener, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemExploreSourceListBinding.inflate( - layoutInflater, - parent, - false, - ) - }, - on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, -) { - - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) - - binding.root.setOnClickListener(eventListener) - binding.root.setOnLongClickListener(eventListener) - binding.root.setOnContextClickListenerCompat(eventListener) - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - binding.textViewSubtitle.text = item.source.getSummary(context) - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - fallback(fallbackIcon) - placeholder(fallbackIcon) - error(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} - -fun exploreSourceGridItemAD( - coil: ImageLoader, - listener: OnListItemClickListener, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemExploreSourceGridBinding.inflate( - layoutInflater, - parent, - false, - ) - }, - on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, -) { - - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) - - binding.root.setOnClickListener(eventListener) - binding.root.setOnLongClickListener(eventListener) - binding.root.setOnContextClickListenerCompat(eventListener) - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - fallback(fallbackIcon) - placeholder(fallbackIcon) - error(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt deleted file mode 100644 index 2814e2709..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.adapter - -import android.view.View -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener - -interface ExploreListEventListener : ListStateHolderListener, View.OnClickListener, ListHeaderClickListener diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreButtons.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreButtons.kt deleted file mode 100644 index 2eb8a003f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/ExploreButtons.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class ExploreButtons( - val isRandomLoading: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ExploreButtons - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/MangaSourceItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/MangaSourceItem.kt deleted file mode 100644 index 2425945e5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/MangaSourceItem.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.model - -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.MangaSource - -data class MangaSourceItem( - val source: MangaSource, - val isGrid: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaSourceItem && other.source == source - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt deleted file mode 100644 index 5d1a99ba0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.explore.ui.model - -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga - -data class RecommendationsItem( - val manga: Manga -) : ListModel { - val summary: String = manga.tags.joinToString { it.title } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is RecommendationsItem - } -} 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 deleted file mode 100644 index a99afe241..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.favourites.data - -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.list.domain.ListSortOrder -import java.time.Instant - -fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( - id = id, - title = title, - sortKey = sortKey, - order = ListSortOrder(order, ListSortOrder.NEWEST), - createdAt = Instant.ofEpochMilli(createdAt), - isTrackingEnabled = track, - isVisibleInLibrary = isVisibleInLibrary, -) - -fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) - -fun Collection.toMangaList() = map { it.toManga() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt deleted file mode 100644 index 2f1e4b151..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.favourites.data - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES - -@Entity(tableName = TABLE_FAVOURITE_CATEGORIES) -data class FavouriteCategoryEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "category_id") val categoryId: Int, - @ColumnInfo(name = "created_at") val createdAt: Long, - @ColumnInfo(name = "sort_key") val sortKey: Int, - @ColumnInfo(name = "title") val title: String, - @ColumnInfo(name = "order") val order: String, - @ColumnInfo(name = "track") val track: Boolean, - @ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean, - @ColumnInfo(name = "deleted_at") val deletedAt: Long, -) { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as FavouriteCategoryEntity - - if (categoryId != other.categoryId) return false - if (createdAt != other.createdAt) return false - if (sortKey != other.sortKey) return false - if (title != other.title) return false - if (order != other.order) return false - if (track != other.track) return false - return isVisibleInLibrary == other.isVisibleInLibrary - } - - override fun hashCode(): Int { - var result = categoryId - result = 31 * result + createdAt.hashCode() - result = 31 * result + sortKey - result = 31 * result + title.hashCode() - result = 31 * result + order.hashCode() - result = 31 * result + track.hashCode() - result = 31 * result + isVisibleInLibrary.hashCode() - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt deleted file mode 100644 index a7c4df2c8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ /dev/null @@ -1,184 +0,0 @@ -package org.koitharu.kotatsu.favourites.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RawQuery -import androidx.room.Transaction -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.core.db.entity.MangaEntity -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.list.domain.ListSortOrder - -@Dao -abstract class FavouritesDao { - - /** SELECT **/ - - @Transaction - @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") - abstract suspend fun findAll(): List - - @Transaction - @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") - abstract suspend fun findLast(limit: Int): List - - fun observeAll(order: ListSortOrder): Flow> { - val orderBy = getOrderBy(order) - - @Language("RoomSql") - val query = SimpleSQLiteQuery( - "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + - "WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy", - ) - return observeAllImpl(query) - } - - @Transaction - @Query( - "SELECT * FROM favourites WHERE deleted_at = 0 " + - "GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset", - ) - abstract suspend fun findAll(offset: Int, limit: Int): List - - @Transaction - @Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset") - abstract suspend fun findAllRaw(offset: Int, limit: Int): List - - @Transaction - @Query( - "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + - "GROUP BY manga_id ORDER BY created_at DESC", - ) - abstract suspend fun findAll(categoryId: Long): List - - fun observeAll(categoryId: Long, order: ListSortOrder): Flow> { - val orderBy = getOrderBy(order) - - @Language("RoomSql") - val query = SimpleSQLiteQuery( - "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + - "WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy", - arrayOf(categoryId), - ) - return observeAllImpl(query) - } - - @Transaction - @Query( - "SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " + - "GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset", - ) - abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List - - @Query( - "SELECT * FROM manga WHERE manga_id IN " + - "(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)", - ) - abstract suspend fun findAllManga(categoryId: Int): List - - suspend fun findCovers(categoryId: Long, order: ListSortOrder): List { - val orderBy = getOrderBy(order) - - @Language("RoomSql") - val query = SimpleSQLiteQuery( - "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " + - "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + - "WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", - arrayOf(categoryId), - ) - return findCoversImpl(query) - } - - @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)") - abstract suspend fun findAllManga(): List - - @Transaction - @Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id") - abstract suspend fun find(id: Long): FavouriteManga? - - @Transaction - @Deprecated("Ignores order") - @Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id") - abstract fun observe(id: Long): Flow - - @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0") - abstract fun observeIds(id: Long): Flow> - - @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0") - abstract suspend fun findCategoriesIds(mangaIds: Collection): List - - /** INSERT **/ - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(favourite: FavouriteEntity) - - /** DELETE **/ - - suspend fun delete(mangaId: Long) = setDeletedAt( - mangaId = mangaId, - deletedAt = System.currentTimeMillis(), - ) - - suspend fun delete(mangaId: Long, categoryId: Long) = setDeletedAt( - categoryId = categoryId, - mangaId = mangaId, - deletedAt = System.currentTimeMillis(), - ) - - suspend fun deleteAll(categoryId: Long) = setDeletedAtAll( - categoryId = categoryId, - deletedAt = System.currentTimeMillis(), - ) - - suspend fun recover(mangaId: Long) = setDeletedAt( - mangaId = mangaId, - deletedAt = 0L, - ) - - suspend fun recover(categoryId: Long, mangaId: Long) = setDeletedAt( - categoryId = categoryId, - mangaId = mangaId, - deletedAt = 0L, - ) - - @Query("DELETE FROM favourites WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") - abstract suspend fun gc(maxDeletionTime: Long) - - /** TOOLS **/ - - @Upsert - abstract suspend fun upsert(entity: FavouriteEntity) - - @Transaction - @RawQuery(observedEntities = [FavouriteEntity::class]) - protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> - - @RawQuery - protected abstract suspend fun findCoversImpl(query: SupportSQLiteQuery): List - - @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId") - protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) - - @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId") - abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long) - - @Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0") - protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long) - - private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) { - ListSortOrder.RATING -> "manga.rating DESC" - ListSortOrder.NEWEST -> "favourites.created_at DESC" - ListSortOrder.ALPHABETIC -> "manga.title ASC" - ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC" - ListSortOrder.UPDATED, // for legacy support - ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC" - - else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") - } -} 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 deleted file mode 100644 index 5b5fa094f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ /dev/null @@ -1,233 +0,0 @@ -package org.koitharu.kotatsu.favourites.domain - -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.toEntities -import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity -import org.koitharu.kotatsu.favourites.data.FavouriteEntity -import org.koitharu.kotatsu.favourites.data.toFavouriteCategory -import org.koitharu.kotatsu.favourites.data.toManga -import org.koitharu.kotatsu.favourites.data.toMangaList -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels -import javax.inject.Inject - -@Reusable -class FavouritesRepository @Inject constructor( - private val db: MangaDatabase, - private val channels: TrackerNotificationChannels, -) { - - suspend fun getAllManga(): List { - val entities = db.getFavouritesDao().findAll() - return entities.toMangaList() - } - - suspend fun getLastManga(limit: Int): List { - val entities = db.getFavouritesDao().findLast(limit) - return entities.toMangaList() - } - - fun observeAll(order: ListSortOrder): Flow> { - return db.getFavouritesDao().observeAll(order) - .mapItems { it.toManga() } - } - - suspend fun getManga(categoryId: Long): List { - val entities = db.getFavouritesDao().findAll(categoryId) - return entities.toMangaList() - } - - fun observeAll(categoryId: Long, order: ListSortOrder): Flow> { - return db.getFavouritesDao().observeAll(categoryId, order) - .mapItems { it.toManga() } - } - - fun observeAll(categoryId: Long): Flow> { - return observeOrder(categoryId) - .flatMapLatest { order -> observeAll(categoryId, order) } - } - - fun observeCategories(): Flow> { - return db.getFavouriteCategoriesDao().observeAll().mapItems { - it.toFavouriteCategory() - }.distinctUntilChanged() - } - - fun observeCategoriesForLibrary(): Flow> { - return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems { - it.toFavouriteCategory() - }.distinctUntilChanged() - } - - fun observeCategoriesWithCovers(): Flow>> { - return db.getFavouriteCategoriesDao().observeAll() - .map { - db.withTransaction { - val res = LinkedHashMap>() - for (entity in it) { - val cat = entity.toFavouriteCategory() - res[cat] = db.getFavouritesDao().findCovers( - categoryId = cat.id, - order = cat.order, - ) - } - res - } - } - } - - fun observeCategory(id: Long): Flow { - return db.getFavouriteCategoriesDao().observe(id) - .map { it?.toFavouriteCategory() } - } - - fun observeCategoriesIds(mangaId: Long): Flow> { - return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() } - } - - suspend fun getCategory(id: Long): FavouriteCategory { - return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() - } - - suspend fun getCategoriesIds(mangaIds: Collection): Set { - return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() - } - - suspend fun createCategory( - title: String, - sortOrder: ListSortOrder, - isTrackerEnabled: Boolean, - isVisibleOnShelf: Boolean, - ): FavouriteCategory { - val entity = FavouriteCategoryEntity( - title = title, - createdAt = System.currentTimeMillis(), - sortKey = db.getFavouriteCategoriesDao().getNextSortKey(), - categoryId = 0, - order = sortOrder.name, - track = isTrackerEnabled, - deletedAt = 0L, - isVisibleInLibrary = isVisibleOnShelf, - ) - val id = db.getFavouriteCategoriesDao().insert(entity) - val category = entity.toFavouriteCategory(id) - channels.createChannel(category) - return category - } - - suspend fun updateCategory( - id: Long, - title: String, - sortOrder: ListSortOrder, - isTrackerEnabled: Boolean, - isVisibleOnShelf: Boolean, - ) { - db.getFavouriteCategoriesDao().update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf) - } - - suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) { - db.getFavouriteCategoriesDao().updateLibVisibility(id, isVisibleInLibrary) - } - - suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) { - db.getFavouriteCategoriesDao().updateTracking(id, isTrackingEnabled) - } - - suspend fun removeCategories(ids: Collection) { - db.withTransaction { - for (id in ids) { - db.getFavouritesDao().deleteAll(id) - db.getFavouriteCategoriesDao().delete(id) - } - } - // run after transaction success - for (id in ids) { - channels.deleteChannel(id) - } - } - - suspend fun setCategoryOrder(id: Long, order: ListSortOrder) { - db.getFavouriteCategoriesDao().updateOrder(id, order.name) - } - - suspend fun reorderCategories(orderedIds: List) { - val dao = db.getFavouriteCategoriesDao() - db.withTransaction { - for ((i, id) in orderedIds.withIndex()) { - dao.updateSortKey(id, i) - } - } - } - - suspend fun addToCategory(categoryId: Long, mangas: Collection) { - db.withTransaction { - for (manga in mangas) { - val tags = manga.tags.toEntities() - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga.toEntity(), tags) - val entity = FavouriteEntity( - mangaId = manga.id, - categoryId = categoryId, - createdAt = System.currentTimeMillis(), - sortKey = 0, - deletedAt = 0L, - ) - db.getFavouritesDao().insert(entity) - } - } - } - - suspend fun removeFromFavourites(ids: Collection): ReversibleHandle { - db.withTransaction { - for (id in ids) { - db.getFavouritesDao().delete(mangaId = id) - } - } - return ReversibleHandle { recoverToFavourites(ids) } - } - - suspend fun removeFromCategory(categoryId: Long, ids: Collection): ReversibleHandle { - db.withTransaction { - for (id in ids) { - db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id) - } - } - return ReversibleHandle { recoverToCategory(categoryId, ids) } - } - - private fun observeOrder(categoryId: Long): Flow { - return db.getFavouriteCategoriesDao().observe(categoryId) - .filterNotNull() - .map { x -> ListSortOrder(x.order, ListSortOrder.NEWEST) } - .distinctUntilChanged() - } - - private suspend fun recoverToFavourites(ids: Collection) { - db.withTransaction { - for (id in ids) { - db.getFavouritesDao().recover(mangaId = id) - } - } - } - - private suspend fun recoverToCategory(categoryId: Long, ids: Collection) { - db.withTransaction { - for (id in ids) { - db.getFavouritesDao().recover(mangaId = id, categoryId = categoryId) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt deleted file mode 100644 index e76c18724..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/model/Cover.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.favourites.domain.model - -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.find - -data class Cover( - val url: String, - val source: String, -) { - val mangaSource: MangaSource? - get() = if (source.isEmpty()) null else MangaSource.entries.find(source) -} 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 deleted file mode 100644 index dd275369d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID - -@AndroidEntryPoint -class FavouritesActivity : BaseActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val categoryTitle = intent.getStringExtra(EXTRA_TITLE) - if (categoryTitle != null) { - title = categoryTitle - } - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID)) - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - private const val EXTRA_CATEGORY_ID = "cat_id" - private const val EXTRA_TITLE = "title" - - fun newIntent(context: Context) = Intent(context, FavouritesActivity::class.java) - - fun newIntent(context: Context, category: FavouriteCategory) = Intent(context, FavouritesActivity::class.java) - .putExtra(EXTRA_CATEGORY_ID, category.id) - .putExtra(EXTRA_TITLE, category.title) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt deleted file mode 100644 index d629424f7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories - -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.view.ActionMode -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import com.google.android.material.R as materialR - -class CategoriesSelectionCallback( - private val recyclerView: RecyclerView, - private val viewModel: FavouritesCategoriesViewModel, -) : ListSelectionController.Callback2 { - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - recyclerView.invalidateItemDecorations() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_category, menu) - return true - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - val categories = viewModel.getCategories(controller.peekCheckedIds()) - var canShow = categories.isNotEmpty() - var canHide = canShow - for (cat in categories) { - if (cat.isVisibleInLibrary) { - canShow = false - } else { - canHide = false - } - } - menu.findItem(R.id.action_show)?.isVisible = canShow - menu.findItem(R.id.action_hide)?.isVisible = canHide - mode.title = controller.count.toString() - return true - } - - 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() - true - } - - R.id.action_hide -> { - viewModel.setIsVisible(controller.snapshot(), false) - mode.finish() - true - } - - R.id.action_remove -> { - confirmDeleteCategories(controller.snapshot(), mode) - true - } - - else -> false - } - } - - private fun confirmDeleteCategories(ids: Set, mode: ActionMode) { - val context = recyclerView.context - MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) - .setMessage(R.string.categories_delete_confirm) - .setTitle(R.string.remove_category) - .setIcon(R.drawable.ic_delete) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.remove) { _, _ -> - viewModel.deleteCategories(ids) - mode.finish() - }.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt deleted file mode 100644 index 3ee6833f0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.View -import androidx.core.graphics.ColorUtils -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration -import org.koitharu.kotatsu.core.util.ext.getItem -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel -import com.google.android.material.R as materialR - -class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val radius = context.resources.getDimension(R.dimen.list_selector_corner) - private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) - private val fillColor = ColorUtils.setAlphaComponent( - ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), - 0x74, - ) - private val padding = context.resources.getDimension(R.dimen.grid_spacing_outer) - - init { - paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) - hasForeground = true - hasBackground = false - isIncludeDecorAndMargins = false - } - - override fun getItemId(parent: RecyclerView, child: View): Long { - val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID - val item = holder.getItem(CategoryListModel::class.java) ?: return RecyclerView.NO_ID - return item.category.id - } - - override fun onDrawForeground( - canvas: Canvas, - parent: RecyclerView, - child: View, - bounds: RectF, - state: RecyclerView.State, - ) { - bounds.inset(padding, padding) - paint.color = fillColor - paint.style = Paint.Style.FILL - canvas.drawRoundRect(bounds, radius, radius, paint) - paint.color = strokeColor - paint.style = Paint.Style.STROKE - canvas.drawRoundRect(bounds, radius, radius, paint) - } -} 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 deleted file mode 100644 index 5e1f0982f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.model.FavouriteCategory -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 -import javax.inject.Inject - -@AndroidEntryPoint -class FavouriteCategoriesActivity : - BaseActivity(), - FavouriteCategoriesListListener, - View.OnClickListener, - ListStateHolderListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel by viewModels() - - private lateinit var adapter: CategoriesAdapter - private lateinit var selectionController: ListSelectionController - private lateinit var reorderHelper: ItemTouchHelper - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - adapter = CategoriesAdapter(coil, this, this, this) - selectionController = ListSelectionController( - activity = this, - decoration = CategoriesSelectionDecoration(this), - registryOwner = this, - callback = CategoriesSelectionCallback(viewBinding.recyclerView, viewModel), - ) - selectionController.attachToRecyclerView(viewBinding.recyclerView) - viewBinding.recyclerView.setHasFixedSize(true) - viewBinding.recyclerView.adapter = adapter - viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, false)) - viewBinding.fabAdd.setOnClickListener(this) - - reorderHelper = ItemTouchHelper(ReorderHelperCallback()).apply { - attachToRecyclerView(viewBinding.recyclerView) - } - - viewModel.content.observe(this, ::onCategoriesChanged) - viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this)) - } - } - - override fun onItemClick(item: FavouriteCategory, view: View) { - if (selectionController.onItemClick(item.id)) { - return - } - val intent = FavouritesActivity.newIntent(view.context, item) - startActivity(intent) - } - - override fun onEditClick(item: FavouriteCategory, view: View) { - if (selectionController.onItemClick(item.id)) { - return - } - val intent = FavouritesCategoryEditActivity.newIntent(view.context, item.id) - startActivity(intent) - } - - override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { - return selectionController.onItemLongClick(item.id) - } - - override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean { - reorderHelper.startDrag(holder) - return true - } - - override fun onRetryClick(error: Throwable) = Unit - - override fun onEmptyActionClick() = Unit - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.fabAdd.updateLayoutParams { - rightMargin = topMargin + insets.right - leftMargin = topMargin + insets.left - bottomMargin = topMargin + insets.bottom - } - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, - ) - } - - private suspend fun onCategoriesChanged(categories: List) { - adapter.emit(categories) - invalidateOptionsMenu() - } - - private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( - ItemTouchHelper.DOWN or ItemTouchHelper.UP, - 0, - ) { - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean { - if (viewHolder.itemViewType != target.itemViewType) { - return false - } - val fromPos = viewHolder.bindingAdapterPosition - val toPos = target.bindingAdapterPosition - if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) { - return false - } - adapter.reorderItems(fromPos, toPos) - return true - } - - override fun canDropOver( - recyclerView: RecyclerView, - current: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean = current.itemViewType == target.itemViewType - - override fun isLongPressDragEnabled(): Boolean = false - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - viewModel.saveOrder(adapter.items ?: return) - } - } - - companion object { - - fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt deleted file mode 100644 index e778d42f4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener - -interface FavouriteCategoriesListListener : OnListItemClickListener { - - fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean - - fun onEditClick(item: FavouriteCategory, view: View) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt deleted file mode 100644 index 27fa877ee..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map -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.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.requireValue -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import javax.inject.Inject - -@HiltViewModel -class FavouritesCategoriesViewModel @Inject constructor( - private val repository: FavouritesRepository, - private val settings: AppSettings, -) : BaseViewModel() { - - private var commitJob: Job? = null - - val content = repository.observeCategoriesWithCovers() - .map { it.toUiList() } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - fun deleteCategories(ids: Set) { - launchJob(Dispatchers.Default) { - repository.removeCategories(ids) - } - } - - fun setAllCategoriesVisible(isVisible: Boolean) { - settings.isAllFavouritesVisible = isVisible - } - - fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } - - fun saveOrder(snapshot: List) { - val prevJob = commitJob - commitJob = launchJob { - prevJob?.cancelAndJoin() - val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { - (it as? CategoryListModel)?.category?.id - } - if (ids.isNotEmpty()) { - repository.reorderCategories(ids) - } - } - } - - fun setIsVisible(ids: Set, isVisible: Boolean) { - launchJob(Dispatchers.Default) { - for (id in ids) { - repository.updateCategory(id, isVisible) - } - } - } - - fun getCategories(ids: Set): ArrayList { - val items = content.requireValue() - return items.mapNotNullTo(ArrayList(ids.size)) { item -> - (item as? CategoryListModel)?.category?.takeIf { it.id in ids } - } - } - - private fun Map>.toUiList(): List = map { (category, covers) -> - CategoryListModel( - mangaCount = covers.size, - covers = covers.take(3), - category = category, - isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources, - ) - }.ifEmpty { - listOf( - EmptyState( - icon = R.drawable.ic_empty_favourites, - textPrimary = R.string.text_empty_holder_primary, - textSecondary = R.string.empty_favourite_categories, - actionStringRes = 0, - ), - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt deleted file mode 100644 index 43658d3a3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.ReorderableListAdapter -import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener -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 CategoriesAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - onItemClickListener: FavouriteCategoriesListListener, - listListener: ListStateHolderListener, -) : ReorderableListAdapter() { - - init { - addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listListener)) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt deleted file mode 100644 index 253827e88..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.adapter - -import android.annotation.SuppressLint -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.MotionEvent -import android.view.View -import android.view.View.OnClickListener -import android.view.View.OnLongClickListener -import android.view.View.OnTouchListener -import androidx.core.graphics.ColorUtils -import androidx.core.view.isVisible -import androidx.core.widget.ImageViewCompat -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemCategoryBinding -import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener -import org.koitharu.kotatsu.list.ui.model.ListModel - -@SuppressLint("ClickableViewAccessibility") -fun categoryAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: FavouriteCategoriesListListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }, -) { - val eventListener = object : OnClickListener, OnLongClickListener, OnTouchListener { - override fun onClick(v: View) = if (v.id == R.id.imageView_edit) { - clickListener.onEditClick(item.category, v) - } else { - clickListener.onItemClick(item.category, v) - } - - override fun onLongClick(v: View) = clickListener.onItemLongClick(item.category, v) - override fun onTouch(v: View?, event: MotionEvent): Boolean = event.actionMasked == MotionEvent.ACTION_DOWN && - clickListener.onDragHandleTouch(this@adapterDelegateViewBinding) - } - 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() - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) - binding.imageViewEdit.setOnClickListener(eventListener) - binding.imageViewHandle.setOnTouchListener(eventListener) - - bind { - binding.textViewTitle.text = item.category.title - binding.textViewSubtitle.text = if (item.mangaCount == 0) { - getString(R.string.empty) - } else { - context.resources.getQuantityString( - R.plurals.items, - item.mangaCount, - item.mangaCount, - ) - } - binding.imageViewTracker.isVisible = item.category.isTrackingEnabled - binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary - repeat(coverViews.size) { i -> - val cover = item.covers.getOrNull(i) - coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(fallback) - source(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/adapter/CategoryListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt deleted file mode 100644 index c60243594..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.adapter - -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.list.ui.model.ListModel - -class CategoryListModel( - val mangaCount: Int, - val covers: List, - val category: FavouriteCategory, - val isTrackerEnabled: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is CategoryListModel && other.category.id == category.id - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as CategoryListModel - - if (mangaCount != other.mangaCount) return false - if (isTrackerEnabled != other.isTrackerEnabled) return false - if (covers != other.covers) return false - if (category.id != other.category.id) return false - if (category.title != other.category.title) return false - // ignore the category.sortKey field - if (category.order != other.category.order) return false - if (category.createdAt != other.category.createdAt) return false - if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false - return category.isVisibleInLibrary == other.category.isVisibleInLibrary - } - - override fun hashCode(): Int { - var result = mangaCount - result = 31 * result + isTrackerEnabled.hashCode() - result = 31 * result + covers.hashCode() - result = 31 * result + category.id.hashCode() - result = 31 * result + category.title.hashCode() - // ignore the category.sortKey field - result = 31 * result + category.order.hashCode() - result = 31 * result + category.createdAt.hashCode() - result = 31 * result + category.isTrackingEnabled.hashCode() - result = 31 * result + category.isVisibleInLibrary.hashCode() - return result - } - - override fun toString(): String { - return "CategoryListModel(categoryId=${category.id})" - } -} 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 deleted file mode 100644 index f22bad095..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.edit - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.Editable -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Filter -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getSerializableCompat -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.setChecked -import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal -import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding -import org.koitharu.kotatsu.list.domain.ListSortOrder -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class FavouritesCategoryEditActivity : - BaseActivity(), - AdapterView.OnItemClickListener, - View.OnClickListener, - DefaultTextWatcher { - - private val viewModel by viewModels() - private var selectedSortOrder: ListSortOrder? = null - private val sortOrders = ListSortOrder.FAVORITES.sortedByOrdinal() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityCategoryEditBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } - initSortSpinner() - viewBinding.buttonDone.setOnClickListener(this) - viewBinding.editName.addTextChangedListener(this) - afterTextChanged(viewBinding.editName.text) - - viewModel.onSaved.observeEvent(this) { finishAfterTransition() } - viewModel.category.observe(this, ::onCategoryChanged) - viewModel.isLoading.observe(this, ::onLoadingStateChanged) - viewModel.onError.observeEvent(this, ::onError) - viewModel.isTrackerEnabled.observe(this) { - viewBinding.switchTracker.isVisible = it - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - val order = savedInstanceState.getSerializableCompat(KEY_SORT_ORDER) - if (order != null) { - selectedSortOrder = order - } - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_done -> viewModel.save( - title = viewBinding.editName.text?.toString()?.trim().orEmpty(), - sortOrder = getSelectedSortOrder(), - isTrackerEnabled = viewBinding.switchTracker.isChecked, - isVisibleOnShelf = viewBinding.switchShelf.isChecked, - ) - } - } - - override fun afterTextChanged(s: Editable?) { - viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.scrollView.updatePadding( - bottom = insets.bottom, - ) - viewBinding.toolbar.updateLayoutParams { - topMargin = insets.top - } - } - - override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - selectedSortOrder = sortOrders.getOrNull(position) - } - - private fun onCategoryChanged(category: FavouriteCategory?) { - setTitle(if (category == null) R.string.create_category else R.string.edit_category) - if (selectedSortOrder != null) { - return - } - viewBinding.editName.setText(category?.title) - 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) - } - - private fun onError(e: Throwable) { - viewBinding.textViewError.text = e.getDisplayMessage(resources) - viewBinding.textViewError.isVisible = true - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.editSort.isEnabled = !isLoading - viewBinding.editName.isEnabled = !isLoading - viewBinding.switchTracker.isEnabled = !isLoading - viewBinding.switchShelf.isEnabled = !isLoading - if (isLoading) { - viewBinding.textViewError.isVisible = false - } - } - - private fun initSortSpinner() { - val entries = sortOrders.map { getString(it.titleResId) } - val adapter = SortAdapter(this, entries) - viewBinding.editSort.setAdapter(adapter) - viewBinding.editSort.onItemClickListener = this - } - - private fun getSelectedSortOrder(): ListSortOrder { - selectedSortOrder?.let { return it } - val entries = sortOrders.map { getString(it.titleResId) } - val index = entries.indexOf(viewBinding.editSort.text.toString()) - return sortOrders.getOrNull(index) ?: ListSortOrder.NEWEST - } - - private class SortAdapter( - context: Context, - entries: List, - ) : ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries) { - - override fun getFilter(): Filter = EmptyFilter - - private object EmptyFilter : Filter() { - override fun performFiltering(constraint: CharSequence?) = FilterResults() - override fun publishResults(constraint: CharSequence?, results: FilterResults?) = Unit - } - } - - 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 deleted file mode 100644 index 6c9e8161f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.edit - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -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.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 - -@HiltViewModel -class FavouritesCategoryEditViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val repository: FavouritesRepository, - private val settings: AppSettings, -) : BaseViewModel() { - - private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID - - val onSaved = MutableEventFlow() - val category = MutableStateFlow(null) - - val isTrackerEnabled = flow { - emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - - init { - launchLoadingJob(Dispatchers.Default) { - category.value = if (categoryId != NO_ID) { - repository.getCategory(categoryId) - } else { - null - } - } - } - - fun save( - title: String, - sortOrder: ListSortOrder, - isTrackerEnabled: Boolean, - isVisibleOnShelf: Boolean, - ) { - launchLoadingJob(Dispatchers.Default) { - check(title.isNotEmpty()) - if (categoryId == NO_ID) { - repository.createCategory(title, sortOrder, isTrackerEnabled, isVisibleOnShelf) - } else { - repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled, isVisibleOnShelf) - } - onSaved.call(Unit) - } - } -} 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 52e1762c4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt +++ /dev/null @@ -1,67 +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 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 - -@AndroidEntryPoint -class FavoriteSheet : BaseAdaptiveSheet(), OnListItemClickListener { - - private val viewModel by viewModels() - - 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(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), - ) - }.showDistinct(fm, TAG) - } -} 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/FavoriteSheetViewModel.kt deleted file mode 100644 index 2a9c85daf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.model.ids -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -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.firstNotNull -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem -import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem -import org.koitharu.kotatsu.parsers.util.mapToSet -import javax.inject.Inject - -@HiltViewModel -class FavoriteSheetViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val favouritesRepository: FavouritesRepository, - settings: AppSettings, -) : BaseViewModel() { - - private val manga = savedStateHandle.require>(FavoriteSheet.KEY_MANGA_LIST).mapToSet { - it.manga - } - private val header = CategoriesHeaderItem() - private val checkedCategories = MutableStateFlow?>(null) - val content = combine( - favouritesRepository.observeCategories(), - checkedCategories.filterNotNull(), - settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }, - ) { categories, checked, tracker -> - buildList(categories.size + 1) { - add(header) - categories.mapTo(this) { cat -> - MangaCategoryItem( - category = cat, - isChecked = cat.id in checked, - isTrackerEnabled = tracker, - ) - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header)) - - init { - launchJob(Dispatchers.Default) { - checkedCategories.value = favouritesRepository.getCategoriesIds(manga.ids()) - } - } - - fun setChecked(categoryId: Long, isChecked: Boolean) { - launchJob(Dispatchers.Default) { - val checkedIds = checkedCategories.firstNotNull() - if (isChecked) { - checkedCategories.value = checkedIds + categoryId - favouritesRepository.addToCategory(categoryId, manga) - } else { - checkedCategories.value = checkedIds - categoryId - favouritesRepository.removeFromCategory(categoryId, manga.ids()) - } - } - } -} 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 2b7ad5686..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.adapter - -import android.view.View -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -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() = 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 -> FavouriteCategoriesActivity.newIntent(v.context) - else -> return@OnClickListener - } - v.context.startActivity(intent) - } - - binding.chipCreate.setOnClickListener(onClickListener) - binding.chipManage.setOnClickListener(onClickListener) -} 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 deleted file mode 100644 index e8ab695a4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.adapter - -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.model.ListModel - -class MangaCategoriesAdapter( - clickListener: OnListItemClickListener, -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(mangaCategoryAD(clickListener)) - .addDelegate(categoriesHeaderAD()) - } -} 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 deleted file mode 100644 index 67b269c0d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.adapter - -import androidx.core.view.isVisible -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -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 -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun mangaCategoryAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemCategoryCheckableBinding.inflate(inflater, parent, false) }, -) { - - itemView.setOnClickListener { - clickListener.onItemClick(item, itemView) - } - - bind { payloads -> - 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.imageViewVisible.isVisible = item.category.isVisibleInLibrary - } -} 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 8f7f34bb0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -class CategoriesHeaderItem : 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 deleted file mode 100644 index d5b09a9ca..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.model - -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 isTrackerEnabled: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaCategoryItem && other.category.id == category.id - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is MangaCategoryItem && previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabModel.kt deleted file mode 100644 index 684c1fa72..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class FavouriteTabModel( - val id: Long, - val title: String, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is FavouriteTabModel && other.id == id - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt deleted file mode 100644 index c9daa207d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentStatePagerAdapter -import kotlinx.coroutines.flow.FlowCollector -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment -import org.koitharu.kotatsu.parsers.util.replaceWith - -@Suppress("DEPRECATION") -class FavouritesContainerAdapter( - fm: FragmentManager -) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT), - FlowCollector> { - - private val dataSet = ArrayList() - - override fun getCount(): Int = dataSet.size - - override fun getItem(position: Int): Fragment { - val item = dataSet[position] - return FavouritesListFragment.newInstance(item.id) - } - - override fun getPageTitle(position: Int): CharSequence { - return dataSet[position].title - } - - override suspend fun emit(value: List) { - dataSet.replaceWith(value) - notifyDataSetChanged() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter2.kt deleted file mode 100644 index 4afab1c9f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter2.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.AdapterListUpdateCallback -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.flow.FlowCollector -import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import kotlin.coroutines.suspendCoroutine - -// FIXME migrate to ViewPager2 in FavouritesContainerFragment -class FavouritesContainerAdapter2(fragment: Fragment) : - FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle), - TabConfigurationStrategy, - FlowCollector> { - - private val differ = AsyncListDiffer( - AdapterListUpdateCallback(this), - AsyncDifferConfig.Builder(ListModelDiffCallback()) - .setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor()) - .build(), - ) - - override fun getItemCount(): Int = differ.currentList.size - - override fun getItemId(position: Int): Long { - return differ.currentList[position].id - } - - override fun containsItem(itemId: Long): Boolean { - return differ.currentList.any { x -> x.id == itemId } - } - - override fun createFragment(position: Int): Fragment { - val item = differ.currentList[position] - return FavouritesListFragment.newInstance(item.id) - } - - override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val item = differ.currentList[position] - tab.text = item.title - tab.tag = item - } - - override suspend fun emit(value: List) = suspendCoroutine { cont -> - differ.submitList(value, ContinuationResumeRunnable(cont)) - } -} 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 deleted file mode 100644 index efce0c7aa..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewStub -import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.util.ActionModeListener -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.setTabsEnabled -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding -import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding -import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity -import javax.inject.Inject - -@AndroidEntryPoint -class FavouritesContainerFragment : BaseFragment(), ActionModeListener, - ViewStub.OnInflateListener, View.OnClickListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel: FavouritesContainerViewModel by viewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentFavouritesContainerBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentFavouritesContainerBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = FavouritesContainerAdapter(childFragmentManager) - binding.pager.adapter = adapter - binding.tabs.setupWithViewPager(binding.pager) - binding.pager.offscreenPageLimit = 1 - binding.stubEmpty.setOnInflateListener(this) - actionModeDelegate.addListener(this) - viewModel.categories.observe(viewLifecycleOwner, adapter) - viewModel.isEmpty.observe(viewLifecycleOwner, ::onEmptyStateChanged) - addMenuProvider(FavouritesContainerMenuProvider(binding.root.context)) - } - - override fun onDestroyView() { - actionModeDelegate.removeListener(this) - super.onDestroyView() - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding?.tabs?.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - override fun onActionModeStarted(mode: ActionMode) { - viewBinding?.run { - pager.isUserInputEnabled = false - tabs.setTabsEnabled(false) - } - } - - override fun onActionModeFinished(mode: ActionMode) { - viewBinding?.run { - pager.isUserInputEnabled = true - tabs.setTabsEnabled(true) - } - } - - override fun onInflate(stub: ViewStub?, inflated: View) { - val stubBinding = ItemEmptyStateBinding.bind(inflated) - stubBinding.icon.newImageRequest(viewLifecycleOwner, R.drawable.ic_empty_favourites)?.enqueueWith(coil) - stubBinding.textPrimary.setText(R.string.text_empty_holder_primary) - stubBinding.textSecondary.setTextAndVisible(R.string.empty_favourite_categories) - stubBinding.buttonRetry.setTextAndVisible(R.string.manage) - stubBinding.buttonRetry.setOnClickListener(this) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_retry -> startActivity( - FavouriteCategoriesActivity.newIntent(v.context), - ) - } - } - - private fun onEmptyStateChanged(isEmpty: Boolean) { - viewBinding?.run { - pager.isGone = isEmpty - tabs.isGone = isEmpty - stubEmpty.isVisible = isEmpty - } - } -} 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 deleted file mode 100644 index 76f817c89..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -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.favourites.ui.categories.FavouriteCategoriesActivity - -class FavouritesContainerMenuProvider( - private val context: Context, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_favourites_container, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.action_manage -> { - context.startActivity(FavouriteCategoriesActivity.newIntent(context)) - } - - else -> return false - } - return true - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt deleted file mode 100644 index b80b45cce..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.container - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import javax.inject.Inject - -@HiltViewModel -class FavouritesContainerViewModel @Inject constructor( - favouritesRepository: FavouritesRepository, -) : BaseViewModel() { - - private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val categories = categoriesStateFlow.filterNotNull() - .mapItems { FavouriteTabModel(it.id, it.title) } - .distinctUntilChanged() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - val isEmpty = categoriesStateFlow.map { - it?.isEmpty() == true - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt deleted file mode 100644 index 86f0a83e6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.list - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.PopupMenu -import androidx.fragment.app.viewModels -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.MangaSource - -@AndroidEntryPoint -class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { - - override val viewModel by viewModels() - - override val isSwipeRefreshEnabled = false - - val categoryId - get() = viewModel.categoryId - - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - if (viewModel.categoryId != NO_ID) { - addMenuProvider(FavouritesListMenuProvider(binding.root.context, viewModel)) - } - } - - override fun onScrolledToEnd() = Unit - - override fun onFilterClick(view: View?) { - val menu = PopupMenu(view?.context ?: return, view) - menu.setOnMenuItemClickListener(this) - val orders = ListSortOrder.FAVORITES.sortedByOrdinal() - for ((i, item) in orders.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, item.titleResId) - } - menu.show() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - val order = ListSortOrder.FAVORITES.sortedByOrdinal().getOrNull(item.order) ?: return false - viewModel.setSortOrder(order) - return true - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_favourites, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { - it.source == MangaSource.LOCAL - } - return super.onPrepareActionMode(controller, mode, menu) - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_remove -> { - viewModel.removeFromFavourites(selectedItemsIds) - mode.finish() - true - } - - else -> super.onActionItemClicked(controller, mode, item) - } - } - - companion object { - - const val NO_ID = 0L - const val ARG_CATEGORY_ID = "category_id" - - fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { - putLong(ARG_CATEGORY_ID, categoryId) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt deleted file mode 100644 index ca134d8e8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.list - -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.favourites.ui.categories.edit.FavouritesCategoryEditActivity - -class FavouritesListMenuProvider( - private val context: Context, - private val viewModel: FavouritesListViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_favourites, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_edit -> { - context.startActivity(FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId)) - true - } - - else -> false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt deleted file mode 100644 index ae467b8af..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ /dev/null @@ -1,511 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import android.view.View -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.ViewModelLifecycle -import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.LocaleComparator -import org.koitharu.kotatsu.core.util.ext.asArrayList -import org.koitharu.kotatsu.core.util.ext.lifecycleScope -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem -import org.koitharu.kotatsu.list.ui.model.ErrorFooter -import org.koitharu.kotatsu.list.ui.model.ListHeader -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.toErrorFooter -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.SuspendLazy -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment -import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import java.text.Collator -import java.util.LinkedList -import java.util.Locale -import java.util.TreeSet -import javax.inject.Inject - -@ViewModelScoped -class FilterCoordinator @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - dataRepository: MangaDataRepository, - private val searchRepository: MangaSearchRepository, - lifecycle: ViewModelLifecycle, -) : MangaFilter { - - private val coroutineScope = lifecycle.lifecycleScope - private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) - private val currentState = MutableStateFlow( - MangaListFilter.Advanced( - sortOrder = repository.defaultSortOrder, - tags = emptySet(), - tagsExclude = emptySet(), - locale = null, - states = emptySet(), - contentRating = emptySet(), - ), - ) - private val localTags = SuspendLazy { - dataRepository.findTags(repository.source) - } - private val tagsFlow = flow { - val localTags = localTags.get() - emit(PendingData(localTags, isLoading = true, error = null)) - tryLoadTags() - .onSuccess { remoteTags -> - emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null)) - }.onFailure { - emit(PendingData(localTags, isLoading = false, error = it)) - } - }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), PendingData(emptySet(), true, null)) - private var availableTagsDeferred = loadTagsAsync() - private var availableLocalesDeferred = loadLocalesAsync() - private var allTagsLoadJob: Job? = null - - override val allTags = MutableStateFlow>(listOf(LoadingState)) - get() { - if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) { - loadAllTags() - } - return field - } - - override val filterTags: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.tags }, - getTopTagsAsFlow(currentState.map { it.tags }, 16), - ) { state, tags -> - FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tags, - isLoading = tags.isLoading, - error = tags.error, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val filterTagsExcluded: StateFlow> = if (repository.isTagsExclusionSupported) { - combine( - currentState.distinctUntilChangedBy { it.tagsExclude }, - getBottomTagsAsFlow(4), - ) { state, tags -> - FilterProperty( - availableItems = tags.items.asArrayList(), - selectedItems = state.tagsExclude, - isLoading = tags.isLoading, - error = tags.error, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - } else { - MutableStateFlow(emptyProperty()) - } - - override val filterSortOrder: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.sortOrder }, - flowOf(repository.sortOrders), - ) { state, orders -> - FilterProperty( - availableItems = orders.sortedBy { it.ordinal }, - selectedItems = setOf(state.sortOrder), - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val filterState: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.states }, - flowOf(repository.states), - ) { state, states -> - FilterProperty( - availableItems = states.sortedBy { it.ordinal }, - selectedItems = state.states, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val filterContentRating: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.contentRating }, - flowOf(repository.contentRatings), - ) { rating, ratings -> - FilterProperty( - availableItems = ratings.sortedBy { it.ordinal }, - selectedItems = rating.contentRating, - isLoading = false, - error = null, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val filterLocale: StateFlow> = combine( - currentState.distinctUntilChangedBy { it.locale }, - getLocalesAsFlow(), - ) { state, locales -> - val list = if (locales.items.isNotEmpty()) { - val l = ArrayList(locales.items.size + 1) - l.add(null) - l.addAll(locales.items) - try { - l.sortWith(nullsFirst(LocaleComparator())) - } catch (e: IllegalArgumentException) { - e.printStackTraceDebug() - } - l - } else { - emptyList() - } - FilterProperty( - availableItems = list, - selectedItems = setOf(state.locale), - isLoading = locales.isLoading, - error = locales.error, - ) - }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) - - override val header: StateFlow = getHeaderFlow().stateIn( - scope = coroutineScope + Dispatchers.Default, - started = SharingStarted.Lazily, - initialValue = FilterHeaderModel( - chips = emptyList(), - sortOrder = repository.defaultSortOrder, - isFilterApplied = false, - ), - ) - - override fun applyFilter(tags: Set) { - setTags(tags) - } - - override fun setSortOrder(value: SortOrder) { - currentState.update { oldValue -> - oldValue.copy(sortOrder = value) - } - repository.defaultSortOrder = value - } - - override fun setLanguage(value: Locale?) { - currentState.update { oldValue -> - oldValue.copy(locale = value) - } - } - - override fun setTag(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tags + value - } else { - oldValue.tags - value - } - } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } - } - oldValue.copy( - tags = newTags, - tagsExclude = oldValue.tagsExclude - newTags, - ) - } - } - - override fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newTags = if (repository.isMultipleTagsSupported) { - if (addOrRemove) { - oldValue.tagsExclude + value - } else { - oldValue.tagsExclude - value - } - } else { - if (addOrRemove) { - setOf(value) - } else { - emptySet() - } - } - oldValue.copy( - tagsExclude = newTags, - tags = oldValue.tags - newTags - ) - } - } - - override fun setState(value: MangaState, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newStates = if (addOrRemove) { - oldValue.states + value - } else { - oldValue.states - value - } - oldValue.copy(states = newStates) - } - } - - override fun setContentRating(value: ContentRating, addOrRemove: Boolean) { - currentState.update { oldValue -> - val newRating = if (addOrRemove) { - oldValue.contentRating + value - } else { - oldValue.contentRating - value - } - oldValue.copy(contentRating = newRating) - } - } - - override fun onListHeaderClick(item: ListHeader, view: View) { - currentState.update { oldValue -> - oldValue.copy( - sortOrder = oldValue.sortOrder, - tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, - locale = if (item.payload == R.string.language) null else oldValue.locale, - states = if (item.payload == R.string.state) emptySet() else oldValue.states, - ) - } - } - - fun observeAvailableTags(): Flow?> = flow { - if (!availableTagsDeferred.isCompleted) { - emit(emptySet()) - } - emit(availableTagsDeferred.await().getOrNull()) - } - - fun observeState() = currentState.asStateFlow() - - fun setTags(tags: Set) { - currentState.update { oldValue -> - oldValue.copy( - tags = tags, - tagsExclude = oldValue.tagsExclude - tags - ) - } - } - - fun reset() { - currentState.update { oldValue -> - MangaListFilter.Advanced.Builder(oldValue.sortOrder).build() - } - } - - fun snapshot() = currentState.value - - private fun getHeaderFlow() = combine( - observeState(), - observeAvailableTags(), - ) { state, available -> - val chips = createChipsList(state, available.orEmpty(), 8) - FilterHeaderModel( - chips = chips, - sortOrder = state.sortOrder, - isFilterApplied = !state.isEmpty(), - ) - } - - private fun getLocalesAsFlow(): Flow> = flow { - emit(PendingData(emptySet(), isLoading = true, error = null)) - tryLoadLocales() - .onSuccess { locales -> - emit(PendingData(locales, isLoading = false, error = null)) - }.onFailure { - emit(PendingData(emptySet(), isLoading = false, error = it)) - } - } - - private fun getTopTagsAsFlow(selectedTags: Flow>, limit: Int): Flow> = combine( - selectedTags.map { - if (it.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) - } else { - searchRepository.getTagsSuggestion(it).take(limit) - } - }, - tagsFlow, - ) { suggested, all -> - val res = suggested.toMutableList() - if (res.size < limit) { - res.addAll(all.items.shuffled().take(limit - res.size)) - } - PendingData(res, all.isLoading, all.error.takeIf { res.size < limit }) - } - - private fun getBottomTagsAsFlow(limit: Int): Flow> = combine( - flow { emit(searchRepository.getRareTags(repository.source, limit)) }, - tagsFlow, - ) { suggested, all -> - val res = suggested.toMutableList() - if (res.size < limit) { - res.addAll(all.items.shuffled().take(limit - res.size)) - } - PendingData(res, all.isLoading, all.error.takeIf { res.size < limit }) - } - - private suspend fun createChipsList( - filterState: MangaListFilter.Advanced, - availableTags: Set, - limit: Int, - ): List { - val selectedTags = filterState.tags.toMutableSet() - var tags = if (selectedTags.isEmpty()) { - searchRepository.getTagsSuggestion("", limit, repository.source) - } else { - searchRepository.getTagsSuggestion(selectedTags).take(limit) - } - if (tags.size < limit) { - tags = tags + availableTags.take(limit - tags.size) - } - if (tags.isEmpty() && selectedTags.isEmpty()) { - return emptyList() - } - val result = LinkedList() - for (tag in tags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = selectedTags.remove(tag), - data = tag, - ) - if (model.isChecked) { - result.addFirst(model) - } else { - result.addLast(model) - } - } - for (tag in selectedTags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = true, - data = tag, - ) - result.addFirst(model) - } - return result - } - - private suspend fun tryLoadTags(): Result> { - val shouldRetryOnError = availableTagsDeferred.isCompleted - val result = availableTagsDeferred.await() - if (result.isFailure && shouldRetryOnError) { - availableTagsDeferred = loadTagsAsync() - return availableTagsDeferred.await() - } - return result - } - - private suspend fun tryLoadLocales(): Result> { - val shouldRetryOnError = availableLocalesDeferred.isCompleted - val result = availableLocalesDeferred.await() - if (result.isFailure && shouldRetryOnError) { - availableLocalesDeferred = loadLocalesAsync() - return availableLocalesDeferred.await() - } - return result - } - - private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { - runCatchingCancellable { - repository.getTags() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { - runCatchingCancellable { - repository.getLocales() - }.onFailure { error -> - error.printStackTraceDebug() - } - } - - private fun mergeTags(primary: Set, secondary: Set): Set { - val result = TreeSet(TagTitleComparator(repository.source.locale)) - result.addAll(secondary) - result.addAll(primary) - return result - } - - private fun loadAllTags() { - val prevJob = allTagsLoadJob - allTagsLoadJob = coroutineScope.launch(Dispatchers.Default) { - runCatchingCancellable { - prevJob?.cancelAndJoin() - appendTagsList(localTags.get(), isLoading = true) - appendTagsList(availableTagsDeferred.await().getOrThrow(), isLoading = false) - }.onFailure { e -> - allTags.value = allTags.value.filterIsInstance() + e.toErrorFooter() - } - } - } - - private fun appendTagsList(newTags: Collection, isLoading: Boolean) = allTags.update { oldList -> - val oldTags = oldList.filterIsInstance() - buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) { - addAll(oldTags) - newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) } - val tempSet = HashSet(size) - removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) } - sortBy { (it as TagCatalogItem).tag.title } - if (isLoading) { - add(LoadingFooter()) - } - } - } - - private data class PendingData( - val items: Collection, - val isLoading: Boolean, - val error: Throwable?, - ) - - private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) - - private fun emptyProperty() = FilterProperty(emptyList(), emptySet(), false, null) - - private class TagTitleComparator(lc: String?) : Comparator { - - private val collator = lc?.let { Collator.getInstance(Locale(it)) } - - override fun compare(o1: MangaTag, o2: MangaTag): Int { - val t1 = o1.title.lowercase() - val t2 = o2.title.lowercase() - return collator?.compare(t1, t2) ?: compareValues(t1, t2) - } - } -} 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 deleted file mode 100644 index 546c3892a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import com.google.android.material.chip.Chip -import org.koitharu.kotatsu.R -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.MangaTag -import com.google.android.material.R as materialR - -class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener { - - private val filter: MangaFilter - get() = (requireActivity() as FilterOwner).filter - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { - return FragmentFilterHeaderBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.chipsTags.onChipClickListener = this - filter.header.observe(viewLifecycleOwner, ::onDataChanged) - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag - if (tag == null) { - TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) - } else { - filter.setTag(tag, chip.isChecked) - } - } - - private fun onDataChanged(header: FilterHeaderModel) { - val binding = viewBinding ?: return - val chips = header.chips - if (chips.isEmpty()) { - binding.chipsTags.setChips(emptyList()) - binding.root.isVisible = false - return - } - if (binding.root.context.isAnimationsEnabled) { - binding.scrollView.smoothScrollTo(0, 0) - } else { - binding.scrollView.scrollTo(0, 0) - } - binding.chipsTags.setChips(header.chips + moreTagsChip()) - binding.root.isVisible = true - } - - private fun moreTagsChip() = ChipsView.ChipModel( - tint = 0, - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - isCheckable = false, - isChecked = false, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt deleted file mode 100644 index d75f81c4a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -interface FilterOwner { - - val filter: MangaFilter -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt deleted file mode 100644 index 26fcf6fab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.Locale - -interface MangaFilter : OnFilterChangedListener { - - val allTags: StateFlow> - - val filterTags: StateFlow> - - val filterTagsExcluded: StateFlow> - - val filterSortOrder: StateFlow> - - val filterState: StateFlow> - - val filterContentRating: StateFlow> - - val filterLocale: StateFlow> - - val header: StateFlow - - fun applyFilter(tags: Set) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt deleted file mode 100644 index 785f32ec6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.Locale - -interface OnFilterChangedListener : ListHeaderClickListener { - - fun setSortOrder(value: SortOrder) - - fun setLanguage(value: Locale?) - - fun setTag(value: MangaTag, addOrRemove: Boolean) - - fun setTagExcluded(value: MangaTag, addOrRemove: Boolean) - - fun setState(value: MangaState, addOrRemove: Boolean) - - fun setContentRating(value: ContentRating, addOrRemove: Boolean) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt deleted file mode 100644 index 468c1130b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.model - -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.parsers.model.SortOrder - -data class FilterHeaderModel( - val chips: Collection, - val sortOrder: SortOrder?, - val isFilterApplied: Boolean, -) { - - val textSummary: String - get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt deleted file mode 100644 index a05157a3d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.model - -data class FilterProperty( - val availableItems: List, - val selectedItems: Set, - val isLoading: Boolean, - val error: Throwable?, -) { - - fun isEmpty(): Boolean = availableItems.isEmpty() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt deleted file mode 100644 index 9cd7fc2b9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.MangaTag - -data class TagCatalogItem( - val tag: MangaTag, - val isChecked: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is TagCatalogItem && other.tag == tag - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} 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 deleted file mode 100644 index ac9bb8b27..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ /dev/null @@ -1,275 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.sheet - -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import androidx.core.view.isGone -import androidx.core.view.updatePadding -import androidx.fragment.app.FragmentManager -import com.google.android.material.chip.Chip -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.titleResId -import org.koitharu.kotatsu.core.ui.model.titleRes -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.SheetFilterBinding -import org.koitharu.kotatsu.filter.ui.FilterOwner -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.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.toTitleCase -import java.util.Locale -import com.google.android.material.R as materialR - -class FilterSheetFragment : BaseAdaptiveSheet(), - AdapterView.OnItemSelectedListener, - ChipsView.OnChipClickListener { - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { - return SheetFilterBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - if (dialog == null) { - binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding.scrollView.scrollIndicators = 0 - } - } - val filter = requireFilter() - filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) - filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) - filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) - filter.filterTagsExcluded.observe(viewLifecycleOwner, this::onTagsExcludedChanged) - filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) - filter.filterContentRating.observe(viewLifecycleOwner, this::onContentRatingChanged) - - binding.spinnerLocale.onItemSelectedListener = this - binding.spinnerOrder.onItemSelectedListener = this - binding.chipsState.onChipClickListener = this - binding.chipsContentRating.onChipClickListener = this - binding.chipsGenres.onChipClickListener = this - binding.chipsGenresExclude.onChipClickListener = this - } - - override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { - val filter = requireFilter() - when (parent.id) { - R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position]) - R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position]) - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) = Unit - - override fun onChipClick(chip: Chip, data: Any?) { - val filter = requireFilter() - when (data) { - is MangaState -> filter.setState(data, chip.isChecked) - is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) { - filter.setTagExcluded(data, chip.isChecked) - } else { - filter.setTag(data, chip.isChecked) - } - - is ContentRating -> filter.setContentRating(data, chip.isChecked) - null -> TagsCatalogSheet.show(childFragmentManager, chip.parentView?.id == R.id.chips_genresExclude) - } - } - - private fun onSortOrderChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewOrderTitle.isGone = value.isEmpty() - b.cardOrder.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val selected = value.selectedItems.single() - b.spinnerOrder.adapter = ArrayAdapter( - b.spinnerOrder.context, - android.R.layout.simple_spinner_dropdown_item, - android.R.id.text1, - value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, - ) - val selectedIndex = value.availableItems.indexOf(selected) - if (selectedIndex >= 0) { - b.spinnerOrder.setSelection(selectedIndex, false) - } - } - - private fun onLocaleChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewLocaleTitle.isGone = value.isEmpty() - b.cardLocale.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val selected = value.selectedItems.singleOrNull() - b.spinnerLocale.adapter = ArrayAdapter( - b.spinnerLocale.context, - android.R.layout.simple_spinner_dropdown_item, - android.R.id.text1, - value.availableItems.map { - it?.getDisplayLanguage(it)?.toTitleCase(it) - ?: b.spinnerLocale.context.getString(R.string.various_languages) - }, - ) - val selectedIndex = value.availableItems.indexOf(selected) - if (selectedIndex >= 0) { - b.spinnerLocale.setSelection(selectedIndex, false) - } - } - - private fun onTagsChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewGenresTitle.isGone = value.isEmpty() - b.chipsGenres.isGone = value.isEmpty() - b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources) - if (value.isEmpty()) { - return - } - val chips = ArrayList(value.selectedItems.size + value.availableItems.size + 1) - value.selectedItems.mapTo(chips) { tag -> - ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = true, - data = tag, - ) - } - value.availableItems.mapNotNullTo(chips) { tag -> - if (tag !in value.selectedItems) { - ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = false, - data = tag, - ) - } else { - null - } - } - chips.add( - ChipsView.ChipModel( - tint = 0, - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - isCheckable = false, - isChecked = false, - data = null, - ), - ) - b.chipsGenres.setChips(chips) - } - - private fun onTagsExcludedChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewGenresExcludeTitle.isGone = value.isEmpty() - b.chipsGenresExclude.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val chips = ArrayList(value.selectedItems.size + value.availableItems.size + 1) - value.selectedItems.mapTo(chips) { tag -> - ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = true, - data = tag, - ) - } - value.availableItems.mapNotNullTo(chips) { tag -> - if (tag !in value.selectedItems) { - ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - isCheckable = true, - isChecked = false, - data = tag, - ) - } else { - null - } - } - chips.add( - ChipsView.ChipModel( - tint = 0, - title = getString(R.string.more), - icon = materialR.drawable.abc_ic_menu_overflow_material, - isCheckable = false, - isChecked = false, - data = null, - ), - ) - b.chipsGenresExclude.setChips(chips) - } - - private fun onStateChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewStateTitle.isGone = value.isEmpty() - b.chipsState.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val chips = value.availableItems.map { state -> - ChipsView.ChipModel( - tint = 0, - title = getString(state.titleResId), - icon = 0, - isCheckable = true, - isChecked = state in value.selectedItems, - data = state, - ) - } - b.chipsState.setChips(chips) - } - - private fun onContentRatingChanged(value: FilterProperty) { - val b = viewBinding ?: return - b.textViewContentRatingTitle.isGone = value.isEmpty() - b.chipsContentRating.isGone = value.isEmpty() - if (value.isEmpty()) { - return - } - val chips = value.availableItems.map { contentRating -> - ChipsView.ChipModel( - tint = 0, - title = getString(contentRating.titleResId), - icon = 0, - isCheckable = true, - isChecked = contentRating in value.selectedItems, - data = contentRating, - ) - } - b.chipsContentRating.setChips(chips) - } - - private fun requireFilter() = (requireActivity() as FilterOwner).filter - - companion object { - - private const val TAG = "FilterSheet" - - fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt deleted file mode 100644 index 1fad15e5b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.tags - -import android.content.Context -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.core.util.ext.setChecked -import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding -import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD -import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD -import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD -import org.koitharu.kotatsu.list.ui.model.ListModel - -class TagsCatalogAdapter( - listener: OnListItemClickListener, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener)) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null)) - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase() - } - - private fun tagCatalogDelegate( - listener: OnListItemClickListener, - ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) }, - ) { - - itemView.setOnClickListener { - listener.onItemClick(item, itemView) - } - - bind { payloads -> - binding.root.text = item.tag.title - binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) - } - } -} 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 deleted file mode 100644 index dca92f60c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt +++ /dev/null @@ -1,102 +0,0 @@ -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.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.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.FilterOwner -import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem - -@AndroidEntryPoint -class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickListener, TextWatcher, - AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener { - - private val viewModel by viewModels( - extrasProducer = { - defaultViewModelCreationExtras.withCreationCallback { factory -> - factory.create( - filter = (requireActivity() as FilterOwner).filter, - isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE), - ) - } - }, - ) - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding { - return SheetTagsBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = TagsCatalogAdapter(this) - binding.recyclerView.adapter = adapter - binding.recyclerView.setHasFixedSize(true) - binding.editSearch.setText(viewModel.searchQuery.value) - binding.editSearch.addTextChangedListener(this) - binding.editSearch.onFocusChangeListener = this - binding.editSearch.setOnEditorActionListener(this) - viewModel.content.observe(viewLifecycleOwner, adapter) - addSheetCallback(this) - disableFitToContents() - } - - override fun onItemClick(item: TagCatalogItem, view: View) { - viewModel.handleTagClick(item.tag, item.isChecked) - } - - override fun onFocusChange(v: View?, hasFocus: Boolean) { - setExpanded( - isExpanded = hasFocus || isExpanded, - isLocked = hasFocus, - ) - } - - override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean { - return if (actionId == EditorInfo.IME_ACTION_SEARCH) { - v.clearFocus() - true - } else { - false - } - } - - 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 - } - - 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/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt deleted file mode 100644 index 092c6950c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.koitharu.kotatsu.filter.ui.tags - -import androidx.lifecycle.viewModelScope -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -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.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.filter.ui.MangaFilter -import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.parsers.model.MangaTag - -@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class) -class TagsCatalogViewModel @AssistedInject constructor( - @Assisted private val filter: MangaFilter, - @Assisted private val isExcluded: Boolean, -) : BaseViewModel() { - - val searchQuery = MutableStateFlow("") - - private val filterProperty: StateFlow> - get() = if (isExcluded) filter.filterTagsExcluded else filter.filterTags - - private val tags = combine( - filter.allTags, - filterProperty.map { it.selectedItems }, - ) { all, selected -> - all.map { x -> - if (x is TagCatalogItem) { - val checked = x.tag in selected - if (x.isChecked == checked) { - x - } else { - x.copy(isChecked = checked) - } - } else { - x - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value) - - val content = combine(tags, searchQuery) { raw, query -> - raw.filter { x -> - x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) - - fun handleTagClick(tag: MangaTag, isChecked: Boolean) { - if (isExcluded) { - filter.setTagExcluded(tag, !isChecked) - } else { - filter.setTag(tag, !isChecked) - } - } - - @AssistedFactory - interface Factory { - fun create(filter: MangaFilter, isExcludeTag: Boolean): TagsCatalogViewModel - } - -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt deleted file mode 100644 index ed2deec64..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.koitharu.kotatsu.history.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RawQuery -import androidx.room.Transaction -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.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.TagEntity -import org.koitharu.kotatsu.list.domain.ListSortOrder - -@Dao -abstract class HistoryDao { - - @Transaction - @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") - abstract suspend fun findAll(offset: Int, limit: Int): List - - @Transaction - @Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)") - abstract suspend fun findAll(ids: Collection): List - - @Transaction - @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") - abstract fun observeAll(): Flow> - - @Transaction - @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") - abstract fun observeAll(limit: Int): Flow> - - fun observeAll(order: ListSortOrder): Flow> { - val orderBy = when (order) { - ListSortOrder.UPDATED -> "history.updated_at DESC" - ListSortOrder.NEWEST -> "history.created_at DESC" - ListSortOrder.PROGRESS -> "history.percent DESC" - ListSortOrder.ALPHABETIC -> "manga.title" - ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC" - else -> throw IllegalArgumentException("Sort order $order is not supported") - } - - @Language("RoomSql") - val query = SimpleSQLiteQuery( - "SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " + - "WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy", - ) - return observeAllImpl(query) - } - - @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)") - abstract suspend fun findAllManga(): List - - @Query( - """SELECT tags.* FROM tags - LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id - INNER JOIN history ON history.manga_id = manga_tags.manga_id - WHERE history.deleted_at = 0 - GROUP BY manga_tags.tag_id - ORDER BY COUNT(manga_tags.manga_id) DESC - LIMIT :limit""", - ) - abstract suspend fun findPopularTags(limit: Int): List - - @Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0") - abstract suspend fun find(id: Long): HistoryEntity? - - @Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0") - abstract fun observe(id: Long): Flow - - @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0") - abstract fun observeCount(): Flow - - @Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0") - abstract suspend fun findProgress(id: Long): Float? - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insert(entity: HistoryEntity): Long - - @Query( - "UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId", - ) - abstract suspend fun update( - mangaId: Long, - page: Int, - chapterId: Long, - scroll: Float, - percent: Float, - updatedAt: Long, - ): Int - - suspend fun delete(mangaId: Long) = setDeletedAt(mangaId, System.currentTimeMillis()) - - suspend fun recover(mangaId: Long) = setDeletedAt(mangaId, 0L) - - @Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime") - abstract suspend fun gc(maxDeletionTime: Long) - - suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis()) - - suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis()) - - suspend fun update(entity: HistoryEntity) = update( - mangaId = entity.mangaId, - page = entity.page, - chapterId = entity.chapterId, - scroll = entity.scroll, - percent = entity.percent, - updatedAt = entity.updatedAt, - ) - - @Transaction - open suspend fun upsert(entity: HistoryEntity): Boolean { - return if (update(entity) == 0) { - insert(entity) - true - } else false - } - - @Transaction - open suspend fun upsert(entities: Iterable) { - for (e in entities) { - if (update(e) == 0) { - insert(e) - } - } - } - - @Query("UPDATE history SET deleted_at = :deletedAt WHERE manga_id = :mangaId") - protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long) - - @Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0") - protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long) - - @Transaction - @RawQuery(observedEntities = [HistoryEntity::class]) - protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow> -} 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 deleted file mode 100644 index 5aae882ae..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ /dev/null @@ -1,195 +0,0 @@ -package org.koitharu.kotatsu.history.data - -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.toEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTag -import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.core.model.findById -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.util.ReversibleHandle -import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.history.domain.model.MangaWithHistory -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -const val PROGRESS_NONE = -1f - -@Reusable -class HistoryRepository @Inject constructor( - private val db: MangaDatabase, - private val trackingRepository: TrackingRepository, - private val settings: AppSettings, - private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, - private val mangaRepository: MangaDataRepository, -) { - - suspend fun getList(offset: Int, limit: Int): List { - val entities = db.getHistoryDao().findAll(offset, limit) - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } - } - - suspend fun getLastOrNull(): Manga? { - val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null - return entity.manga.toManga(entity.tags.toMangaTags()) - } - - fun observeLast(): Flow { - return db.getHistoryDao().observeAll(1).map { - val first = it.firstOrNull() - first?.manga?.toManga(first.tags.toMangaTags()) - } - } - - fun observeAll(): Flow> { - return db.getHistoryDao().observeAll().mapItems { - it.manga.toManga(it.tags.toMangaTags()) - } - } - - fun observeAll(limit: Int): Flow> { - return db.getHistoryDao().observeAll(limit).mapItems { - it.manga.toManga(it.tags.toMangaTags()) - } - } - - fun observeAllWithHistory(order: ListSortOrder): Flow> { - return db.getHistoryDao().observeAll(order).mapItems { - MangaWithHistory( - it.manga.toManga(it.tags.toMangaTags()), - it.history.toMangaHistory(), - ) - } - } - - fun observeOne(id: Long): Flow { - return db.getHistoryDao().observe(id).map { - it?.toMangaHistory() - } - } - - fun observeHasItems(): Flow { - return db.getHistoryDao().observeCount() - .map { it > 0 } - .distinctUntilChanged() - } - - suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) { - if (shouldSkip(manga)) { - return - } - db.withTransaction { - mangaRepository.storeManga(manga) - db.getHistoryDao().upsert( - HistoryEntity( - mangaId = manga.id, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - chapterId = chapterId, - page = page, - scroll = scroll.toFloat(), // we migrate to int, but decide to not update database - percent = percent, - deletedAt = 0L, - ), - ) - trackingRepository.syncWithHistory(manga, chapterId) - val chapter = manga.chapters?.findById(chapterId) - if (chapter != null) { - scrobblers.forEach { it.tryScrobble(manga.id, chapter) } - } - } - } - - suspend fun getOne(manga: Manga): MangaHistory? { - return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() - } - - suspend fun getProgress(mangaId: Long): Float { - return db.getHistoryDao().findProgress(mangaId) ?: PROGRESS_NONE - } - - suspend fun clear() { - db.getHistoryDao().clear() - } - - suspend fun delete(manga: Manga) { - db.getHistoryDao().delete(manga.id) - } - - suspend fun deleteAfter(minDate: Long) { - db.getHistoryDao().deleteAfter(minDate) - } - - suspend fun delete(ids: Collection): ReversibleHandle { - db.withTransaction { - for (id in ids) { - db.getHistoryDao().delete(id) - } - } - return ReversibleHandle { - recover(ids) - } - } - - /** - * Try to replace one manga with another one - * Useful for replacing saved manga on deleting it with remote source - */ - suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { - if (alternative == null || db.getMangaDao().update(alternative.toEntity()) <= 0) { - db.getHistoryDao().delete(manga.id) - } - } - - suspend fun getPopularTags(limit: Int): List { - return db.getHistoryDao().findPopularTags(limit).map { x -> x.toMangaTag() } - } - - fun shouldSkip(manga: Manga): Boolean { - return manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled - } - - fun observeShouldSkip(manga: Manga): Flow { - return settings.observe() - .filter { key -> key == AppSettings.KEY_INCOGNITO_MODE || key == AppSettings.KEY_HISTORY_EXCLUDE_NSFW } - .onStart { emit("") } - .map { shouldSkip(manga) } - .distinctUntilChanged() - } - - private suspend fun recover(ids: Collection) { - db.withTransaction { - for (id in ids) { - db.getHistoryDao().recover(id) - } - } - } - - private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { - val chapters = manga.chapters - if (manga.isLocal || chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) { - return this - } - val newChapterId = chapters.getOrNull( - (chapters.size * percent).toInt(), - )?.id ?: return this - val newEntity = copy(chapterId = newChapterId) - db.getHistoryDao().update(newEntity) - return newEntity - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt deleted file mode 100644 index a0af9d1a8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/domain/HistoryUpdateUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.koitharu.kotatsu.history.domain - -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.ui.ReaderState -import javax.inject.Inject - -class HistoryUpdateUseCase @Inject constructor( - private val historyRepository: HistoryRepository, -) { - - suspend operator fun invoke(manga: Manga, readerState: ReaderState, percent: Float) { - historyRepository.addOrUpdate( - manga = manga, - chapterId = readerState.chapterId, - page = readerState.page, - scroll = readerState.scroll, - percent = percent, - ) - } - - fun invokeAsync( - manga: Manga, - readerState: ReaderState, - percent: Float - ) = processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) { - runCatchingCancellable { - withContext(NonCancellable) { - invoke(manga, readerState, percent) - } - }.onFailure { - it.printStackTraceDebug() - } - } -} 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 deleted file mode 100644 index 5c05fbf46..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.history.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner - -@AndroidEntryPoint -class HistoryActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = HistoryListFragment() - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, HistoryActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt deleted file mode 100644 index 323eba0d3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.koitharu.kotatsu.history.ui - -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter -import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver - -class HistoryListAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: MangaListListener, - sizeResolver: ItemSizeResolver, -) : MangaListAdapter(coil, lifecycleOwner, listener, sizeResolver), FastScroller.SectionIndexer { - - override fun getSectionText(context: Context, position: Int): CharSequence? { - val list = items - for (i in (0..position).reversed()) { - val item = list.getOrNull(i) ?: continue - if (item is ListHeader) { - return item.getText(context) - } - } - return null - } -} 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 deleted file mode 100644 index ed6a4718e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.history.ui - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -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.os.NetworkManageIntent -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper -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.list.ui.size.DynamicItemSizeResolver -import org.koitharu.kotatsu.parsers.model.MangaSource - -@AndroidEntryPoint -class HistoryListFragment : MangaListFragment() { - - override val viewModel by viewModels() - override val isSwipeRefreshEnabled = false - - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - RecyclerScrollKeeper(binding.recyclerView).attach() - addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) - } - - override fun onScrolledToEnd() = Unit - - override fun onEmptyActionClick() { - startActivity(NetworkManageIntent()) - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_history, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { - it.source == MangaSource.LOCAL - } - return super.onPrepareActionMode(controller, mode, menu) - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_remove -> { - viewModel.removeFromHistory(selectedItemsIds) - mode.finish() - true - } - - else -> super.onActionItemClicked(controller, mode, item) - } - } - - override fun onCreateAdapter() = HistoryListAdapter( - coil, - viewLifecycleOwner, - this, - DynamicItemSizeResolver(resources, settings, adjustWidth = false), - ) -} 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 deleted file mode 100644 index 08b1f82f4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.koitharu.kotatsu.history.ui - -import android.content.Context -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.core.view.MenuProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import com.google.android.material.R as materialR - -class HistoryListMenuProvider( - private val context: Context, - private val viewModel: HistoryListViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_history, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_clear_history -> { - showClearHistoryDialog() - true - } - - else -> false - } - } - - private fun showClearHistoryDialog() { - val selectionListener = RememberSelectionDialogListener(2) - MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) - .setTitle(R.string.clear_history) - .setSingleChoiceItems( - arrayOf( - context.getString(R.string.last_2_hours), - context.getString(R.string.today), - context.getString(R.string.clear_all_history), - ), - selectionListener.selection, - selectionListener, - ) - .setIcon(R.drawable.ic_delete) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - val minDate = when (selectionListener.selection) { - 0 -> Instant.now().minus(2, ChronoUnit.HOURS) - 1 -> LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant() - 2 -> Instant.EPOCH - else -> return@setPositiveButton - } - viewModel.clearHistory(minDate) - }.show() - } -} 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 deleted file mode 100644 index aec6e8055..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.koitharu.kotatsu.history.ui - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.MangaHistory -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.onFirst -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.history.domain.model.MangaWithHistory -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyHint -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import java.time.Instant -import javax.inject.Inject - -@HiltViewModel -class HistoryListViewModel @Inject constructor( - private val repository: HistoryRepository, - settings: AppSettings, - private val extraProvider: ListExtraProvider, - private val localMangaRepository: LocalMangaRepository, - networkState: NetworkState, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { - - private val sortOrder: StateFlow = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.IO, - key = AppSettings.KEY_HISTORY_ORDER, - valueProducer = { historySortOrder }, - ) - - override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode) - - private val isGroupingEnabled = settings.observeAsFlow( - key = AppSettings.KEY_HISTORY_GROUPING, - valueProducer = { isHistoryGroupingEnabled }, - ).combine(sortOrder) { g, s -> - g && s.isGroupingSupported() - } - - override val content = combine( - sortOrder.flatMapLatest { repository.observeAllWithHistory(it) }, - isGroupingEnabled, - listMode, - networkState, - ) { list, grouped, mode, online -> - when { - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_history, - textPrimary = R.string.text_history_holder_primary, - textSecondary = R.string.text_history_holder_secondary, - actionStringRes = 0, - ), - ) - - else -> mapList(list, grouped, mode, online) - } - }.onStart { - loadingCounter.increment() - }.onFirst { - loadingCounter.decrement() - }.catch { - emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - override fun onRefresh() = Unit - - override fun onRetry() = Unit - - fun clearHistory(minDate: Instant) { - launchJob(Dispatchers.Default) { - val stringRes = if (minDate <= Instant.EPOCH) { - repository.clear() - R.string.history_cleared - } else { - repository.deleteAfter(minDate.toEpochMilli()) - R.string.removed_from_history - } - onActionDone.call(ReversibleAction(stringRes, null)) - } - } - - fun removeFromHistory(ids: Set) { - if (ids.isEmpty()) { - return - } - launchJob(Dispatchers.Default) { - val handle = repository.delete(ids) - onActionDone.call(ReversibleAction(R.string.removed_from_history, handle)) - } - } - - private suspend fun mapList( - list: List, - grouped: Boolean, - mode: ListMode, - isOnline: Boolean, - ): List { - val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1) - val order = sortOrder.value - var prevHeader: ListHeader? = null - if (!isOnline) { - result += EmptyHint( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.network_unavailable, - textSecondary = R.string.network_unavailable_hint, - actionStringRes = R.string.manage, - ) - } - for ((m, history) in list) { - val manga = if (!isOnline && !m.isLocal) { - localMangaRepository.findSavedManga(m)?.manga ?: continue - } else { - m - } - if (grouped) { - val header = history.header(order) - if (header != prevHeader) { - if (header != null) { - result += header - } - prevHeader = header - } - } - result += when (mode) { - ListMode.LIST -> manga.toListModel(extraProvider) - ListMode.DETAILED_LIST -> manga.toListDetailedModel(extraProvider) - ListMode.GRID -> manga.toGridModel(extraProvider) - } - } - return result - } - - private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) { - ListSortOrder.UPDATED -> ListHeader(calculateTimeAgo(updatedAt)) - ListSortOrder.NEWEST -> ListHeader(calculateTimeAgo(createdAt)) - ListSortOrder.PROGRESS -> ListHeader( - when (percent) { - 1f -> R.string.status_completed - in 0f..0.01f -> R.string.status_planned - in 0f..1f -> R.string.status_reading - else -> R.string.unknown - }, - ) - - ListSortOrder.ALPHABETIC, - ListSortOrder.RELEVANCE, - ListSortOrder.NEW_CHAPTERS, - ListSortOrder.RATING -> null - } -} 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 deleted file mode 100644 index 56a11f004..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.koitharu.kotatsu.image.ui - -import android.content.Context -import android.content.Intent -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.graphics.drawable.toBitmap -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import coil.ImageLoader -import coil.request.CachePolicy -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.target.ViewTarget -import com.davemorrissey.labs.subscaleview.ImageSource -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getDisplayIcon -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -import org.koitharu.kotatsu.databinding.ActivityImageBinding -import org.koitharu.kotatsu.databinding.ItemErrorStateBinding -import org.koitharu.kotatsu.parsers.model.MangaSource -import javax.inject.Inject - -@AndroidEntryPoint -class ImageActivity : BaseActivity(), ImageRequest.Listener, View.OnClickListener { - - @Inject - lateinit var coil: ImageLoader - - private var errorBinding: ItemErrorStateBinding? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityImageBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setDisplayShowTitleEnabled(false) - } - loadImage(intent.data) - } - - override fun onWindowInsetsChanged(insets: Insets) { - with(viewBinding.toolbar) { - updatePadding( - left = insets.left, - right = insets.right, - ) - updateLayoutParams { - topMargin = insets.top - } - } - } - - override fun onClick(v: View?) { - loadImage(intent.data) - } - - override fun onError(request: ImageRequest, result: ErrorResult) { - viewBinding.progressBar.hide() - with(errorBinding ?: ItemErrorStateBinding.bind(viewBinding.stubError.inflate())) { - errorBinding = this - root.isVisible = true - textViewError.text = result.throwable.getDisplayMessage(resources) - textViewError.setCompoundDrawablesWithIntrinsicBounds(0, result.throwable.getDisplayIcon(), 0, 0) - buttonRetry.isVisible = true - buttonRetry.setOnClickListener(this@ImageActivity) - } - } - - override fun onStart(request: ImageRequest) { - viewBinding.progressBar.show() - (errorBinding?.root ?: viewBinding.stubError).isVisible = false - } - - override fun onSuccess(request: ImageRequest, result: SuccessResult) { - viewBinding.progressBar.hide() - (errorBinding?.root ?: viewBinding.stubError).isVisible = false - } - - private fun loadImage(url: Uri?) { - ImageRequest.Builder(this) - .data(url) - .memoryCachePolicy(CachePolicy.DISABLED) - .lifecycle(this) - .listener(this) - .tag(intent.getSerializableExtraCompat(EXTRA_SOURCE)) - .target(SsivTarget(viewBinding.ssiv)) - .enqueueWith(coil) - } - - private class SsivTarget( - override val view: SubsamplingScaleImageView, - ) : ViewTarget { - - override fun onError(error: Drawable?) = setDrawable(error) - - override fun onSuccess(result: Drawable) = setDrawable(result) - - override fun equals(other: Any?): Boolean { - return (this === other) || (other is SsivTarget && view == other.view) - } - - override fun hashCode() = view.hashCode() - - override fun toString() = "SsivTarget(view=$view)" - - private fun setDrawable(drawable: Drawable?) { - if (drawable != null) { - view.setImage(ImageSource.Bitmap(drawable.toBitmap())) - } else { - view.recycle() - } - } - } - - companion object { - - private 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) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt deleted file mode 100644 index 60cc18885..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProvider.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.koitharu.kotatsu.list.domain - -import android.content.Context -import androidx.annotation.ColorRes -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -@Reusable -class ListExtraProvider @Inject constructor( - @ApplicationContext context: Context, - private val settings: AppSettings, - private val trackingRepository: TrackingRepository, - private val historyRepository: HistoryRepository, -) { - - private val dict by lazy { - context.resources.openRawResource(R.raw.tags_redlist).use { - val set = HashSet() - it.bufferedReader().forEachLine { x -> - val line = x.trim() - if (line.isNotEmpty()) { - set.add(line) - } - } - set - } - } - - suspend fun getCounter(mangaId: Long): Int { - return if (settings.isTrackerEnabled) { - trackingRepository.getNewChaptersCount(mangaId) - } else { - 0 - } - } - - suspend fun getProgress(mangaId: Long): Float { - return if (settings.isReadingIndicatorsEnabled) { - historyRepository.getProgress(mangaId) - } else { - PROGRESS_NONE - } - } - - @ColorRes - fun getTagTint(tag: MangaTag): Int { - return if (tag.title.lowercase() in dict) { - R.color.warning - } else { - 0 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListSortOrder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListSortOrder.kt deleted file mode 100644 index 932edd88f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListSortOrder.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.list.domain - -import androidx.annotation.StringRes -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.parsers.util.find -import java.util.EnumSet - -enum class ListSortOrder( - @StringRes val titleResId: Int, -) { - - UPDATED(R.string.updated), - NEWEST(R.string.order_added), - PROGRESS(R.string.progress), - ALPHABETIC(R.string.by_name), - RATING(R.string.by_rating), - RELEVANCE(R.string.by_relevance), - NEW_CHAPTERS(R.string.new_chapters), - ; - - fun isGroupingSupported() = this == UPDATED || this == NEWEST || this == PROGRESS - - companion object { - - val HISTORY: Set = EnumSet.of(UPDATED, NEWEST, PROGRESS, ALPHABETIC, NEW_CHAPTERS) - val FAVORITES: Set = EnumSet.of(ALPHABETIC, NEWEST, RATING, NEW_CHAPTERS, PROGRESS) - val SUGGESTIONS: Set = EnumSet.of(RELEVANCE) - - operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModelDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModelDiffCallback.kt deleted file mode 100644 index 3d2984a75..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/ListModelDiffCallback.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.list.ui - -import androidx.recyclerview.widget.DiffUtil -import org.koitharu.kotatsu.list.ui.model.ListModel - -open class ListModelDiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { - return oldItem.areItemsTheSame(newItem) - } - - override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { - return oldItem == newItem - } - - override fun getChangePayload(oldItem: T, newItem: T): Any? { - return newItem.getChangePayload(oldItem) - } - - companion object : ListModelDiffCallback() { - - val PAYLOAD_CHECKED_CHANGED = Any() - val PAYLOAD_NESTED_LIST_CHANGED = Any() - val PAYLOAD_PROGRESS_CHANGED = Any() - val PAYLOAD_ANYTHING_CHANGED = Any() - } -} 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 deleted file mode 100644 index fd5e16aab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.list.ui - -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.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 - -class MangaListMenuProvider( - private val fragment: Fragment, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_list, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_list_mode -> { - val section: ListConfigSection = when (fragment) { - is HistoryListFragment -> ListConfigSection.History - is SuggestionsFragment -> ListConfigSection.Suggestions - is FavouritesListFragment -> ListConfigSection.Favorites(fragment.categoryId) - else -> ListConfigSection.General - } - ListConfigBottomSheet.show(fragment.childFragmentManager, section) - true - } - - else -> false - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt deleted file mode 100644 index e8af462a8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.list.ui - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag - -abstract class MangaListViewModel( - private val settings: AppSettings, - private val downloadScheduler: DownloadWorker.Scheduler, -) : BaseViewModel() { - - abstract val content: StateFlow> - open val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.listMode) - val onActionDone = MutableEventFlow() - val gridScale = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_GRID_SIZE, - valueProducer = { gridSize / 100f }, - ) - val onDownloadStarted = MutableEventFlow() - - val isIncognitoModeEnabled: Boolean - get() = settings.isIncognitoModeEnabled - - open fun onUpdateFilter(tags: Set) = Unit - - abstract fun onRefresh() - - abstract fun onRetry() - - fun download(items: Set) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule(items) - onDownloadStarted.call(Unit) - } - } - - fun List.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) { - filterNot { it.isNsfw } - } else { - this - } -} 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 deleted file mode 100644 index ab67bf9e5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt +++ /dev/null @@ -1,67 +0,0 @@ -@file:androidx.annotation.OptIn(ExperimentalBadgeUtils::class) - -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import androidx.annotation.CheckResult -import androidx.cardview.widget.CardView -import androidx.core.view.doOnNextLayout -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.badge.BadgeUtils -import com.google.android.material.badge.ExperimentalBadgeUtils -import org.koitharu.kotatsu.R -import com.google.android.material.R as materialR - -@CheckResult -fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { - return bindBadgeImpl(badge, null, counter) -} - -@CheckResult -fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? { - return bindBadgeImpl(badge, text, 0) -} - -fun View.clearBadge(badge: BadgeDrawable?) { - BadgeUtils.detachBadgeDrawable(badge, this) -} - -private fun View.bindBadgeImpl( - badge: BadgeDrawable?, - text: String?, - counter: Int, -): BadgeDrawable? = if (text != null || counter > 0) { - val badgeDrawable = badge ?: initBadge(this) - if (counter > 0) { - badgeDrawable.number = counter - } else { - badgeDrawable.text = text?.takeUnless { it.isEmpty() } - } - badgeDrawable.isVisible = true - badgeDrawable.align(this) - badgeDrawable -} else { - badge?.isVisible = false - badge -} - -private fun initBadge(anchor: View): BadgeDrawable { - val badge = BadgeDrawable.create(anchor.context) - val resources = anchor.resources - badge.maxCharacterCount = resources.getInteger(R.integer.manga_badge_max_character_count) - anchor.doOnNextLayout { - BadgeUtils.attachBadgeDrawable(badge, it) - badge.align(it) - } - return badge -} - -private fun BadgeDrawable.align(anchor: View) { - val extraOffset = if (anchor is CardView) { - (anchor.radius / 2f).toInt() - } else { - anchor.resources.getDimensionPixelOffset(materialR.dimen.m3_badge_offset) - } - horizontalOffset = intrinsicWidth + extraOffset - verticalOffset = intrinsicHeight + extraOffset -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt deleted file mode 100644 index 3aef252ef..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyHintAD.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding -import org.koitharu.kotatsu.list.ui.model.EmptyHint -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun emptyHintAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: ListStateHolderListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, -) { - - binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } - - bind { - binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) - binding.textPrimary.setText(item.textPrimary) - binding.textSecondary.setTextAndVisible(item.textSecondary) - binding.buttonRetry.setTextAndVisible(item.actionStringRes) - } -} 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 deleted file mode 100644 index 25e0f57f9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun emptyStateListAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: ListStateHolderListener?, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }, -) { - - if (listener != null) { - binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } - } - - bind { - binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) - binding.textPrimary.setText(item.textPrimary) - binding.textSecondary.setTextAndVisible(item.textSecondary) - if (listener != null) { - binding.buttonRetry.setTextAndVisible(item.actionStringRes) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt deleted file mode 100644 index e29e1e49a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import com.google.android.material.badge.BadgeDrawable -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun listHeaderAD( - listener: ListHeaderClickListener?, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) }, -) { - var badge: BadgeDrawable? = null - - if (listener != null) { - binding.buttonMore.setOnClickListener { - listener.onListHeaderClick(item, it) - } - } - - bind { - binding.textViewTitle.text = item.getText(context) - if (item.buttonTextRes == 0) { - binding.buttonMore.isInvisible = true - binding.buttonMore.text = null - binding.buttonMore.clearBadge(badge) - } else { - binding.buttonMore.setText(item.buttonTextRes) - binding.buttonMore.isVisible = true - badge = itemView.bindBadge(badge, item.badge) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderClickListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderClickListener.kt deleted file mode 100644 index 67a242759..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeaderClickListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import org.koitharu.kotatsu.list.ui.model.ListHeader - -interface ListHeaderClickListener { - - fun onListHeaderClick(item: ListHeader, view: View) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt deleted file mode 100644 index 9603e4304..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -enum class ListItemType { - - FILTER_SORT, - FILTER_TAG, - FILTER_TAG_MULTI, - FILTER_STATE, - FILTER_LANGUAGE, - HEADER, - MANGA_LIST, - MANGA_LIST_DETAILED, - MANGA_GRID, - MANGA_NESTED_GROUP, - FOOTER_LOADING, - FOOTER_ERROR, - STATE_LOADING, - STATE_ERROR, - STATE_EMPTY, - EXPLORE_BUTTONS, - EXPLORE_SOURCE_GRID, - EXPLORE_SOURCE_LIST, - EXPLORE_SUGGESTION, - TIP, - HINT_EMPTY, - PAGE_THUMB, - FEED, - DOWNLOAD, - CATEGORY_LARGE, - MANGA_SCROBBLING, - NAV_ITEM, - CHAPTER, -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt deleted file mode 100644 index 5040db801..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -interface ListStateHolderListener { - - fun onRetryClick(error: Throwable) - - fun onEmptyActionClick() -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt deleted file mode 100644 index d1cf4f20c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaDetailsClickListener.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag - -interface MangaDetailsClickListener : OnListItemClickListener { - - fun onReadClick(manga: Manga, view: View) - - fun onTagClick(manga: Manga, tag: MangaTag, view: View) -} 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 deleted file mode 100644 index 0840404e8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -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.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemMangaGridBinding -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaGridModel -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver -import org.koitharu.kotatsu.parsers.model.Manga - -fun mangaGridItemAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - sizeResolver: ItemSizeResolver, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }, -) { - var badge: BadgeDrawable? = null - - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item.manga, v) - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - } - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) - itemView.setOnContextClickListenerCompat(eventListener) - sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView) - - bind { payloads -> - binding.textViewTitle.text = item.title - binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { - size(CoverSizeResolver(binding.imageViewCover)) - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - transformations(TrimTransformation()) - allowRgb565(true) - tag(item.manga) - source(item.source) - enqueueWith(coil) - } - badge = itemView.bindBadge(badge, item.counter) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt deleted file mode 100644 index 91edfdaf2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver - -open class MangaListAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: MangaListListener, - sizeResolver: ItemSizeResolver, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listener)) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) - addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener)) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.HEADER, listHeaderAD(listener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt deleted file mode 100644 index 77a197d14..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.chip.Chip -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.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel -import org.koitharu.kotatsu.parsers.model.MangaTag - -fun mangaListDetailedItemAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: MangaDetailsClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, -) { - var badge: BadgeDrawable? = null - - val listenerAdapter = object : View.OnClickListener, View.OnLongClickListener, ChipsView.OnChipClickListener { - override fun onClick(v: View) = when (v.id) { - R.id.button_read -> clickListener.onReadClick(item.manga, v) - else -> clickListener.onItemClick(item.manga, v) - } - - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - - override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag ?: return - clickListener.onTagClick(item.manga, tag, chip) - } - } - itemView.setOnClickListener(listenerAdapter) - itemView.setOnLongClickListener(listenerAdapter) - itemView.setOnContextClickListenerCompat(listenerAdapter) - binding.buttonRead.setOnClickListener(listenerAdapter) - binding.chipsTags.onChipClickListener = listenerAdapter - - bind { payloads -> - binding.textViewTitle.text = item.title - binding.textViewSubtitle.textAndVisible = item.subtitle - binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { - size(CoverSizeResolver(binding.imageViewCover)) - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - transformations(TrimTransformation()) - allowRgb565(true) - tag(item.manga) - source(item.source) - enqueueWith(coil) - } - if (payloads.isEmpty()) { - binding.scrollViewTags.scrollTo(0, 0) - } - binding.chipsTags.setChips(item.tags) - binding.ratingBar.isVisible = item.manga.hasRating - binding.ratingBar.rating = binding.ratingBar.numStars * item.manga.rating - badge = itemView.bindBadge(badge, item.counter) - } -} 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 deleted file mode 100644 index c15805d59..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -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.TrimTransformation -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemMangaListBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaListModel -import org.koitharu.kotatsu.parsers.model.Manga - -fun mangaListItemAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, -) { - var badge: BadgeDrawable? = null - - val eventListener = object : View.OnClickListener, View.OnLongClickListener { - override fun onClick(v: View) = clickListener.onItemClick(item.manga, v) - override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v) - } - itemView.setOnClickListener(eventListener) - itemView.setOnLongClickListener(eventListener) - itemView.setOnContextClickListenerCompat(eventListener) - - bind { - binding.textViewTitle.text = item.title - binding.textViewSubtitle.textAndVisible = item.subtitle - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - transformations(TrimTransformation()) - tag(item.manga) - source(item.source) - enqueueWith(coil) - } - badge = itemView.bindBadge(badge, item.counter) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt deleted file mode 100644 index d445e34bb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListListener.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.view.View -import org.koitharu.kotatsu.parsers.model.MangaTag - -interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener { - - fun onUpdateFilter(tags: Set) - - fun onFilterClick(view: View?) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TipAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TipAD.kt deleted file mode 100644 index bec674344..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TipAD.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.widgets.TipView -import org.koitharu.kotatsu.databinding.ItemTip2Binding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.TipModel - -fun tipAD( - listener: TipView.OnButtonClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemTip2Binding.inflate(layoutInflater, parent, false) } -) { - - binding.root.onButtonClickListener = listener - - bind { - with(binding.root) { - tag = item - setTitle(item.title) - setText(item.text) - setIcon(item.icon) - setPrimaryButtonText(item.primaryButtonText) - setSecondaryButtonText(item.secondaryButtonText) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt deleted file mode 100644 index 1ec2dacd8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import android.content.Context -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ItemDecoration -import org.koitharu.kotatsu.R - -class TypedListSpacingDecoration( - context: Context, - private val addHorizontalPadding: Boolean, -) : ItemDecoration() { - - private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small) - private val spacingNormal = - context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) - private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large) - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - val itemType = parent.getChildViewHolder(view)?.itemViewType?.let { - ListItemType.entries.getOrNull(it) - } - when (itemType) { - ListItemType.FILTER_SORT, - ListItemType.FILTER_TAG, - ListItemType.FILTER_TAG_MULTI, - ListItemType.FILTER_STATE, - ListItemType.FILTER_LANGUAGE, - -> outRect.set(0) - - ListItemType.HEADER, - ListItemType.FEED, - ListItemType.EXPLORE_SOURCE_LIST, - ListItemType.MANGA_SCROBBLING, - ListItemType.MANGA_LIST, - -> outRect.set(0) - - ListItemType.DOWNLOAD, - ListItemType.HINT_EMPTY, - ListItemType.MANGA_LIST_DETAILED, - -> outRect.set(spacingNormal) - - ListItemType.PAGE_THUMB, - ListItemType.MANGA_GRID, - -> outRect.set(spacingNormal) - - ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal) - - ListItemType.FOOTER_LOADING, - ListItemType.FOOTER_ERROR, - ListItemType.STATE_LOADING, - ListItemType.STATE_ERROR, - ListItemType.STATE_EMPTY, - ListItemType.EXPLORE_SOURCE_GRID, - ListItemType.EXPLORE_SUGGESTION, - ListItemType.MANGA_NESTED_GROUP, - ListItemType.CATEGORY_LARGE, - ListItemType.NAV_ITEM, - ListItemType.CHAPTER, - null, - -> outRect.set(0) - - ListItemType.TIP -> outRect.set(0) // TODO - } - if (addHorizontalPadding && !itemType.isEdgeToEdge()) { - outRect.set( - outRect.left + spacingNormal, - outRect.top, - outRect.right + spacingNormal, - outRect.bottom, - ) - } - } - - private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing) - - private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP - || this == ListItemType.FILTER_SORT - || this == ListItemType.FILTER_TAG -} 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 deleted file mode 100644 index ef3cac052..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.koitharu.kotatsu.list.ui.config - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -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 -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -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 -import javax.inject.Inject - -@AndroidEntryPoint -class ListConfigBottomSheet : - BaseAdaptiveSheet(), - Slider.OnChangeListener, - MaterialButtonToggleGroup.OnButtonCheckedListener, CompoundButton.OnCheckedChangeListener, - AdapterView.OnItemSelectedListener { - - @Inject - @Deprecated("") - lateinit var settings: AppSettings - - private val viewModel by viewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = SheetListModeBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: SheetListModeBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val mode = viewModel.listMode - binding.buttonList.isChecked = mode == ListMode.LIST - binding.buttonListDetailed.isChecked = mode == ListMode.DETAILED_LIST - binding.buttonGrid.isChecked = mode == ListMode.GRID - binding.textViewGridTitle.isVisible = mode == ListMode.GRID - binding.sliderGrid.isVisible = mode == ListMode.GRID - - binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(binding.root.context)) - binding.sliderGrid.setValueRounded(viewModel.gridSize.toFloat()) - binding.sliderGrid.addOnChangeListener(this) - - binding.checkableGroup.addOnButtonCheckedListener(this) - - binding.switchGrouping.isVisible = viewModel.isGroupingAvailable - if (viewModel.isGroupingAvailable) { - binding.switchGrouping.isEnabled = settings.historySortOrder.isGroupingSupported() - } - binding.switchGrouping.isChecked = settings.isHistoryGroupingEnabled - binding.switchGrouping.setOnCheckedChangeListener(this) - - val sortOrders = viewModel.getSortOrders() - if (sortOrders != null) { - binding.textViewOrderTitle.isVisible = true - binding.spinnerOrder.adapter = ArrayAdapter( - binding.spinnerOrder.context, - android.R.layout.simple_spinner_dropdown_item, - android.R.id.text1, - sortOrders.map { binding.spinnerOrder.context.getString(it.titleResId) }, - ) - val selected = sortOrders.indexOf(viewModel.getSelectedSortOrder()) - if (selected >= 0) { - binding.spinnerOrder.setSelection(selected, false) - } - binding.spinnerOrder.onItemSelectedListener = this - binding.cardOrder.isVisible = true - } - } - - override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { - if (!isChecked) { - return - } - val mode = when (checkedId) { - R.id.button_list -> ListMode.LIST - R.id.button_list_detailed -> ListMode.DETAILED_LIST - R.id.button_grid -> ListMode.GRID - else -> return - } - requireViewBinding().textViewGridTitle.isVisible = mode == ListMode.GRID - requireViewBinding().sliderGrid.isVisible = mode == ListMode.GRID - viewModel.listMode = mode - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - when (buttonView.id) { - R.id.switch_grouping -> settings.isHistoryGroupingEnabled = isChecked - } - } - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - if (fromUser) { - viewModel.gridSize = value.toInt() - } - } - - override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { - when (parent.id) { - R.id.spinner_order -> { - viewModel.setSortOrder(position) - viewBinding?.switchGrouping?.isEnabled = settings.historySortOrder.isGroupingSupported() - } - } - } - - 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/ListConfigSection.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigSection.kt deleted file mode 100644 index 14d8bdbe7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigSection.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.list.ui.config - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed interface ListConfigSection : Parcelable { - - @Parcelize - data object History : ListConfigSection - - @Parcelize - data object General : ListConfigSection - - @Parcelize - data class Favorites( - val categoryId: Long, - ) : ListConfigSection - - @Parcelize - data object Suggestions : ListConfigSection -} 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 deleted file mode 100644 index 23d4f59d7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt +++ /dev/null @@ -1,83 +0,0 @@ -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.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.list.domain.ListSortOrder -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import javax.inject.Inject - -@HiltViewModel -class ListConfigViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val settings: AppSettings, - private val favouritesRepository: FavouritesRepository, -) : BaseViewModel() { - - val section = savedStateHandle.require(ListConfigBottomSheet.ARG_SECTION) - - var listMode: ListMode - get() = when (section) { - is ListConfigSection.Favorites -> settings.favoritesListMode - ListConfigSection.General -> settings.listMode - ListConfigSection.History -> settings.historyListMode - ListConfigSection.Suggestions -> settings.suggestionsListMode - } - set(value) { - when (section) { - is ListConfigSection.Favorites -> settings.favoritesListMode = value - ListConfigSection.General -> settings.listMode = value - ListConfigSection.History -> settings.historyListMode = value - ListConfigSection.Suggestions -> settings.suggestionsListMode = value - } - } - - var gridSize: Int - get() = settings.gridSize - set(value) { - settings.gridSize = value - } - - val isGroupingAvailable: Boolean - get() = section == ListConfigSection.History - - fun getSortOrders(): List? = when (section) { - is ListConfigSection.Favorites -> ListSortOrder.FAVORITES - ListConfigSection.General -> null - ListConfigSection.History -> ListSortOrder.HISTORY - ListConfigSection.Suggestions -> ListSortOrder.SUGGESTIONS - }?.sortedByOrdinal() - - fun getSelectedSortOrder(): ListSortOrder? = when (section) { - is ListConfigSection.Favorites -> getCategorySortOrder(section.categoryId) - ListConfigSection.General -> null - ListConfigSection.History -> settings.historySortOrder - ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO - } - - fun setSortOrder(position: Int) { - val value = getSortOrders()?.getOrNull(position) ?: return - when (section) { - is ListConfigSection.Favorites -> launchJob { - favouritesRepository.setCategoryOrder(section.categoryId, value) - } - - ListConfigSection.General -> Unit - ListConfigSection.History -> settings.historySortOrder = value - - ListConfigSection.Suggestions -> Unit - } - } - - private fun getCategorySortOrder(id: Long): ListSortOrder = runBlocking { - runCatchingCancellable { - favouritesRepository.getCategory(id).order - }.getOrDefault(ListSortOrder.NEWEST) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyHint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyHint.kt deleted file mode 100644 index ee60ee256..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/EmptyHint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes - -data class EmptyHint( - @DrawableRes val icon: Int, - @StringRes val textPrimary: Int, - @StringRes val textSecondary: Int, - @StringRes val actionStringRes: Int, -) : ListModel { - - fun toState() = EmptyState(icon, textPrimary, textSecondary, actionStringRes) - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is EmptyHint && textPrimary == other.textPrimary - } -} 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 deleted file mode 100644 index 3608e550d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -import android.content.Context -import androidx.annotation.StringRes -import org.koitharu.kotatsu.core.ui.model.DateTimeAgo - -@Suppress("DataClassPrivateConstructor") -data class ListHeader private constructor( - private val textRaw: Any, - @StringRes val buttonTextRes: Int, - val payload: Any?, - val badge: String?, -) : ListModel { - - constructor( - text: CharSequence, - @StringRes buttonTextRes: Int = 0, - payload: Any? = null, - badge: String? = null, - ) : this(textRaw = text, buttonTextRes, payload, badge) - - constructor( - @StringRes textRes: Int, - @StringRes buttonTextRes: Int = 0, - payload: Any? = null, - badge: String? = null, - ) : this(textRaw = textRes, buttonTextRes, payload, badge) - - constructor( - dateTimeAgo: DateTimeAgo, - @StringRes buttonTextRes: Int = 0, - payload: Any? = null, - badge: String? = null, - ) : this(textRaw = dateTimeAgo, buttonTextRes, payload, badge) - - 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) - else -> null - } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ListHeader && textRaw == other.textRaw - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModel.kt deleted file mode 100644 index 1f92be769..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModel.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -interface ListModel { - - override fun equals(other: Any?): Boolean - - fun areItemsTheSame(other: ListModel): Boolean - - fun getChangePayload(previousState: ListModel): Any? = null -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt deleted file mode 100644 index 2ecb9c6db..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.getDisplayIcon -import org.koitharu.kotatsu.core.util.ext.ifZero -import org.koitharu.kotatsu.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.parsers.model.Manga - -suspend fun Manga.toListModel( - extraProvider: ListExtraProvider? -) = MangaListModel( - id = id, - title = title, - subtitle = tags.joinToString(", ") { it.title }, - coverUrl = coverUrl, - manga = this, - counter = extraProvider?.getCounter(id) ?: 0, - progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, -) - -suspend fun Manga.toListDetailedModel( - extraProvider: ListExtraProvider?, -) = MangaListDetailedModel( - id = id, - title = title, - subtitle = altTitle, - coverUrl = coverUrl, - manga = this, - counter = extraProvider?.getCounter(id) ?: 0, - progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, - tags = tags.map { - ChipsView.ChipModel( - tint = extraProvider?.getTagTint(it) ?: 0, - title = it.title, - icon = 0, - isCheckable = false, - isChecked = false, - data = it, - ) - }, -) - -suspend fun Manga.toGridModel( - extraProvider: ListExtraProvider?, -) = MangaGridModel( - id = id, - title = title, - coverUrl = coverUrl, - manga = this, - counter = extraProvider?.getCounter(id) ?: 0, - progress = extraProvider?.getProgress(id) ?: PROGRESS_NONE, -) - -suspend fun List.toUi( - mode: ListMode, - extraProvider: ListExtraProvider, -): List = if (isEmpty()) { - emptyList() -} else { - toUi(ArrayList(size), mode, extraProvider) -} - -suspend fun > List.toUi( - destination: C, - mode: ListMode, - extraProvider: ListExtraProvider, -): C = when (mode) { - ListMode.LIST -> mapTo(destination) { it.toListModel(extraProvider) } - ListMode.DETAILED_LIST -> mapTo(destination) { it.toListDetailedModel(extraProvider) } - ListMode.GRID -> mapTo(destination) { it.toGridModel(extraProvider) } -} - -fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState( - exception = this, - icon = getDisplayIcon(), - canRetry = canRetry, - buttonText = ExceptionResolver.getResolveStringId(this).ifZero { R.string.try_again }, -) - -fun Throwable.toErrorFooter() = ErrorFooter( - exception = this, - icon = R.drawable.ic_alert_outline, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt deleted file mode 100644 index 7030d059d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -data class LoadingFooter @JvmOverloads constructor( - val key: Int = 0, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is LoadingFooter && key == other.key - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingState.kt deleted file mode 100644 index 36fa63418..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/LoadingState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -object LoadingState : ListModel { - - override fun equals(other: Any?): Boolean = other === LoadingState - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is LoadingState - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt deleted file mode 100644 index 95ea83b60..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource - -sealed class MangaItemModel : ListModel { - - abstract val id: Long - abstract val manga: Manga - abstract val title: String - abstract val coverUrl: String - abstract val counter: Int - abstract val progress: Float - - val source: MangaSource - get() = manga.source - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaItemModel && other.javaClass == javaClass && id == other.id - } - - override fun getChangePayload(previousState: ListModel): Any? { - return when { - previousState !is MangaItemModel -> super.getChangePayload(previousState) - progress != previousState.progress -> ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED - counter != previousState.counter -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED - else -> null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/TipModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/TipModel.kt deleted file mode 100644 index a3eb66045..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/TipModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.list.ui.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes - -data class TipModel( - val key: String, - @StringRes val title: Int, - @StringRes val text: Int, - @DrawableRes val icon: Int, - @StringRes val primaryButtonText: Int, - @StringRes val secondaryButtonText: Int, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is TipModel && other.key == key - } -} 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 deleted file mode 100644 index cb4640e48..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ /dev/null @@ -1,212 +0,0 @@ -package org.koitharu.kotatsu.list.ui.preview - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.core.graphics.Insets -import androidx.core.text.method.LinkMovementMethodCompat -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import coil.ImageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.util.CoilUtils -import com.google.android.material.chip.Chip -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.crossfade -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -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.FilterOwner -import org.koitharu.kotatsu.image.ui.ImageActivity -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.SearchActivity -import javax.inject.Inject - -@AndroidEntryPoint -class PreviewFragment : BaseFragment(), View.OnClickListener, ChipsView.OnChipClickListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel: PreviewViewModel by viewModels() - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPreviewBinding { - return FragmentPreviewBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: FragmentPreviewBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.buttonClose.isVisible = activity is MangaListActivity - binding.buttonClose.setOnClickListener(this) - binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() - binding.chipsTags.onChipClickListener = this - binding.textViewAuthor.setOnClickListener(this) - binding.imageViewCover.setOnClickListener(this) - binding.buttonOpen.setOnClickListener(this) - binding.buttonRead.setOnClickListener(this) - - viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) - viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated) - viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) - viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) - } - - override fun onClick(v: View) { - val manga = viewModel.manga.value - when (v.id) { - R.id.button_close -> closeSelf() - R.id.button_open -> startActivity( - DetailsActivity.newIntent(v.context, manga), - ) - - R.id.button_read -> { - startActivity( - ReaderActivity.IntentBuilder(v.context) - .manga(manga) - .build(), - ) - } - - R.id.textView_author -> startActivity( - SearchActivity.newIntent( - context = v.context, - source = manga.source, - query = manga.author ?: return, - ), - ) - - R.id.imageView_cover -> startActivity( - ImageActivity.newIntent( - v.context, - manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }, - manga.source, - ), - scaleUpActivityOptionsOf(v), - ) - } - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag ?: return - val filter = (activity as? FilterOwner)?.filter - if (filter == null) { - startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) - } else { - filter.setTag(tag, true) - closeSelf() - } - } - - private fun onMangaUpdated(manga: Manga) { - with(requireViewBinding()) { - // Main - loadCover(manga) - textViewTitle.text = manga.title - textViewSubtitle.textAndVisible = manga.altTitle - textViewAuthor.textAndVisible = manga.author - if (manga.hasRating) { - ratingBar.rating = manga.rating * ratingBar.numStars - ratingBar.isVisible = true - } else { - ratingBar.isVisible = false - } - } - } - - private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) { - with(requireViewBinding()) { - toolbarBottom.isVisible = footer != null - if (footer == null) { - return - } - toolbarBottom.title = when { - footer.isInProgress() -> { - getString(R.string.chapter_d_of_d, footer.currentChapter, footer.totalChapters) - } - - footer.totalChapters > 0 -> { - resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters) - } - - else -> { - getString(R.string.no_chapters) - } - } - buttonRead.isEnabled = footer.totalChapters > 0 - buttonRead.setIconResource( - when { - footer.isIncognito -> R.drawable.ic_incognito - footer.isInProgress() -> R.drawable.ic_play - else -> R.drawable.ic_read - }, - ) - buttonRead.setText( - if (footer.isInProgress()) { - R.string._continue - } else { - R.string.read - }, - ) - } - } - - private fun onDescriptionChanged(description: CharSequence?) { - val tv = viewBinding?.textViewDescription ?: return - when { - description == null -> tv.setText(R.string.loading_) - description.isBlank() -> tv.setText(R.string.no_description) - else -> tv.setText(description, TextView.BufferType.NORMAL) - } - } - - private fun loadCover(manga: Manga) { - val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } - val lastResult = CoilUtils.result(requireViewBinding().imageViewCover) - if (lastResult is SuccessResult && lastResult.request.data == imageUrl) { - return - } - val request = ImageRequest.Builder(context ?: return) - .target(requireViewBinding().imageViewCover) - .size(CoverSizeResolver(requireViewBinding().imageViewCover)) - .data(imageUrl) - .tag(manga.source) - .crossfade(requireContext()) - .lifecycle(viewLifecycleOwner) - .placeholderMemoryCacheKey(manga.coverUrl) - val previousDrawable = lastResult?.drawable - if (previousDrawable != null) { - request.fallback(previousDrawable) - .placeholder(previousDrawable) - .error(previousDrawable) - } else { - request.fallback(R.drawable.ic_placeholder) - .placeholder(R.drawable.ic_placeholder) - .error(R.drawable.ic_error_placeholder) - } - request.enqueueWith(coil) - } - - private fun onTagsChipsChanged(chips: List) { - requireViewBinding().chipsTags.setChips(chips) - } - - private fun closeSelf() { - ((activity as? MangaListActivity)?.hidePreview()) - } -} 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 deleted file mode 100644 index e3e03d63b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.koitharu.kotatsu.list.ui.preview - -import android.text.Html -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ForegroundColorSpan -import androidx.core.text.getSpans -import androidx.core.text.parseAsHtml -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -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.parser.MangaRepository -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.core.util.ext.sanitize -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import javax.inject.Inject - -@HiltViewModel -class PreviewViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val extraProvider: ListExtraProvider, - private val repositoryFactory: MangaRepository.Factory, - private val historyRepository: HistoryRepository, - private val imageGetter: Html.ImageGetter, -) : BaseViewModel() { - - val manga = MutableStateFlow( - savedStateHandle.require(MangaIntent.KEY_MANGA).manga, - ) - - val footer = combine( - manga, - historyRepository.observeOne(manga.value.id), - manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(), - ) { m, history, incognito -> - if (m.chapters == null) { - return@combine null - } - val b = m.getPreferredBranch(history) - val chapters = m.getChapters(b).orEmpty() - FooterInfo( - branch = b, - currentChapter = history?.chapterId?.let { - chapters.indexOfFirst { x -> x.id == it } - } ?: -1, - totalChapters = chapters.size, - isIncognito = incognito, - ) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) - - val description = manga - .distinctUntilChangedBy { it.description.orEmpty() } - .transformLatest { - val description = it.description - if (description.isNullOrEmpty()) { - emit(null) - } else { - emit(description.parseAsHtml().filterSpans().sanitize()) - emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans()) - } - }.combine(isLoading) { desc, loading -> - if (loading) null else desc ?: "" - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), null) - - val tagsChips = manga.map { - it.tags.map { tag -> - ChipsView.ChipModel( - title = tag.title, - tint = extraProvider.getTagTint(tag), - icon = 0, - data = tag, - isCheckable = false, - isChecked = false, - ) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - init { - launchLoadingJob(Dispatchers.Default) { - val repo = repositoryFactory.create(manga.value.source) - manga.value = repo.getDetails(manga.value) - } - } - - private fun Spanned.filterSpans(): CharSequence { - val spannable = SpannableString.valueOf(this) - val spans = spannable.getSpans() - for (span in spans) { - spannable.removeSpan(span) - } - return spannable.trim() - } - - data class FooterInfo( - val branch: String?, - val currentChapter: Int, - val totalChapters: Int, - val isIncognito: Boolean, - ) { - - fun isInProgress() = currentChapter >= 0 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/DynamicItemSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/DynamicItemSizeResolver.kt deleted file mode 100644 index a26cf770f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/DynamicItemSizeResolver.kt +++ /dev/null @@ -1,117 +0,0 @@ -package org.koitharu.kotatsu.list.ui.size - -import android.content.SharedPreferences -import android.content.res.Resources -import android.view.View -import android.widget.TextView -import androidx.annotation.StyleRes -import androidx.core.widget.TextViewCompat -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.history.ui.util.ReadingProgressView -import kotlin.math.roundToInt - -class DynamicItemSizeResolver( - resources: Resources, - private val settings: AppSettings, - private val adjustWidth: Boolean, -) : ItemSizeResolver { - - private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width) - private val scaleFactor: Float - get() = settings.gridSize / 100f - - override val cellWidth: Int - get() = (gridWidth * scaleFactor).roundToInt() - - override fun attachToView( - lifecycleOwner: LifecycleOwner, - view: View, - textView: TextView?, - progressView: ReadingProgressView? - ) { - val observer = SizeObserver(view, textView, progressView) - view.addOnAttachStateChangeListener(observer) - lifecycleOwner.lifecycle.addObserver(observer) - if (view.isAttachedToWindow) { - observer.update() - } - } - - private inner class SizeObserver( - private val view: View, - private val textView: TextView?, - private val progressView: ReadingProgressView?, - ) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener, View.OnAttachStateChangeListener { - - private val widthThreshold = view.resources.getDimensionPixelSize(R.dimen.small_grid_width) - - @StyleRes - private var prevTextAppearance = 0 - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == AppSettings.KEY_GRID_SIZE) { - update() - } - } - - override fun onViewAttachedToWindow(v: View) { - settings.subscribe(this) - update() - } - - override fun onViewDetachedFromWindow(v: View) { - settings.unsubscribe(this) - } - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - settings.unsubscribe(this) - view.removeOnAttachStateChangeListener(this) - } - - fun update() { - val newWidth = cellWidth - textView?.adjustTextAppearance(newWidth) - if (adjustWidth) { - val lp = view.layoutParams - if (lp.width != newWidth) { - lp.width = newWidth - view.layoutParams = lp - } - } - progressView?.adjustSize(newWidth) - } - - private fun ReadingProgressView.adjustSize(width: Int) { - val lp = layoutParams - val size = resources.getDimensionPixelSize( - if (width < widthThreshold) { - R.dimen.card_indicator_size_small - } else { - R.dimen.card_indicator_size - }, - ) - if (lp.width != size || lp.height != size) { - lp.width = size - lp.height = size - layoutParams = lp - } - } - - private fun TextView.adjustTextAppearance(width: Int) { - val textAppearanceResId = if (width < widthThreshold) { - R.style.TextAppearance_Kotatsu_GridTitle_Small - } else { - R.style.TextAppearance_Kotatsu_GridTitle - } - if (textAppearanceResId != prevTextAppearance) { - prevTextAppearance = textAppearanceResId - TextViewCompat.setTextAppearance(this, textAppearanceResId) - requestLayout() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/ItemSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/ItemSizeResolver.kt deleted file mode 100644 index de44dff58..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/ItemSizeResolver.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.list.ui.size - -import android.view.View -import android.widget.TextView -import androidx.lifecycle.LifecycleOwner -import org.koitharu.kotatsu.history.ui.util.ReadingProgressView - -interface ItemSizeResolver { - - val cellWidth: Int - - fun attachToView( - lifecycleOwner: LifecycleOwner, - view: View, - textView: TextView?, - progressView: ReadingProgressView?, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/StaticItemSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/StaticItemSizeResolver.kt deleted file mode 100644 index e4f1bc919..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/size/StaticItemSizeResolver.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.koitharu.kotatsu.list.ui.size - -import android.view.View -import android.widget.TextView -import androidx.core.view.updateLayoutParams -import androidx.core.widget.TextViewCompat -import androidx.lifecycle.LifecycleOwner -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.history.ui.util.ReadingProgressView - -class StaticItemSizeResolver( - override val cellWidth: Int, -) : ItemSizeResolver { - - private var widthThreshold: Int = -1 - private var textAppearanceResId = R.style.TextAppearance_Kotatsu_GridTitle - - override fun attachToView( - lifecycleOwner: LifecycleOwner, - view: View, - textView: TextView?, - progressView: ReadingProgressView? - ) { - if (widthThreshold == -1) { - widthThreshold = view.resources.getDimensionPixelSize(R.dimen.small_grid_width) - textAppearanceResId = if (cellWidth < widthThreshold) { - R.style.TextAppearance_Kotatsu_GridTitle_Small - } else { - R.style.TextAppearance_Kotatsu_GridTitle - } - } - if (textView != null) { - TextViewCompat.setTextAppearance(textView, textAppearanceResId) - } - view.updateLayoutParams { - width = cellWidth - } - progressView?.adjustSize() - } - - private fun ReadingProgressView.adjustSize() { - val lp = layoutParams - val size = resources.getDimensionPixelSize( - if (cellWidth < widthThreshold) { - R.dimen.card_indicator_size_small - } else { - R.dimen.card_indicator_size - }, - ) - if (lp.width != size || lp.height != size) { - lp.width = size - lp.height = size - layoutParams = lp - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt deleted file mode 100644 index b9f2eebb4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import android.net.Uri -import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP -import java.io.File - -private fun isCbzExtension(ext: String?): Boolean { - return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true) -} - -fun hasCbzExtension(string: String): Boolean { - val ext = string.substringAfterLast('.', "") - return isCbzExtension(ext) -} - -fun File.hasCbzExtension() = isCbzExtension(extension) - -fun Uri.isZipUri() = scheme.let { - it == URI_SCHEME_ZIP || it == "cbz" || it == "zip" -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/ImageFileFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/ImageFileFilter.kt deleted file mode 100644 index 06a48f4eb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/ImageFileFilter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import java.io.File - -fun hasImageExtension(string: String): Boolean { - val ext = string.substringAfterLast('.', "") - return ext.equals("png", ignoreCase = true) || ext.equals("jpg", ignoreCase = true) - || ext.equals("jpeg", ignoreCase = true) || ext.equals("webp", ignoreCase = true) -} - -fun hasImageExtension(file: File) = hasImageExtension(file.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt deleted file mode 100644 index 9bf2ff6c5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ /dev/null @@ -1,228 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import android.net.Uri -import androidx.core.net.toFile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.AlphanumComparator -import org.koitharu.kotatsu.core.util.CompositeMutex2 -import org.koitharu.kotatsu.core.util.ext.children -import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.core.util.ext.filterWith -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.local.data.output.LocalMangaOutput -import org.koitharu.kotatsu.local.data.output.LocalMangaUtil -import org.koitharu.kotatsu.local.domain.model.LocalManga -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.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaPage -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.model.SortOrder -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.io.File -import java.util.EnumSet -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton - -private const val MAX_PARALLELISM = 4 - -@Singleton -class LocalMangaRepository @Inject constructor( - private val storageManager: LocalStorageManager, - @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, - private val settings: AppSettings, -) : MangaRepository { - - override val source = MangaSource.LOCAL - private val locks = CompositeMutex2() - - override val isMultipleTagsSupported: Boolean = true - override val isTagsExclusionSupported: Boolean = true - override val isSearchSupported: Boolean = true - override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) - override val states = emptySet() - override val contentRatings = emptySet() - - override var defaultSortOrder: SortOrder - get() = settings.localListOrder - set(value) { - settings.localListOrder = value - } - - override suspend fun getList(offset: Int, filter: MangaListFilter?): List { - if (offset > 0) { - return emptyList() - } - val list = getRawList() - when (filter) { - is MangaListFilter.Search -> { - list.retainAll { x -> x.isMatchesQuery(filter.query) } - } - - is MangaListFilter.Advanced -> { - if (filter.tags.isNotEmpty()) { - list.retainAll { x -> x.containsTags(filter.tags) } - } - if (filter.tagsExclude.isNotEmpty()) { - list.removeAll { x -> x.containsAnyTag(filter.tags) } - } - when (filter.sortOrder) { - SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) - SortOrder.RATING -> list.sortByDescending { it.manga.rating } - SortOrder.NEWEST, - SortOrder.UPDATED, - -> list.sortByDescending { it.createdAt } - - else -> Unit - } - } - - null -> Unit - } - return list.unwrap() - } - - override suspend fun getDetails(manga: Manga): Manga = when { - manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) { - "Manga is not local or saved" - } - - else -> LocalMangaInput.of(manga).getManga().manga - } - - override suspend fun getPages(chapter: MangaChapter): List { - return LocalMangaInput.of(chapter).getPages(chapter) - } - - suspend fun delete(manga: Manga): Boolean { - val file = Uri.parse(manga.url).toFile() - val result = file.deleteAwait() - if (result) { - localStorageChanges.emit(null) - } - return result - } - - suspend fun deleteChapters(manga: Manga, ids: Set) { - lockManga(manga.id) - try { - val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) { - "Manga is not stored on local storage" - }.manga - LocalMangaUtil(subject).deleteChapters(ids) - localStorageChanges.emit(LocalManga(subject)) - } finally { - unlockManga(manga.id) - } - } - - suspend fun getRemoteManga(localManga: Manga): Manga? { - return runCatchingCancellable { - LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal } - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - } - - suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable { - // fast path - LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { - return it.getManga() - } - // slow path - val files = getAllFiles() - return channelFlow { - for (file in files) { - launch { - val mangaInput = LocalMangaInput.of(file) - runCatchingCancellable { - val mangaInfo = mangaInput.getMangaInfo() - if (mangaInfo != null && mangaInfo.id == remoteManga.id) { - send(mangaInput) - } - }.onFailure { - it.printStackTraceDebug() - } - } - } - }.firstOrNull()?.getManga() - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - - override suspend fun getPageUrl(page: MangaPage) = page.url - - override suspend fun getTags() = emptySet() - - override suspend fun getLocales() = emptySet() - - override suspend fun getRelated(seed: Manga): List = emptyList() - - suspend fun getOutputDir(manga: Manga): File? { - val defaultDir = storageManager.getDefaultWriteableDir() - if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { - return defaultDir - } - return storageManager.getWriteableDirs() - .firstOrNull { - LocalMangaOutput.get(it, manga) != null - } ?: defaultDir - } - - suspend fun cleanup(): Boolean { - if (locks.isNotEmpty()) { - return false - } - val dirs = storageManager.getWriteableDirs() - runInterruptible(Dispatchers.IO) { - dirs.flatMap { dir -> - dir.children().filterWith(TempFileFilter()) - }.forEach { file -> - file.deleteRecursively() - } - } - return true - } - - suspend fun lockManga(id: Long) { - locks.lock(id) - } - - fun unlockManga(id: Long) { - locks.unlock(id) - } - - private suspend fun getRawList(): ArrayList { - val files = getAllFiles().toList() // TODO remove toList() - return coroutineScope { - val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) - files.map { file -> - async(dispatcher) { - runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull() - } - }.awaitAll() - }.filterNotNullTo(ArrayList(files.size)) - } - - private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir -> - dir.children() - } - - private fun Collection.unwrap(): List = map { it.manga } -} 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 deleted file mode 100644 index 4f81808b1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import android.content.Context -import android.graphics.Bitmap -import android.os.StatFs -import com.tomclaw.cache.DiskLruCache -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import okio.Source -import okio.buffer -import okio.sink -import okio.use -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.ext.compressToPNG -import org.koitharu.kotatsu.core.util.ext.longHashCode -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.subdir -import org.koitharu.kotatsu.core.util.ext.takeIfReadable -import org.koitharu.kotatsu.core.util.ext.takeIfWriteable -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.parsers.util.SuspendLazy -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PagesCache @Inject constructor(@ApplicationContext context: Context) { - - private val cacheDir = SuspendLazy { - val dirs = context.externalCacheDirs + context.cacheDir - dirs.firstNotNullOf { - it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable() - } - } - private val lruCache = SuspendLazy { - val dir = cacheDir.get() - val availableSize = (getAvailableSize() * 0.8).toLong() - val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN) - runCatchingCancellable { - DiskLruCache.create(dir, size) - }.recoverCatching { error -> - error.printStackTraceDebug() - dir.deleteRecursively() - dir.mkdir() - DiskLruCache.create(dir, size) - }.getOrThrow() - } - - suspend fun get(url: String): File? { - val cache = lruCache.get() - return runInterruptible(Dispatchers.IO) { - cache.get(url)?.takeIfReadable() - } - } - - suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) { - val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) - try { - val bytes = file.sink(append = false).buffer().use { - it.writeAllCancellable(source) - } - check(bytes != 0L) { "No data has been written" } - lruCache.get().put(url, file) - } finally { - file.delete() - } - } - - suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { - val file = File(cacheDir.get().parentFile, url.longHashCode().toString()) - try { - bitmap.compressToPNG(file) - lruCache.get().put(url, file) - } finally { - file.delete() - } - } - - private suspend fun getAvailableSize(): Long = runCatchingCancellable { - val statFs = StatFs(cacheDir.get().absolutePath) - statFs.availableBytes - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(SIZE_DEFAULT) - - private companion object { - - val SIZE_MIN - get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES) - - val SIZE_DEFAULT - get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Qualifiers.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Qualifiers.kt deleted file mode 100644 index abc8d7714..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Qualifiers.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class LocalStorageChanges diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/TempFileFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/TempFileFilter.kt deleted file mode 100644 index 1df7760aa..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/TempFileFilter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.local.data - -import java.io.File -import java.io.FileFilter - -class TempFileFilter : FileFilter { - - override fun accept(file: File): Boolean { - return file.name.endsWith(".tmp", ignoreCase = true) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt deleted file mode 100644 index b7442c380..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/importer/SingleMangaImporter.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.koitharu.kotatsu.local.data.importer - -import android.content.Context -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.withContext -import okio.buffer -import okio.sink -import okio.source -import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException -import org.koitharu.kotatsu.core.util.ext.resolveName -import org.koitharu.kotatsu.core.util.ext.writeAllCancellable -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.data.hasCbzExtension -import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.local.domain.model.LocalManga -import java.io.File -import java.io.IOException -import javax.inject.Inject - -@Reusable -class SingleMangaImporter @Inject constructor( - @ApplicationContext private val context: Context, - private val storageManager: LocalStorageManager, - @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, -) { - - private val contentResolver = context.contentResolver - - suspend fun import(uri: Uri): LocalManga { - val result = if (isDirectory(uri)) { - importDirectory(uri) - } else { - importFile(uri) - } - localStorageChanges.emit(result) - return result - } - - private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) { - val contentResolver = storageManager.contentResolver - val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") - if (!hasCbzExtension(name)) { - throw UnsupportedFileException("Unsupported file on $uri") - } - val dest = File(getOutputDir(), name) - runInterruptible { - contentResolver.openInputStream(uri) - }?.use { source -> - dest.sink().buffer().use { output -> - output.writeAllCancellable(source.source()) - } - } ?: throw IOException("Cannot open input stream: $uri") - LocalMangaInput.of(dest).getManga() - } - - private suspend fun importDirectory(uri: Uri): LocalManga { - val root = requireNotNull(DocumentFile.fromTreeUri(context, uri)) { - "Provided uri $uri is not a tree" - } - val dest = File(getOutputDir(), root.requireName()) - dest.mkdir() - for (docFile in root.listFiles()) { - docFile.copyTo(dest) - } - return LocalMangaInput.of(dest).getManga() - } - - /** - * TODO: progress - */ - private suspend fun DocumentFile.copyTo(destDir: File) { - if (isDirectory) { - val subDir = File(destDir, requireName()) - subDir.mkdir() - for (docFile in listFiles()) { - docFile.copyTo(subDir) - } - } else { - inputStream().source().use { input -> - File(destDir, requireName()).sink().buffer().use { output -> - output.writeAllCancellable(input) - } - } - } - } - - private suspend fun getOutputDir(): File { - return storageManager.getDefaultWriteableDir() ?: throw IOException("External files dir unavailable") - } - - private suspend fun DocumentFile.inputStream() = runInterruptible(Dispatchers.IO) { - contentResolver.openInputStream(uri) ?: throw IOException("Cannot open input stream: $uri") - } - - private fun DocumentFile.requireName(): String { - return name ?: throw IOException("Cannot fetch name from uri: $uri") - } - - private fun isDirectory(uri: Uri): Boolean { - return runCatching { - DocumentFile.fromTreeUri(context, uri) - }.isSuccess - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt deleted file mode 100644 index 6ac62b8de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt +++ /dev/null @@ -1,150 +0,0 @@ -package org.koitharu.kotatsu.local.data.input - -import androidx.core.net.toFile -import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.core.util.AlphanumComparator -import org.koitharu.kotatsu.core.util.ext.creationTime -import org.koitharu.kotatsu.core.util.ext.longHashCode -import org.koitharu.kotatsu.core.util.ext.toListSorted -import org.koitharu.kotatsu.core.util.ext.walkCompat -import org.koitharu.kotatsu.local.data.MangaIndex -import org.koitharu.kotatsu.local.data.hasCbzExtension -import org.koitharu.kotatsu.local.data.hasImageExtension -import org.koitharu.kotatsu.local.data.output.LocalMangaOutput -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.toCamelCase -import java.io.File -import java.util.TreeMap -import java.util.zip.ZipFile - -/** - * Manga {Folder} - * |--- index.json (optional) - * |--- Chapter 1.cbz - * |--- Page 1.png - * : - * L--- Page x.png - * |--- Chapter 2.cbz - * : - * L--- Chapter x.cbz - */ -class LocalMangaDirInput(root: File) : LocalMangaInput(root) { - - override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) { - val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) - val mangaUri = root.toUri().toString() - val chapterFiles = getChaptersFiles() - val info = index?.getMangaInfo() - val cover = fileUri( - root, - index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(), - ) - val manga = info?.copy2( - source = MangaSource.LOCAL, - url = mangaUri, - coverUrl = cover, - largeCoverUrl = cover, - chapters = info.chapters?.mapIndexedNotNull { i, c -> - val fileName = index.getChapterFileName(c.id) - val file = if (fileName != null) { - chapterFiles[fileName] - } else { - // old downloads - chapterFiles.values.elementAtOrNull(i) - } ?: return@mapIndexedNotNull null - c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) - }, - ) ?: Manga( - id = root.absolutePath.longHashCode(), - title = root.name.toHumanReadable(), - url = mangaUri, - publicUrl = mangaUri, - source = MangaSource.LOCAL, - coverUrl = findFirstImageEntry().orEmpty(), - chapters = chapterFiles.values.mapIndexed { i, f -> - MangaChapter( - id = "$i${f.name}".longHashCode(), - name = f.nameWithoutExtension.toHumanReadable(), - number = i + 1, - source = MangaSource.LOCAL, - uploadDate = f.creationTime, - url = f.toUri().toString(), - scanlator = null, - branch = null, - ) - }, - altTitle = null, - rating = -1f, - isNsfw = false, - tags = setOf(), - state = null, - author = null, - largeCoverUrl = null, - description = null, - ) - LocalManga(manga, root) - } - - override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { - val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) - index?.getMangaInfo() - } - - override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { - val file = chapter.url.toUri().toFile() - if (file.isDirectory) { - file.walkCompat() - .filter { hasImageExtension(it) } - .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) - .map { - val pageUri = it.toUri().toString() - MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL) - } - } else { - ZipFile(file).use { zip -> - zip.entries() - .asSequence() - .filter { x -> !x.isDirectory } - .map { it.name } - .toListSorted(AlphanumComparator()) - .map { - val pageUri = zipUri(file, it) - MangaPage( - id = pageUri.longHashCode(), - url = pageUri, - preview = null, - source = MangaSource.LOCAL, - ) - } - } - } - } - - private fun String.toHumanReadable() = replace("_", " ").toCamelCase() - - private fun getChaptersFiles() = root.walkCompat() - .filter { it.hasCbzExtension() } - .associateByTo(TreeMap(AlphanumComparator())) { it.name } - - private fun findFirstImageEntry(): String? { - return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() - ?: run { - val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null - ZipFile(cbz).use { zip -> - zip.entries().asSequence() - .firstOrNull { !it.isDirectory && hasImageExtension(it.name) } - ?.let { zipUri(cbz, it.name) } - } - } - } - - private fun fileUri(base: File, name: String): String { - return File(base, name).toUri().toString() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt deleted file mode 100644 index 45454631a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.koitharu.kotatsu.local.data.input - -import android.net.Uri -import androidx.core.net.toFile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.local.data.hasCbzExtension -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.toFileNameSafe -import java.io.File - -sealed class LocalMangaInput( - protected val root: File, -) { - - abstract suspend fun getManga(): LocalManga - - abstract suspend fun getMangaInfo(): Manga? - - abstract suspend fun getPages(chapter: MangaChapter): List - - companion object { - - fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile()) - - fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile()) - - fun of(file: File): LocalMangaInput = when { - file.isDirectory -> LocalMangaDirInput(file) - else -> LocalMangaZipInput(file) - } - - fun ofOrNull(file: File): LocalMangaInput? = when { - file.isDirectory -> LocalMangaDirInput(file) - hasCbzExtension(file.name) -> LocalMangaZipInput(file) - else -> null - } - - suspend fun find(roots: Iterable, manga: Manga): LocalMangaInput? = channelFlow { - val fileName = manga.title.toFileNameSafe() - for (root in roots) { - launch { - val dir = File(root, fileName) - val zip = File(root, "$fileName.cbz") - val input = when { - dir.isDirectory -> LocalMangaDirInput(dir) - zip.isFile -> LocalMangaZipInput(zip) - else -> null - } - val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull() - if (info?.id == manga.id) { - send(input) - } - } - } - }.flowOn(Dispatchers.Default).firstOrNull() - - @JvmStatic - protected fun zipUri(file: File, entryName: String): String = - Uri.fromParts("cbz", file.path, entryName).toString() - - @JvmStatic - protected fun Manga.copy2( - url: String, - coverUrl: String, - largeCoverUrl: String, - chapters: List?, - source: MangaSource, - ) = Manga( - id = id, - title = title, - altTitle = altTitle, - url = url, - publicUrl = publicUrl, - rating = rating, - isNsfw = isNsfw, - coverUrl = coverUrl, - tags = tags, - state = state, - author = author, - largeCoverUrl = largeCoverUrl, - description = description, - chapters = chapters, - source = source, - ) - - @JvmStatic - protected fun MangaChapter.copy( - url: String, - source: MangaSource, - ) = MangaChapter( - id = id, - name = name, - number = number, - url = url, - scanlator = scanlator, - uploadDate = uploadDate, - branch = branch, - source = source, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt deleted file mode 100644 index cc679b7b6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaZipInput.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.koitharu.kotatsu.local.data.input - -import android.net.Uri -import android.webkit.MimeTypeMap -import androidx.collection.ArraySet -import androidx.core.net.toFile -import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.core.util.ext.longHashCode -import org.koitharu.kotatsu.core.util.ext.readText -import org.koitharu.kotatsu.core.util.ext.toListSorted -import org.koitharu.kotatsu.local.data.MangaIndex -import org.koitharu.kotatsu.local.data.output.LocalMangaOutput -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.toCamelCase -import java.io.File -import java.util.Enumeration -import java.util.zip.ZipEntry -import java.util.zip.ZipFile - -/** - * Manga archive {.cbz or .zip file} - * |--- index.json (optional) - * |--- Page 1.png - * |--- Page 2.png - * : - * L--- Page x.png - */ -class LocalMangaZipInput(root: File) : LocalMangaInput(root) { - - override suspend fun getManga(): LocalManga { - val manga = runInterruptible(Dispatchers.IO) { - ZipFile(root).use { zip -> - val fileUri = root.toUri().toString() - val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) - val index = entry?.let(zip::readText)?.let(::MangaIndex) - val info = index?.getMangaInfo() - if (info != null) { - val cover = zipUri( - root, - entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), - ) - return@use info.copy2( - source = MangaSource.LOCAL, - url = fileUri, - coverUrl = cover, - largeCoverUrl = cover, - chapters = info.chapters?.map { c -> - c.copy(url = fileUri, source = MangaSource.LOCAL) - }, - ) - } - // fallback - val title = root.nameWithoutExtension.replace("_", " ").toCamelCase() - val chapters = ArraySet() - for (x in zip.entries()) { - if (!x.isDirectory) { - chapters += x.name.substringBeforeLast(File.separatorChar, "") - } - } - val uriBuilder = root.toUri().buildUpon() - Manga( - id = root.absolutePath.longHashCode(), - title = title, - url = fileUri, - publicUrl = fileUri, - source = MangaSource.LOCAL, - coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), - chapters = chapters.sortedWith(org.koitharu.kotatsu.core.util.AlphanumComparator()) - .mapIndexed { i, s -> - MangaChapter( - id = "$i$s".longHashCode(), - name = s.ifEmpty { title }, - number = i + 1, - source = MangaSource.LOCAL, - uploadDate = 0L, - url = uriBuilder.fragment(s).build().toString(), - scanlator = null, - branch = null, - ) - }, - altTitle = null, - rating = -1f, - isNsfw = false, - tags = setOf(), - state = null, - author = null, - largeCoverUrl = null, - description = null, - ) - } - } - return LocalManga(manga, root) - } - - override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { - ZipFile(root).use { zip -> - val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) - val index = entry?.let(zip::readText)?.let(::MangaIndex) - index?.getMangaInfo() - } - } - - override suspend fun getPages(chapter: MangaChapter): List { - return runInterruptible(Dispatchers.IO) { - val uri = Uri.parse(chapter.url) - val file = uri.toFile() - val zip = ZipFile(file) - val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) - var entries = zip.entries().asSequence() - entries = if (index != null) { - val pattern = index.getChapterNamesPattern(chapter) - entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } - } else { - val parent = uri.fragment.orEmpty() - entries.filter { x -> - !x.isDirectory && x.name.substringBeforeLast( - File.separatorChar, - "", - ) == parent - } - } - entries - .toListSorted(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) - .map { x -> - val entryUri = zipUri(file, x.name) - MangaPage( - id = entryUri.longHashCode(), - url = entryUri, - preview = null, - source = MangaSource.LOCAL, - ) - } - } - } - - private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { - val list = entries.toList() - .filterNot { it.isDirectory } - .sortedWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.name }) - val map = MimeTypeMap.getSingleton() - return list.firstOrNull { - map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) - ?.startsWith("image/") == true - } - } -} 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 deleted file mode 100644 index 9dd76c100..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.koitharu.kotatsu.local.data.output - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koitharu.kotatsu.core.model.findById -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.core.util.ext.takeIfReadable -import org.koitharu.kotatsu.core.zip.ZipOutput -import org.koitharu.kotatsu.local.data.MangaIndex -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.toFileNameSafe -import java.io.File - -class LocalMangaDirOutput( - rootFile: File, - manga: Manga, -) : LocalMangaOutput(rootFile) { - - private val chaptersOutput = HashMap() - private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) - private val mutex = Mutex() - - init { - if (!manga.isLocal) { - index.setMangaInfo(manga) - } - } - - override suspend fun mergeWithExisting() = Unit - - override suspend fun addCover(file: File, ext: String) = mutex.withLock { - val name = buildString { - append("cover") - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - runInterruptible(Dispatchers.IO) { - file.copyTo(File(rootFile, name), overwrite = true) - } - index.setCoverEntry(name) - flushIndex() - } - - override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock { - val output = chaptersOutput.getOrPut(chapter) { - ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) - } - val name = buildString { - append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { - append('.') - append(ext) - } - } - runInterruptible(Dispatchers.IO) { - output.put(name, file) - } - index.addChapter(chapter, chapterFileName(chapter)) - } - - override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock { - val output = chaptersOutput.remove(chapter) ?: return@withLock false - output.flushAndFinish() - flushIndex() - true - } - - override suspend fun finish() = mutex.withLock { - flushIndex() - for (output in chaptersOutput.values) { - output.flushAndFinish() - } - chaptersOutput.clear() - } - - override suspend fun cleanup() = mutex.withLock { - for (output in chaptersOutput.values) { - output.file.deleteAwait() - } - } - - override fun close() { - for (output in chaptersOutput.values) { - output.close() - } - } - - suspend fun deleteChapter(chapterId: Long) = mutex.withLock { - val chapter = checkNotNull(index.getMangaInfo()?.chapters) { - "No chapters found" - }.findById(chapterId) ?: error("Chapter not found") - val chapterDir = File(rootFile, chapterFileName(chapter)) - chapterDir.deleteAwait() - index.removeChapter(chapterId) - } - - fun setIndex(newIndex: MangaIndex) { - index.setFrom(newIndex) - } - - private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) { - finish() - close() - val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) - file.renameTo(resFile) - } - - private fun chapterFileName(chapter: MangaChapter): String { - index.getChapterFileName(chapter.id)?.let { - return it - } - val baseName = "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) - var i = 0 - while (true) { - val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz" - if (!File(rootFile, name).exists()) { - return name - } - i++ - } - } - - private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) { - File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString()) - } - - companion object { - - private const val FILENAME_PATTERN = "%08d_%03d%03d" - } -} 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 deleted file mode 100644 index ded98e4f7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.koitharu.kotatsu.local.data.output - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okio.Closeable -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.local.data.input.LocalMangaInput -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.parsers.util.toFileNameSafe -import java.io.File - -sealed class LocalMangaOutput( - val rootFile: File, -) : Closeable { - - abstract suspend fun mergeWithExisting() - - abstract suspend fun addCover(file: File, ext: String) - - abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) - - abstract suspend fun flushChapter(chapter: MangaChapter): Boolean - - abstract suspend fun finish() - - abstract suspend fun cleanup() - - companion object { - - const val ENTRY_NAME_INDEX = "index.json" - const val SUFFIX_TMP = ".tmp" - private val mutex = Mutex() - - suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) { - val preferSingleCbz = manga.chapters.let { - it != null && it.size <= 3 - } - checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz)) - } - - suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) { - getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false) - } - - private suspend fun getImpl( - root: File, - manga: Manga, - onlyIfExists: Boolean, - preferSingleCbz: Boolean, - ): LocalMangaOutput? { - mutex.withLock { - var i = 0 - val baseName = manga.title.toFileNameSafe() - while (true) { - val fileName = if (i == 0) baseName else baseName + "_$i" - val dir = File(root, fileName) - val zip = File(root, "$fileName.cbz") - i++ - return when { - dir.isDirectory -> { - if (canWriteTo(dir, manga)) { - LocalMangaDirOutput(dir, manga) - } else { - continue - } - } - - zip.isFile -> if (canWriteTo(zip, manga)) { - LocalMangaZipOutput(zip, manga) - } else { - continue - } - - !onlyIfExists -> if (preferSingleCbz) { - LocalMangaZipOutput(zip, manga) - } else { - LocalMangaDirOutput(dir, manga) - } - - else -> null - } - } - } - } - - private suspend fun canWriteTo(file: File, manga: Manga): Boolean { - val info = runCatchingCancellable { - LocalMangaInput.of(file).getMangaInfo() - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() ?: return false - return info.id == manga.id - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt deleted file mode 100644 index baa354cfa..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaUtil.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.local.data.output - -import androidx.core.net.toFile -import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource - -class LocalMangaUtil( - private val manga: Manga, -) { - - init { - require(manga.source == MangaSource.LOCAL) { - "Expected LOCAL source but ${manga.source} found" - } - } - - suspend fun deleteChapters(ids: Set) { - newOutput().use { output -> - when (output) { - is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) { - LocalMangaZipOutput.filterChapters(output, ids) - } - - is LocalMangaDirOutput -> { - for (id in ids) { - output.deleteChapter(id) - } - output.finish() - } - } - } - } - - private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) { - val file = manga.url.toUri().toFile() - if (file.isDirectory) { - LocalMangaDirOutput(file, manga) - } else { - LocalMangaZipOutput(file, manga) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt deleted file mode 100644 index b83867e5c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/util/ExtraCloseableSource.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.local.data.util - -import okhttp3.internal.closeQuietly -import okio.Closeable -import okio.Source - -private class ExtraCloseableSource( - private val delegate: Source, - private val extraCloseable: Closeable, -) : Source by delegate { - - override fun close() { - try { - delegate.close() - } finally { - extraCloseable.closeQuietly() - } - } -} - -fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt deleted file mode 100644 index 506b3a657..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteLocalMangaUseCase.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.local.domain - -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.io.IOException -import javax.inject.Inject - -class DeleteLocalMangaUseCase @Inject constructor( - private val localMangaRepository: LocalMangaRepository, - private val historyRepository: HistoryRepository, -) { - - suspend operator fun invoke(manga: Manga) { - val victim = if (manga.isLocal) manga else localMangaRepository.findSavedManga(manga)?.manga - checkNotNull(victim) { "Cannot find saved manga for ${manga.title}" } - val original = if (manga.isLocal) localMangaRepository.getRemoteManga(manga) else manga - localMangaRepository.delete(victim) || throw IOException("Unable to delete file") - runCatchingCancellable { - historyRepository.deleteOrSwap(victim, original) - }.onFailure { - it.printStackTraceDebug() - } - } - - suspend operator fun invoke(ids: Set) { - val list = localMangaRepository.getList(0, null) - var removed = 0 - for (manga in list) { - if (manga.id in ids) { - invoke(manga) - removed++ - } - } - check(removed == ids.size) { - "Removed $removed files but ${ids.size} requested" - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt deleted file mode 100644 index 1f23d304f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/model/LocalManga.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.local.domain.model - -import androidx.core.net.toFile -import androidx.core.net.toUri -import org.koitharu.kotatsu.core.util.ext.creationTime -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import java.io.File - -data class LocalManga( - val manga: Manga, - val file: File = manga.url.toUri().toFile(), -) { - - var createdAt: Long = -1L - private set - get() { - if (field == -1L) { - field = file.creationTime - } - return field - } - - fun isMatchesQuery(query: String): Boolean { - return manga.title.contains(query, ignoreCase = true) || - manga.altTitle?.contains(query, ignoreCase = true) == true - } - - fun containsTags(tags: Set): Boolean { - return manga.tags.containsAll(tags) - } - - fun containsAnyTag(tags: Set): Boolean { - return tags.any { tag -> - manga.tags.contains(tag) - } - } - - override fun toString(): String { - return "LocalManga(${file.path}: ${manga.title})" - } -} 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 deleted file mode 100644 index 933348cc8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -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.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.FragmentManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.databinding.DialogImportBinding - -class ImportDialogFragment : AlertDialogFragment(), View.OnClickListener { - - private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { - startImport(it) - } - private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { - startImport(listOfNotNull(it)) - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogImportBinding { - return DialogImportBinding.inflate(inflater, container, false) - } - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setTitle(R.string._import) - .setNegativeButton(android.R.string.cancel, null) - .setCancelable(true) - } - - override fun onViewBindingCreated(binding: DialogImportBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.buttonDir.setOnClickListener(this) - binding.buttonFile.setOnClickListener(this) - } - - override fun onClick(v: View) { - val res = when (v.id) { - R.id.button_file -> importFileCall.tryLaunch(arrayOf("*/*")) - R.id.button_dir -> importDirCall.tryLaunch(null) - else -> true - } - if (!res) { - Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() - } - } - - private fun startImport(uris: Collection) { - if (uris.isEmpty()) { - return - } - val ctx = requireContext() - ImportWorker.start(ctx, uris) - Toast.makeText(ctx, R.string.import_will_start_soon, 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/ImportWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportWorker.kt deleted file mode 100644 index 831f4514f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportWorker.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -import android.app.Notification -import android.app.PendingIntent -import android.content.Context -import android.content.pm.ServiceInfo -import android.net.Uri -import android.os.Build -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.hilt.work.HiltWorker -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ForegroundInfo -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import coil.ImageLoader -import coil.request.ImageRequest -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ErrorReporterReceiver -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -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 - -@HiltWorker -class ImportWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val importer: SingleMangaImporter, - private val coil: ImageLoader -) : CoroutineWorker(appContext, params) { - - private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } - - override suspend fun doWork(): Result { - val uri = inputData.getString(DATA_URI)?.toUriOrNull() ?: return Result.failure() - setForeground(getForegroundInfo()) - val result = runCatchingCancellable { - importer.import(uri).manga - } - if (applicationContext.checkNotificationPermission()) { - val notification = buildNotification(result) - notificationManager.notify(uri.hashCode(), notification) - } - return Result.success() - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - val title = applicationContext.getString(R.string.importing_manga) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(title) - .setShowBadge(false) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - notificationManager.createNotificationChannel(channel) - - val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setDefaults(0) - .setSilent(true) - .setOngoing(true) - .setProgress(0, 0, true) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .build() - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification) - } - } - - private suspend fun buildNotification(result: kotlin.Result): Notification { - val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setDefaults(0) - .setSilent(true) - .setAutoCancel(true) - result.onSuccess { manga -> - notification.setLargeIcon( - coil.execute( - ImageRequest.Builder(applicationContext) - .data(manga.coverUrl) - .tag(manga.source) - .build(), - ).toBitmapOrNull(), - ) - notification.setSubText(manga.title) - val intent = DetailsActivity.newIntent(applicationContext, manga) - notification.setContentIntent( - PendingIntentCompat.getActivity( - applicationContext, - manga.id.toInt(), - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - false, - ), - ).setVisibility( - if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC, - ) - notification.setContentTitle(applicationContext.getString(R.string.import_completed)) - .setContentText(applicationContext.getString(R.string.import_completed_hint)) - .setSmallIcon(R.drawable.ic_stat_done) - NotificationCompat.BigTextStyle(notification) - .bigText(applicationContext.getString(R.string.import_completed_hint)) - }.onFailure { error -> - notification.setContentTitle(applicationContext.getString(R.string.error_occurred)) - .setContentText(error.getDisplayMessage(applicationContext.resources)) - .setSmallIcon(android.R.drawable.stat_notify_error) - .addAction( - R.drawable.ic_alert_outline, - applicationContext.getString(R.string.report), - ErrorReporterReceiver.getPendingIntent(applicationContext, error), - ) - } - return notification.build() - } - - companion object { - - const val DATA_URI = "uri" - - private const val TAG = "import" - private const val CHANNEL_ID = "importing" - private const val FOREGROUND_NOTIFICATION_ID = 37 - - fun start(context: Context, uris: Iterable) { - val constraints = Constraints.Builder() - .setRequiresStorageNotLow(true) - .build() - val requests = uris.map { uri -> - OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG) - .setInputData(Data.Builder().putString(DATA_URI, uri.toString()).build()) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - } - WorkManager.getInstance(context) - .enqueue(requests) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt deleted file mode 100644 index b19b955da..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -import android.app.NotificationManager -import android.content.Context -import android.content.Intent -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableSharedFlow -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.ui.CoroutineIntentService -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -@AndroidEntryPoint -class LocalChaptersRemoveService : CoroutineIntentService() { - - @Inject - lateinit var localMangaRepository: LocalMangaRepository - - @Inject - @LocalStorageChanges - lateinit var localStorageChanges: MutableSharedFlow - - override fun onCreate() { - super.onCreate() - isRunning = true - } - - override fun onDestroy() { - isRunning = false - super.onDestroy() - } - - override suspend fun processIntent(startId: Int, intent: Intent) { - val manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga ?: return - val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return - startForeground() - val mangaWithChapters = localMangaRepository.getDetails(manga) - localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) - localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga))) - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - } - - override fun onError(startId: Int, error: Throwable) { - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.error_occurred)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setDefaults(0) - .setSilent(true) - .setContentText(error.getDisplayMessage(resources)) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setAutoCancel(true) - .build() - val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(NOTIFICATION_ID + startId, notification) - } - - private fun startForeground() { - val title = getString(R.string.local_manga_processing) - val manager = NotificationManagerCompat.from(this) - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(title) - .setShowBadge(false) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - manager.createNotificationChannel(channel) - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setDefaults(0) - .setSilent(true) - .setProgress(0, 0, true) - .setSmallIcon(android.R.drawable.stat_notify_sync) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) - .setOngoing(false) - .build() - startForeground(NOTIFICATION_ID, notification) - } - - companion object { - - var isRunning: Boolean = false - private set - - private const val CHANNEL_ID = "local_processing" - private const val NOTIFICATION_ID = 21 - - private const val EXTRA_MANGA = "manga" - private const val EXTRA_CHAPTERS_IDS = "chapters_ids" - - fun start(context: Context, manga: Manga, chaptersIds: Collection) { - if (chaptersIds.isEmpty()) { - return - } - val intent = Intent(context, LocalChaptersRemoveService::class.java) - intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) - intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) - ContextCompat.startForegroundService(context, intent) - } - } -} 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 deleted file mode 100644 index d3f27893f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ActionMode -import androidx.core.net.toFile -import androidx.core.net.toUri -import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -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.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter -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.remotelist.ui.RemoteListFragment - -class LocalListFragment : MangaListFragment(), FilterOwner { - - override val viewModel by viewModels() - - override val filter: MangaFilter - get() = viewModel - - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - addMenuProvider(LocalListMenuProvider(binding.root.context, this::onEmptyActionClick)) - viewModel.onMangaRemoved.observeEvent(viewLifecycleOwner) { onItemRemoved() } - } - - override fun onEmptyActionClick() { - ImportDialogFragment.show(childFragmentManager) - } - - override fun onFilterClick(view: View?) { - FilterSheetFragment.show(childFragmentManager) - } - - override fun onScrolledToEnd() = viewModel.loadNextPage() - - override fun onCreateActionMode( - controller: ListSelectionController, - mode: ActionMode, - menu: Menu, - ): Boolean { - mode.menuInflater.inflate(R.menu.mode_local, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - override fun onActionItemClicked( - controller: ListSelectionController, - mode: ActionMode, - item: MenuItem, - ): Boolean { - return when (item.itemId) { - R.id.action_remove -> { - showDeletionConfirm(selectedItemsIds, mode) - true - } - - R.id.action_share -> { - val files = selectedItems.map { it.url.toUri().toFile() } - ShareHelper(requireContext()).shareCbz(files) - mode.finish() - true - } - - else -> super.onActionItemClicked(controller, mode, item) - } - } - - private fun showDeletionConfirm(ids: Set, mode: ActionMode) { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.delete_manga) - .setMessage(getString(R.string.text_delete_local_manga_batch)) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.delete(ids) - mode.finish() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun onItemRemoved() { - Snackbar.make( - requireViewBinding().recyclerView, - R.string.removal_completed, - Snackbar.LENGTH_SHORT, - ).show() - } - - companion object { - - fun newInstance() = LocalListFragment().withArgs(1) { - putSerializable( - RemoteListFragment.ARG_SOURCE, - MangaSource.LOCAL, - ) // required by FilterCoordinator - } - } -} 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 deleted file mode 100644 index 0cd62a9c6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -import android.content.SharedPreferences -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -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.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.explore.domain.ExploreRepository -import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.local.data.LocalStorageChanges -import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase -import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel -import javax.inject.Inject - -@HiltViewModel -class LocalListViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - filter: FilterCoordinator, - private val settings: AppSettings, - downloadScheduler: DownloadWorker.Scheduler, - listExtraProvider: ListExtraProvider, - private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, - exploreRepository: ExploreRepository, - @LocalStorageChanges private val localStorageChanges: SharedFlow, -) : RemoteListViewModel( - savedStateHandle, - mangaRepositoryFactory, - filter, - settings, - listExtraProvider, - downloadScheduler, - exploreRepository, -), SharedPreferences.OnSharedPreferenceChangeListener { - - val onMangaRemoved = MutableEventFlow() - - init { - launchJob(Dispatchers.Default) { - localStorageChanges - .collect { - loadList(filter.snapshot(), append = false).join() - } - } - settings.subscribe(this) - } - - override fun onCleared() { - settings.unsubscribe(this) - super.onCleared() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == AppSettings.KEY_LOCAL_MANGA_DIRS) { - onRefresh() - } - } - - fun delete(ids: Set) { - launchLoadingJob(Dispatchers.Default) { - deleteLocalMangaUseCase(ids) - onMangaRemoved.call(Unit) - } - } - - override fun createEmptyState(canResetFilter: Boolean): EmptyState { - return EmptyState( - icon = R.drawable.ic_empty_local, - textPrimary = R.string.text_local_holder_primary, - textSecondary = R.string.text_local_holder_secondary, - actionStringRes = R.string._import, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt deleted file mode 100644 index 5791a3391..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.koitharu.kotatsu.local.ui - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.await -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import java.util.concurrent.TimeUnit - -@HiltWorker -class LocalStorageCleanupWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val localMangaRepository: LocalMangaRepository, - private val dataRepository: MangaDataRepository, -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result { - return if (localMangaRepository.cleanup()) { - dataRepository.cleanupLocalManga() - Result.success() - } else { - Result.retry() - } - } - - companion object { - - private const val TAG = "cleanup" - - suspend fun enqueue(context: Context) { - val constraints = Constraints.Builder() - .setRequiresBatteryNotLow(true) - .build() - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG) - .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES) - .build() - WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await() - } - } -} 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 deleted file mode 100644 index 11f34a6c2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.koitharu.kotatsu.main.domain - -import androidx.collection.ArraySet -import coil.intercept.Interceptor -import coil.network.HttpException -import coil.request.ErrorResult -import coil.request.ImageResult -import org.jsoup.HttpStatusException -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository -import org.koitharu.kotatsu.core.model.findById -import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.parsers.exception.ParseException -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import java.util.Collections -import javax.inject.Inject - -class CoverRestoreInterceptor @Inject constructor( - private val dataRepository: MangaDataRepository, - private val bookmarksRepository: BookmarksRepository, - private val repositoryFactory: MangaRepository.Factory, -) : Interceptor { - - private val blacklist = Collections.synchronizedSet(ArraySet()) - - override suspend fun intercept(chain: Interceptor.Chain): ImageResult { - val request = chain.request - val result = chain.proceed(request) - if (result is ErrorResult && result.throwable.shouldRestore()) { - request.tags.tag()?.let { - if (restoreManga(it)) { - return chain.proceed(request.newBuilder().build()) - } else { - return result - } - } - request.tags.tag()?.let { - if (restoreBookmark(it)) { - return chain.proceed(request.newBuilder().build()) - } else { - return result - } - } - } - return result - } - - private suspend fun restoreManga(manga: Manga): Boolean { - val key = manga.publicUrl - if (!blacklist.add(key)) { - return false - } - val restored = runCatchingCancellable { - restoreMangaImpl(manga) - }.getOrDefault(false) - if (restored) { - blacklist.remove(key) - } - return restored - } - - private suspend fun restoreMangaImpl(manga: Manga): Boolean { - if (dataRepository.findMangaById(manga.id) == null) { - return false - } - val repo = repositoryFactory.create(manga.source) as? RemoteMangaRepository ?: return false - val fixed = repo.find(manga) ?: return false - return if (fixed != manga) { - dataRepository.storeManga(fixed) - fixed.coverUrl != manga.coverUrl - } else { - false - } - } - - private suspend fun restoreBookmark(bookmark: Bookmark): Boolean { - val key = bookmark.imageUrl - if (!blacklist.add(key)) { - return false - } - val restored = runCatchingCancellable { - restoreBookmarkImpl(bookmark) - }.getOrDefault(false) - if (restored) { - blacklist.remove(key) - } - return restored - } - - private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean { - val repo = repositoryFactory.create(bookmark.manga.source) as? RemoteMangaRepository ?: return false - val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false - val page = repo.getPages(chapter)[bookmark.page] - val imageUrl = page.preview.ifNullOrEmpty { page.url } - return if (imageUrl != bookmark.imageUrl) { - bookmarksRepository.updateBookmark(bookmark, imageUrl) - true - } else { - false - } - } - - private fun Throwable.shouldRestore(): Boolean { - return this is HttpException || this is HttpStatusException || this is ParseException - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/ReadingResumeEnabledUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/ReadingResumeEnabledUseCase.kt deleted file mode 100644 index 26e9d26c3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/ReadingResumeEnabledUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.main.domain - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.parsers.model.MangaSource -import javax.inject.Inject - -class ReadingResumeEnabledUseCase @Inject constructor( - private val networkState: NetworkState, - private val historyRepository: HistoryRepository, - private val settings: AppSettings, -) { - - operator fun invoke(): Flow = settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { - isIncognitoModeEnabled - }.flatMapLatest { incognito -> - if (incognito) { - flowOf(false) - } else { - combine(networkState, historyRepository.observeLast()) { isOnline, last -> - last != null && (isOnline || last.source == MangaSource.LOCAL) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/ExitCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/ExitCallback.kt deleted file mode 100644 index ebdf7921b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/ExitCallback.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.koitharu.kotatsu.main.ui - -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner - -class ExitCallback( - private val activity: MainActivity, - private val snackbarHost: View, -) : OnBackPressedCallback(false) { - - private var job: Job? = null - - init { - observeSettings() - } - - override fun handleOnBackPressed() { - job?.cancel() - job = activity.lifecycleScope.launch { - resetExitConfirmation() - } - } - - private suspend fun resetExitConfirmation() { - isEnabled = false - val snackbar = Snackbar.make(snackbarHost, R.string.confirm_exit, Snackbar.LENGTH_INDEFINITE) - snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav - snackbar.show() - delay(2000) - snackbar.dismiss() - isEnabled = true - } - - private fun observeSettings() { - activity.settings - .observeAsFlow(AppSettings.KEY_EXIT_CONFIRM) { isExitConfirmationEnabled } - .flowOn(Dispatchers.Default) - .onEach { isEnabled = it } - .launchIn(activity.lifecycleScope) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActionButtonBehavior.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActionButtonBehavior.kt deleted file mode 100644 index 0c912e54e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActionButtonBehavior.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.main.ui - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton -import org.koitharu.kotatsu.core.ui.util.ShrinkOnScrollBehavior -import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView - -class MainActionButtonBehavior : ShrinkOnScrollBehavior { - - constructor() : super() - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - - override fun layoutDependsOn( - parent: CoordinatorLayout, - child: ExtendedFloatingActionButton, - dependency: View - ): Boolean { - return dependency is SlidingBottomNavigationView || super.layoutDependsOn(parent, child, dependency) - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: ExtendedFloatingActionButton, - dependency: View - ): Boolean { - val bottom = child.bottom - val bottomLine = parent.height - return if (bottom > bottomLine) { - ViewCompat.offsetTopAndBottom(child, bottomLine - bottom) - true - } else { - false - } - } -} 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 deleted file mode 100644 index e213ded93..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ /dev/null @@ -1,424 +0,0 @@ -package org.koitharu.kotatsu.main.ui - -import android.Manifest -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.os.Build -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.appcompat.view.ActionMode -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.fragment.app.commit -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.withResumed -import androidx.transition.TransitionManager -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL -import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP -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.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.NavItem -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.util.MenuInvalidator -import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper -import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView -import org.koitharu.kotatsu.core.util.ext.hideKeyboard -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.history.ui.HistoryListFragment -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.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.MultiSearchActivity -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.AppUpdateDialog -import javax.inject.Inject -import com.google.android.material.R as materialR - -private const val TAG_SEARCH = "search" - -@AndroidEntryPoint -class MainActivity : BaseActivity(), AppBarOwner, BottomNavOwner, - View.OnClickListener, - View.OnFocusChangeListener, SearchSuggestionListener, - MainNavigationDelegate.OnFragmentChangedListener { - - @Inject - lateinit var settings: AppSettings - - private val viewModel by viewModels() - private val searchSuggestionViewModel by viewModels() - private val closeSearchCallback = CloseSearchCallback() - private val appUpdateDialog = AppUpdateDialog(this) - private lateinit var navigationDelegate: MainNavigationDelegate - private lateinit var appUpdateBadge: OptionsMenuBadgeHelper - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override val bottomNav: SlidingBottomNavigationView? - get() = viewBinding.bottomNav - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityMainBinding.inflate(layoutInflater)) - - with(viewBinding.searchView) { - onFocusChangeListener = this@MainActivity - searchSuggestionListener = this@MainActivity - } - - viewBinding.fab?.setOnClickListener(this) - viewBinding.navRail?.headerView?.setOnClickListener(this) - - navigationDelegate = MainNavigationDelegate( - navBar = checkNotNull(bottomNav ?: viewBinding.navRail), - fragmentManager = supportFragmentManager, - settings = settings, - ) - navigationDelegate.addOnFragmentChangedListener(this) - navigationDelegate.onCreate(this, savedInstanceState) - - appUpdateBadge = OptionsMenuBadgeHelper(viewBinding.toolbar, R.id.action_app_update) - - onBackPressedDispatcher.addCallback(ExitCallback(this, viewBinding.container)) - onBackPressedDispatcher.addCallback(navigationDelegate) - onBackPressedDispatcher.addCallback(closeSearchCallback) - - if (savedInstanceState == null) { - onFirstStart() - } - - viewModel.onOpenReader.observeEvent(this, this::onOpenReader) - viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.container, null)) - viewModel.isLoading.observe(this, this::onLoadingStateChanged) - 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.isIncognitoMode.observe(this) { - adjustSearchUI(isSearchOpened(), false) - } - searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - adjustSearchUI(isSearchOpened(), animate = false) - } - - override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { - adjustFabVisibility(topFragment = fragment) - if (fromUser) { - actionModeDelegate.finishActionMode() - closeSearchCallback.handleOnBackPressed() - viewBinding.appbar.setExpanded(true) - } - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - super.onCreateOptionsMenu(menu) - menuInflater.inflate(R.menu.opt_main, menu) - return true - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (menu == null) { - return false - } - menu.findItem(R.id.action_incognito)?.isChecked = - searchSuggestionViewModel.isIncognitoModeEnabled.value - val hasAppUpdate = viewModel.appUpdate.value != null - menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate - appUpdateBadge.setBadgeVisible(hasAppUpdate) - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> if (isSearchOpened()) { - closeSearchCallback.handleOnBackPressed() - true - } else { - viewBinding.searchView.requestFocus() - true - } - - R.id.action_settings -> { - startActivity(SettingsActivity.newIntent(this)) - true - } - - R.id.action_incognito -> { - viewModel.setIncognitoMode(!item.isChecked) - true - } - - R.id.action_app_update -> { - viewModel.appUpdate.value?.also { - appUpdateDialog.show(it) - } != null - } - - else -> super.onOptionsItemSelected(item) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.fab, R.id.railFab -> viewModel.openLastReader() - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - override fun onFocusChange(v: View?, hasFocus: Boolean) { - val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) - if (v?.id == R.id.searchView && hasFocus) { - if (fragment == null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) - navigationDelegate.primaryFragment?.let { - setMaxLifecycle(it, Lifecycle.State.STARTED) - } - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - runOnCommit { onSearchOpened() } - } - } - } - } - - override fun onMangaClick(manga: Manga) { - startActivity(DetailsActivity.newIntent(this, manga)) - } - - override fun onQueryClick(query: String, submit: Boolean) { - viewBinding.searchView.query = query - if (submit && query.isNotEmpty()) { - startActivity(MultiSearchActivity.newIntent(this, query)) - searchSuggestionViewModel.saveQuery(query) - viewBinding.searchView.post { - closeSearchCallback.handleOnBackPressed() - } - } - } - - override fun onTagClick(tag: MangaTag) { - startActivity(MangaListActivity.newIntent(this, setOf(tag))) - } - - override fun onQueryChanged(query: String) { - searchSuggestionViewModel.onQueryChanged(query) - } - - override fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { - searchSuggestionViewModel.onSourceToggle(source, isEnabled) - } - - override fun onSourceClick(source: MangaSource) { - val intent = MangaListActivity.newIntent(this, source) - startActivity(intent) - } - - override fun onSupportActionModeStarted(mode: ActionMode) { - super.onSupportActionModeStarted(mode) - adjustFabVisibility() - bottomNav?.hide() - } - - override fun onSupportActionModeFinished(mode: ActionMode) { - super.onSupportActionModeFinished(mode) - adjustFabVisibility() - bottomNav?.show() - } - - 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) - } - - private fun onFeedCounterChanged(counter: Int) { - navigationDelegate.setCounter(NavItem.FEED, counter) - } - - private fun onIncognitoModeChanged(isIncognito: Boolean) { - var options = viewBinding.searchView.imeOptions - options = if (isIncognito) { - options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } else { - options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() - } - viewBinding.searchView.imeOptions = options - invalidateMenu() - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.fab?.isEnabled = !isLoading - } - - private fun onResumeEnabledChanged(isEnabled: Boolean) { - adjustFabVisibility(isResumeEnabled = isEnabled) - } - - private fun onSearchOpened() { - adjustSearchUI(isOpened = true, animate = true) - } - - private fun onSearchClosed() { - viewBinding.searchView.hideKeyboard() - adjustSearchUI(isOpened = false, animate = true) - } - - private fun isSearchOpened(): Boolean { - return supportFragmentManager.findFragmentByTag(TAG_SEARCH) != null - } - - private fun onFirstStart() { - lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher - withContext(Dispatchers.Default) { - LocalStorageCleanupWorker.enqueue(applicationContext) - } - withResumed { - MangaPrefetchService.prefetchLast(this@MainActivity) - requestNotificationsPermission() - } - } - } - - private fun adjustFabVisibility( - isResumeEnabled: Boolean = viewModel.isResumeEnabled.value, - topFragment: Fragment? = navigationDelegate.primaryFragment, - isSearchOpened: Boolean = isSearchOpened(), - ) { - val fab = viewBinding.fab ?: return - if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) { - if (!fab.isVisible) { - fab.show() - } - } else { - if (fab.isVisible) { - fab.hide() - } - } - } - - private fun adjustSearchUI(isOpened: Boolean, animate: Boolean) { - if (animate) { - TransitionManager.beginDelayedTransition(viewBinding.appbar) - } - val appBarScrollFlags = if (isOpened) { - SCROLL_FLAG_NO_SCROLL - } else { - SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP - } - viewBinding.toolbarCard.updateLayoutParams { - scrollFlags = appBarScrollFlags - } - viewBinding.insetsHolder.updateLayoutParams { - scrollFlags = appBarScrollFlags - } - viewBinding.toolbarCard.background = if (isOpened) { - null - } else { - ContextCompat.getDrawable(this, R.drawable.search_bar_background) - } - val padding = if (isOpened) 0 else resources.getDimensionPixelOffset(R.dimen.margin_normal) - viewBinding.appbar.updatePadding(left = padding, right = padding) - adjustFabVisibility(isSearchOpened = isOpened) - supportActionBar?.apply { - setHomeAsUpIndicator( - when { - isOpened -> materialR.drawable.abc_ic_ab_back_material - viewModel.isIncognitoMode.value -> R.drawable.ic_incognito - else -> materialR.drawable.abc_ic_search_api_material - }, - ) - setHomeActionContentDescription( - if (isOpened) R.string.back else R.string.search, - ) - } - viewBinding.searchView.setHintCompat( - if (isOpened) R.string.search_hint else R.string.search_manga, - ) - bottomNav?.showOrHide(!isOpened) - closeSearchCallback.isEnabled = isOpened - } - - private fun requestNotificationsPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS, - ) != PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - 1, - ) - } - } - - private inner class CloseSearchCallback : OnBackPressedCallback(false) { - - override fun handleOnBackPressed() { - val fm = supportFragmentManager - val fragment = fm.findFragmentByTag(TAG_SEARCH) - viewBinding.searchView.clearFocus() - if (fragment == null) { - // this should not happen but who knows - isEnabled = false - return - } - fm.commit { - setReorderingAllowed(true) - remove(fragment) - navigationDelegate.primaryFragment?.let { - setMaxLifecycle(it, Lifecycle.State.RESUMED) - } - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - runOnCommit { onSearchClosed() } - } - } - } -} 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 deleted file mode 100644 index ea1e11959..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ /dev/null @@ -1,208 +0,0 @@ -package org.koitharu.kotatsu.main.ui - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.activity.OnBackPressedCallback -import androidx.annotation.IdRes -import androidx.core.view.isEmpty -import androidx.core.view.iterator -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.google.android.material.navigation.NavigationBarView -import com.google.android.material.transition.MaterialFadeThrough -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.NavItem -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.explore.ui.ExploreFragment -import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment -import org.koitharu.kotatsu.history.ui.HistoryListFragment -import org.koitharu.kotatsu.local.ui.LocalListFragment -import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment -import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment -import java.util.LinkedList - -private const val TAG_PRIMARY = "primary" - -class MainNavigationDelegate( - private val navBar: NavigationBarView, - private val fragmentManager: FragmentManager, - private val settings: AppSettings, -) : OnBackPressedCallback(false), - NavigationBarView.OnItemSelectedListener, - NavigationBarView.OnItemReselectedListener { - - private val listeners = LinkedList() - - val primaryFragment: Fragment? - get() = fragmentManager.findFragmentByTag(TAG_PRIMARY) - - init { - navBar.setOnItemSelectedListener(this) - navBar.setOnItemReselectedListener(this) - } - - override fun onNavigationItemSelected(item: MenuItem): Boolean { - return onNavigationItemSelected(item.itemId) - } - - override fun onNavigationItemReselected(item: MenuItem) { - val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) - if (fragment == null || fragment !is RecyclerViewOwner || fragment.view == null) { - return - } - val recyclerView = fragment.recyclerView - if (recyclerView.context.isAnimationsEnabled) { - recyclerView.smoothScrollToPosition(0) - } else { - recyclerView.firstVisibleItemPosition = 0 - } - } - - override fun handleOnBackPressed() { - navBar.selectedItemId = firstItem()?.itemId ?: return - } - - fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) { - if (navBar.menu.isEmpty()) { - createMenu(settings.mainNavItems, navBar.menu) - } - observeSettings(lifecycleOwner) - val fragment = primaryFragment - if (fragment != null) { - onFragmentChanged(fragment, fromUser = false) - val itemId = getItemId(fragment) - if (navBar.selectedItemId != itemId) { - navBar.selectedItemId = itemId - } - } else { - val itemId = if (savedInstanceState == null) { - firstItem()?.itemId ?: navBar.selectedItemId - } else { - navBar.selectedItemId - } - onNavigationItemSelected(itemId) - } - } - - fun setCounter(item: NavItem, counter: Int) { - setCounter(item.id, counter) - } - - private fun setCounter(@IdRes id: Int, counter: Int) { - if (counter == 0) { - navBar.getBadge(id)?.isVisible = false - } else { - val badge = navBar.getOrCreateBadge(id) - if (counter < 0) { - badge.clearNumber() - } else { - badge.number = counter - } - badge.isVisible = true - } - } - - fun setItemVisibility(@IdRes itemId: Int, isVisible: Boolean) { - val item = navBar.menu.findItem(itemId) ?: return - item.isVisible = isVisible - if (item.isChecked && !isVisible) { - navBar.selectedItemId = firstItem()?.itemId ?: return - } - } - - fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) { - listeners.add(listener) - } - - fun removeOnFragmentChangedListener(listener: OnFragmentChangedListener) { - listeners.remove(listener) - } - - private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean { - return setPrimaryFragment( - when (itemId) { - R.id.nav_history -> HistoryListFragment() - R.id.nav_favorites -> FavouritesContainerFragment() - R.id.nav_explore -> ExploreFragment() - R.id.nav_feed -> FeedFragment() - R.id.nav_local -> LocalListFragment.newInstance() - R.id.nav_suggestions -> SuggestionsFragment() - R.id.nav_bookmarks -> BookmarksFragment() - else -> return false - }, - ) - } - - private fun getItemId(fragment: Fragment) = when (fragment) { - is HistoryListFragment -> R.id.nav_history - is FavouritesContainerFragment -> R.id.nav_favorites - is ExploreFragment -> R.id.nav_explore - is FeedFragment -> R.id.nav_feed - is LocalListFragment -> R.id.nav_local - is SuggestionsFragment -> R.id.nav_suggestions - is BookmarksFragment -> R.id.nav_bookmarks - else -> 0 - } - - private fun setPrimaryFragment(fragment: Fragment): Boolean { - if (fragmentManager.isStateSaved) { - return false - } - fragment.enterTransition = MaterialFadeThrough() - fragmentManager.beginTransaction() - .setReorderingAllowed(true) - .replace(R.id.container, fragment, TAG_PRIMARY) - .commit() - onFragmentChanged(fragment, fromUser = true) - return true - } - - private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { - isEnabled = getItemId(fragment) != firstItem()?.itemId - listeners.forEach { it.onFragmentChanged(fragment, fromUser) } - } - - private fun createMenu(items: List, menu: Menu) { - for (item in items) { - menu.add(Menu.NONE, item.id, Menu.NONE, item.title) - .setIcon(item.icon) - } - } - - private fun observeSettings(lifecycleOwner: LifecycleOwner) { - settings.observe() - .filter { x -> x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS } - .onStart { emit("") } - .flowOn(Dispatchers.Default) - .onEach { - setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled) - setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled) - }.launchIn(lifecycleOwner.lifecycleScope) - } - - private fun firstItem(): MenuItem? { - val menu = navBar.menu - for (item in menu) { - if (item.isVisible) return item - } - return null - } - - interface OnFragmentChangedListener { - - fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt deleted file mode 100644 index c62274e39..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.main.ui - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException -import org.koitharu.kotatsu.core.github.AppUpdateRepository -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.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -@HiltViewModel -class MainViewModel @Inject constructor( - private val historyRepository: HistoryRepository, - private val appUpdateRepository: AppUpdateRepository, - trackingRepository: TrackingRepository, - private val settings: AppSettings, - readingResumeEnabledUseCase: ReadingResumeEnabledUseCase, - private val sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - val onOpenReader = MutableEventFlow() - val onFirstStart = MutableEventFlow() - - val isResumeEnabled = readingResumeEnabledUseCase().stateIn( - scope = viewModelScope + Dispatchers.Default, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false, - ) - - val isIncognitoMode = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_INCOGNITO_MODE, - valueProducer = { isIncognitoModeEnabled }, - ) - - val appUpdate = appUpdateRepository.observeAvailableUpdate() - - val feedCounter = trackingRepository.observeUpdatedMangaCount() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, 0) - - init { - launchJob { - appUpdateRepository.fetchUpdate() - } - launchJob(Dispatchers.Default) { - if (sourcesRepository.isSetupRequired()) { - onFirstStart.call(Unit) - } - } - } - - fun openLastReader() { - launchLoadingJob(Dispatchers.Default) { - val manga = historyRepository.getLastOrNull() ?: throw EmptyHistoryException() - onOpenReader.call(manga) - } - } - - fun setIncognitoMode(isEnabled: Boolean) { - settings.isIncognitoModeEnabled = isEnabled - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/BottomNavOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/BottomNavOwner.kt deleted file mode 100644 index ac6d1f0c6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/BottomNavOwner.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.main.ui.owners - -import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView - -interface BottomNavOwner { - - val bottomNav: SlidingBottomNavigationView? -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt deleted file mode 100644 index 3920088d4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/NoModalBottomSheetOwner.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.koitharu.kotatsu.main.ui.owners - -interface NoModalBottomSheetOwner { - - fun getBottomSheetCollapsedHeight(): Int -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/SnackbarOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/SnackbarOwner.kt deleted file mode 100644 index 778010fa7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/owners/SnackbarOwner.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.main.ui.owners - -import androidx.coordinatorlayout.widget.CoordinatorLayout - -interface SnackbarOwner { - - val snackbarHost: CoordinatorLayout -} 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 deleted file mode 100644 index a5f25dcef..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.koitharu.kotatsu.main.ui.welcome - -import android.accounts.AccountManager -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -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.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -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.parsers.util.toTitleCase -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment -import java.util.Locale - -@AndroidEntryPoint -class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipClickListener, View.OnClickListener, - ActivityResultCallback { - - private val viewModel by viewModels() - - private val backupSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocument(), - this, - ) - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding { - return SheetWelcomeBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet) - binding.chipsLocales.onChipClickListener = this - binding.chipsType.onChipClickListener = this - binding.chipBackup.setOnClickListener(this) - binding.chipSync.setOnClickListener(this) - - viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged) - viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) - } - - override fun onChipClick(chip: Chip, data: Any?) { - when (data) { - is ContentType -> viewModel.setTypeChecked(data, chip.isChecked) - is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked) - } - } - - override fun onClick(v: View) { - when (v.id) { - R.id.chip_backup -> { - if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { - Snackbar.make( - v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - } - - R.id.chip_sync -> { - val am = AccountManager.get(v.context) - val accountType = getString(R.string.account_type_sync) - am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) - } - } - } - - override fun onActivityResult(result: Uri?) { - if (result != null) { - RestoreDialogFragment.show(parentFragmentManager, result) - } - } - - private fun onLocalesChanged(value: FilterProperty) { - val chips = viewBinding?.chipsLocales ?: return - chips.setChips( - value.availableItems.map { - ChipsView.ChipModel( - tint = 0, - title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages), - icon = 0, - isCheckable = true, - isChecked = it in value.selectedItems, - data = it, - ) - }, - ) - } - - private fun onTypesChanged(value: FilterProperty) { - val chips = viewBinding?.chipsType ?: return - chips.setChips( - value.availableItems.map { - ChipsView.ChipModel( - tint = 0, - title = getString(it.titleResId), - icon = 0, - isCheckable = true, - isChecked = it in value.selectedItems, - data = it, - ) - }, - ) - } - - companion object { - - private const val TAG = "WelcomeSheet" - - fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG) - - fun dismiss(fm: FragmentManager): Boolean { - val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false - sheet.dismissAllowingStateLoss() - return true - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt deleted file mode 100644 index 311a6d64b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.main.ui.welcome - -import android.content.Context -import androidx.core.os.ConfigurationCompat -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.LocaleComparator -import org.koitharu.kotatsu.core.util.ext.sortedWithSafe -import org.koitharu.kotatsu.core.util.ext.toList -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mapToSet -import java.util.EnumSet -import java.util.Locale -import javax.inject.Inject - -@HiltViewModel -class WelcomeViewModel @Inject constructor( - private val repository: MangaSourcesRepository, - @ApplicationContext context: Context, -) : BaseViewModel() { - - private val allSources = repository.allMangaSources - private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } } - - private var updateJob: Job - - val locales = MutableStateFlow( - FilterProperty( - availableItems = listOf(null), - selectedItems = setOf(null), - isLoading = true, - error = null, - ), - ) - - val types = MutableStateFlow( - FilterProperty( - availableItems = ContentType.entries.toList(), - selectedItems = setOf(ContentType.MANGA), - isLoading = false, - error = null, - ), - ) - - init { - updateJob = launchJob(Dispatchers.Default) { - val languages = localesGroups.keys.associateBy { x -> x?.language } - val selectedLocales = HashSet(2) - selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList() - .firstNotNullOfOrNull { lc -> languages[lc.language] } - selectedLocales += null - locales.value = locales.value.copy( - availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())), - selectedItems = selectedLocales, - isLoading = false, - ) - } - } - - fun setLocaleChecked(locale: Locale?, isChecked: Boolean) { - val snapshot = locales.value - locales.value = snapshot.copy( - selectedItems = if (isChecked) { - snapshot.selectedItems + locale - } else { - snapshot.selectedItems - locale - }, - ) - val prevJob = updateJob - updateJob = launchJob(Dispatchers.Default) { - prevJob.join() - commit() - } - } - - fun setTypeChecked(type: ContentType, isChecked: Boolean) { - val snapshot = types.value - types.value = snapshot.copy( - selectedItems = if (isChecked) { - snapshot.selectedItems + type - } else { - snapshot.selectedItems - type - }, - ) - val prevJob = updateJob - updateJob = launchJob(Dispatchers.Default) { - prevJob.join() - commit() - } - } - - private suspend fun commit() { - val languages = locales.value.selectedItems.mapToSet { it?.language } - val types = types.value.selectedItems - val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x -> - x.contentType in types && x.locale in languages - } - repository.setSourcesEnabledExclusive(enabledSources) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChapterPages.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChapterPages.kt deleted file mode 100644 index 7175f53f2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChapterPages.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import androidx.collection.LongSparseArray -import androidx.collection.contains -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage - -class ChapterPages private constructor(private val pages: ArrayDeque) : List by pages { - - // map chapterId to index in pages deque - private val indices = LongSparseArray() - - constructor() : this(ArrayDeque()) - - val chaptersSize: Int - get() = indices.size() - - fun removeFirst() { - val chapterId = pages.first().chapterId - indices.remove(chapterId) - var delta = 0 - while (pages.first().chapterId == chapterId) { - pages.removeFirst() - delta-- - } - shiftIndices(delta) - } - - fun removeLast() { - val chapterId = pages.last().chapterId - indices.remove(chapterId) - while (pages.last().chapterId == chapterId) { - pages.removeLast() - } - } - - fun addLast(id: Long, newPages: List) { - indices.put(id, pages.size until (pages.size + newPages.size)) - pages.addAll(newPages) - } - - fun addFirst(id: Long, newPages: List) { - shiftIndices(newPages.size) - indices.put(id, newPages.indices) - pages.addAll(0, newPages) - } - - fun clear() { - indices.clear() - pages.clear() - } - - fun size(id: Long) = indices[id]?.run { - endInclusive - start + 1 - } ?: 0 - - fun subList(id: Long): List { - val range = indices[id] ?: return emptyList() - return pages.subList(range.first, range.last + 1) - } - - operator fun contains(chapterId: Long) = indices.contains(chapterId) - - private fun shiftIndices(delta: Int) { - for (i in 0 until indices.size()) { - val range = indices.valueAt(i) - indices.setValueAt(i, range + delta) - } - } - - private operator fun IntRange.plus(delta: Int): IntRange { - return IntRange(start + delta, endInclusive + delta) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt deleted file mode 100644 index 9463b6b87..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ChaptersLoader.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import android.util.LongSparseArray -import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import javax.inject.Inject - -private const val PAGES_TRIM_THRESHOLD = 120 - -@ViewModelScoped -class ChaptersLoader @Inject constructor( - private val mangaRepositoryFactory: MangaRepository.Factory, -) { - - private val chapters = LongSparseArray() - private val chapterPages = ChapterPages() - private val mutex = Mutex() - - val size: Int - get() = chapters.size() - - suspend fun init(manga: MangaDetails) = mutex.withLock { - chapters.clear() - manga.allChapters.forEach { - chapters.put(it.id, it) - } - } - - suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) { - val chapters = manga.allChapters - val predicate: (MangaChapter) -> Boolean = { it.id == currentId } - val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) - if (index == -1) return - val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return - val newPages = loadChapter(newChapter.id) - mutex.withLock { - if (chapterPages.chaptersSize > 1) { - // trim pages - if (chapterPages.size > PAGES_TRIM_THRESHOLD) { - if (isNext) { - chapterPages.removeFirst() - } else { - chapterPages.removeLast() - } - } - } - if (isNext) { - chapterPages.addLast(newChapter.id, newPages) - } else { - chapterPages.addFirst(newChapter.id, newPages) - } - } - } - - suspend fun loadSingleChapter(chapterId: Long) { - val pages = loadChapter(chapterId) - mutex.withLock { - chapterPages.clear() - chapterPages.addLast(chapterId, pages) - } - } - - fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId] - - fun hasPages(chapterId: Long): Boolean { - return chapterId in chapterPages - } - - fun getPages(chapterId: Long): List { - return chapterPages.subList(chapterId) - } - - fun getPagesCount(chapterId: Long): Int { - return chapterPages.size(chapterId) - } - - fun last() = chapterPages.last() - - fun first() = chapterPages.first() - - fun snapshot() = chapterPages.toList() - - private suspend fun loadChapter(chapterId: Long): List { - val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" } - val repo = mangaRepositoryFactory.create(chapter.source) - return repo.getPages(chapter).mapIndexed { index, page -> - ReaderPage(page, index, chapterId) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt deleted file mode 100644 index 049914d3c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import android.graphics.BitmapFactory -import android.net.Uri -import android.util.Size -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okhttp3.OkHttpClient -import org.koitharu.kotatsu.core.model.findChapter -import org.koitharu.kotatsu.core.network.ImageProxyInterceptor -import org.koitharu.kotatsu.core.network.MangaHttpClient -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.prefs.ReaderMode -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.io.InputStream -import java.util.zip.ZipFile -import javax.inject.Inject -import kotlin.math.roundToInt - -class DetectReaderModeUseCase @Inject constructor( - private val dataRepository: MangaDataRepository, - private val settings: AppSettings, - private val mangaRepositoryFactory: MangaRepository.Factory, - @MangaHttpClient private val okHttpClient: OkHttpClient, - private val imageProxyInterceptor: ImageProxyInterceptor, -) { - - suspend operator fun invoke(manga: Manga, state: ReaderState?): ReaderMode { - dataRepository.getReaderMode(manga.id)?.let { return it } - val defaultMode = settings.defaultReaderMode - if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) { - return defaultMode - } - val chapter = state?.let { manga.findChapter(it.chapterId) } - ?: manga.chapters?.firstOrNull() - ?: error("There are no chapters in this manga") - val repo = mangaRepositoryFactory.create(manga.source) - val pages = repo.getPages(chapter) - return runCatchingCancellable { - val isWebtoon = guessMangaIsWebtoon(repo, pages) - if (isWebtoon) ReaderMode.WEBTOON else defaultMode - }.onSuccess { - dataRepository.saveReaderMode(manga, it) - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(defaultMode) - } - - /** - * Automatic determine type of manga by page size - * @return ReaderMode.WEBTOON if page is wide - */ - private suspend fun guessMangaIsWebtoon(repository: MangaRepository, pages: List): Boolean { - val pageIndex = (pages.size * 0.3).roundToInt() - val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" } - val url = repository.getPageUrl(page) - val uri = Uri.parse(url) - val size = if (uri.scheme == "cbz") { - runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - zip.getInputStream(entry).use { - getBitmapSize(it) - } - } - } else { - val request = PageLoader.createPageRequest(page, url) - imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { - runInterruptible(Dispatchers.IO) { - getBitmapSize(it.body?.byteStream()) - } - } - } - return size.width * MIN_WEBTOON_RATIO < size.height - } - - companion object { - - private const val MIN_WEBTOON_RATIO = 1.8 - - private fun getBitmapSize(input: InputStream?): Size { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeStream(input, null, options)?.recycle() - val imageHeight: Int = options.outHeight - val imageWidth: Int = options.outWidth - check(imageHeight > 0 && imageWidth > 0) - return Size(imageWidth, imageHeight) - } - } -} 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 deleted file mode 100644 index c7fa16972..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ /dev/null @@ -1,255 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import android.content.Context -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.annotation.AnyThread -import androidx.collection.LongSparseArray -import androidx.collection.set -import androidx.core.net.toFile -import androidx.core.net.toUri -import dagger.hilt.android.ActivityRetainedLifecycle -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.scopes.ActivityRetainedScoped -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.withPermit -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.use -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.network.ImageProxyInterceptor -import org.koitharu.kotatsu.core.network.MangaHttpClient -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope -import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP -import org.koitharu.kotatsu.core.util.ext.compressToPNG -import org.koitharu.kotatsu.core.util.ext.ensureRamAtLeast -import org.koitharu.kotatsu.core.util.ext.ensureSuccess -import org.koitharu.kotatsu.core.util.ext.exists -import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull -import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode -import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.ramAvailable -import org.koitharu.kotatsu.core.util.ext.withProgress -import org.koitharu.kotatsu.core.util.progress.ProgressDeferred -import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.data.isZipUri -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import java.util.LinkedList -import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipFile -import javax.inject.Inject -import kotlin.concurrent.Volatile -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext - -@ActivityRetainedScoped -class PageLoader @Inject constructor( - @ApplicationContext private val context: Context, - lifecycle: ActivityRetainedLifecycle, - @MangaHttpClient private val okHttp: OkHttpClient, - private val cache: PagesCache, - private val settings: AppSettings, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val imageProxyInterceptor: ImageProxyInterceptor, -) { - - val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default - - private val tasks = LongSparseArray>() - private val semaphore = Semaphore(3) - private val convertLock = Mutex() - private val prefetchLock = Mutex() - - @Volatile - private var repository: MangaRepository? = null - private val prefetchQueue = LinkedList() - private val counter = AtomicInteger(0) - private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive - - fun isPrefetchApplicable(): Boolean { - return repository is RemoteMangaRepository - && settings.isPagesPreloadEnabled - && !context.isPowerSaveMode() - && !isLowRam() - } - - @AnyThread - fun prefetch(pages: List) = loaderScope.launch { - prefetchLock.withLock { - for (page in pages.asReversed()) { - if (tasks.containsKey(page.id)) { - continue - } - prefetchQueue.offerFirst(page.toMangaPage()) - if (prefetchQueue.size > prefetchQueueLimit) { - prefetchQueue.pollLast() - } - } - } - if (counter.get() == 0) { - onIdle() - } - } - - fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred { - var task = tasks[page.id]?.takeIf { it.isValid() } - if (force) { - task?.cancel() - } else if (task?.isCancelled == false) { - return task - } - task = loadPageAsyncImpl(page, force) - synchronized(tasks) { - tasks[page.id] = task - } - return task - } - - suspend fun loadPage(page: MangaPage, force: Boolean): Uri { - return loadPageAsync(page, force).await() - } - - suspend fun convertBimap(uri: Uri): Uri = convertLock.withLock { - if (uri.isZipUri()) { - val bitmap = runInterruptible(Dispatchers.IO) { - ZipFile(uri.schemeSpecificPart).use { zip -> - val entry = zip.getEntry(uri.fragment) - context.ensureRamAtLeast(entry.size * 2) - zip.getInputStream(zip.getEntry(uri.fragment)).use { - BitmapFactory.decodeStream(it) - } - } - } - cache.put(uri.toString(), bitmap).toUri() - } else { - val file = uri.toFile() - context.ensureRamAtLeast(file.length() * 2) - val image = runInterruptible(Dispatchers.IO) { - BitmapFactory.decodeFile(file.absolutePath) - } - try { - image.compressToPNG(file) - } finally { - image.recycle() - } - uri - } - } - - suspend fun getPageUrl(page: MangaPage): String { - return getRepository(page.source).getPageUrl(page) - } - - private fun onIdle() = loaderScope.launch { - prefetchLock.withLock { - while (prefetchQueue.isNotEmpty()) { - val page = prefetchQueue.pollFirst() ?: return@launch - if (cache.get(page.url) == null) { - synchronized(tasks) { - tasks[page.id] = loadPageAsyncImpl(page, false) - } - return@launch - } - } - } - } - - private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred { - val progress = MutableStateFlow(PROGRESS_UNDEFINED) - val deferred = loaderScope.async { - if (!skipCache) { - cache.get(page.url)?.let { return@async it.toUri() } - } - counter.incrementAndGet() - try { - loadPageImpl(page, progress) - } finally { - if (counter.decrementAndGet() == 0) { - onIdle() - } - } - } - return ProgressDeferred(deferred, progress) - } - - @Synchronized - private fun getRepository(source: MangaSource): MangaRepository { - val result = repository - return if (result != null && result.source == source) { - result - } else { - mangaRepositoryFactory.create(source).also { repository = it } - } - } - - private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): Uri = semaphore.withPermit { - val pageUrl = getPageUrl(page) - check(pageUrl.isNotBlank()) { "Cannot obtain full image url" } - val uri = Uri.parse(pageUrl) - return if (uri.isZipUri()) { - if (uri.scheme == URI_SCHEME_ZIP) { - uri - } else { // legacy uri - uri.buildUpon().scheme(URI_SCHEME_ZIP).build() - } - } else { - val request = createPageRequest(page, pageUrl) - imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> - val body = checkNotNull(response.body) { "Null response body" } - body.withProgress(progress).use { - cache.put(pageUrl, it.source()) - } - }.toUri() - } - } - - private fun isLowRam(): Boolean { - return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) - } - - private fun Deferred.isValid(): Boolean { - return getCompletionResultOrNull()?.map { uri -> - uri.exists() && uri.isTargetNotEmpty() - }?.getOrDefault(false) ?: true - } - - private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), - CoroutineExceptionHandler { - - override fun handleException(context: CoroutineContext, exception: Throwable) { - exception.printStackTraceDebug() - } - } - - companion object { - - private const val PROGRESS_UNDEFINED = -1f - private const val PREFETCH_LIMIT_DEFAULT = 6 - private const val PREFETCH_MIN_RAM_MB = 80L - - fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder() - .url(pageUrl) - .get() - .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") - .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) - .tag(MangaSource::class.java, page.source) - .build() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt deleted file mode 100644 index eb5c73669..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.koitharu.kotatsu.reader.domain - -import android.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter - -data class ReaderColorFilter( - val brightness: Float, - val contrast: Float, - val isInverted: Boolean, - val isGrayscale: Boolean, -) { - - val isEmpty: Boolean - get() = !isGrayscale && !isInverted && brightness == 0f && contrast == 0f - - fun toColorFilter(): ColorMatrixColorFilter { - val cm = ColorMatrix() - if (isGrayscale) { - cm.grayscale() - } - if (isInverted) { - cm.inverted() - } - cm.setBrightness(brightness) - cm.setContrast(contrast) - return ColorMatrixColorFilter(cm) - } - - private fun ColorMatrix.setBrightness(brightness: Float) { - val scale = brightness + 1f - val matrix = ColorMatrix() - matrix.setScale(scale, scale, scale, 1f) - postConcat(matrix) - } - - private fun ColorMatrix.setContrast(contrast: Float) { - val scale = contrast + 1f - val translate = (-.5f * scale + .5f) * 255f - val array = floatArrayOf( - scale, 0f, 0f, 0f, translate, - 0f, scale, 0f, 0f, translate, - 0f, 0f, scale, 0f, translate, - 0f, 0f, 0f, 1f, 0f, - ) - val matrix = ColorMatrix(array) - postConcat(matrix) - } - - private fun ColorMatrix.inverted() { - val matrix = floatArrayOf( - -1.0f, 0.0f, 0.0f, 1.0f, 1.0f, - 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, - 0.0f, 0.0f, -1.0f, 1.0f, 1.0f, - 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, - ) - postConcat(ColorMatrix(matrix)) - } - - private fun ColorMatrix.grayscale() { - setSaturation(0f) - } - - companion object { - - val EMPTY = ReaderColorFilter( - brightness = 0.0f, - contrast = 0.0f, - isInverted = false, - isGrayscale = false, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt deleted file mode 100644 index 30b609152..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ChaptersSheet.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -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.activityViewModels -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.databinding.SheetChaptersBinding -import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter -import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.details.ui.model.toListItem -import org.koitharu.kotatsu.parsers.model.MangaChapter -import javax.inject.Inject -import kotlin.math.roundToInt - -@AndroidEntryPoint -class ChaptersSheet : BaseAdaptiveSheet(), - OnListItemClickListener { - - @Inject - lateinit var settings: AppSettings - - private val viewModel: ReaderViewModel by activityViewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = SheetChaptersBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: SheetChaptersBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val chapters = viewModel.manga?.allChapters - if (chapters.isNullOrEmpty()) { - dismissAllowingStateLoss() - return - } - val currentId = viewModel.getCurrentState()?.chapterId ?: 0L - val currentPosition = chapters.indexOfFirst { it.id == currentId } - val items = chapters.mapIndexed { index, chapter -> - chapter.toListItem( - isCurrent = index == currentPosition, - isUnread = index > currentPosition, - isNew = false, - isDownloaded = false, - isBookmarked = false, - ) - } - binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> - if (currentPosition >= 0) { - val targetPosition = (currentPosition - 1).coerceAtLeast(0) - val offset = - (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt() - adapter.setItems( - items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset), - ) - } else { - adapter.items = items - } - } - } - - override fun onItemClick(item: ChapterListItem, view: View) { - ((parentFragment as? OnChapterChangeListener) - ?: (activity as? OnChapterChangeListener))?.let { - dismiss() - it.onChapterChanged(item.chapter) - } - } - - fun interface OnChapterChangeListener { - - fun onChapterChanged(chapter: MangaChapter) - } - - companion object { - - private const val TAG = "ChaptersBottomSheet" - - fun show(fm: FragmentManager) = ChaptersSheet().showDistinct(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt deleted file mode 100644 index 749e7884c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageLabelFormatter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import com.google.android.material.slider.LabelFormatter -import org.koitharu.kotatsu.parsers.util.format - -class PageLabelFormatter : LabelFormatter { - - override fun getFormattedValue(value: Float): String { - return (value + 1).format(0) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt deleted file mode 100644 index bee4837b6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ /dev/null @@ -1,471 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.transition.Fade -import android.transition.Slide -import android.transition.TransitionManager -import android.transition.TransitionSet -import android.view.Gravity -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.view.WindowManager -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.OnApplyWindowInsetsListener -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.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.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ReaderMode -import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity -import org.koitharu.kotatsu.core.ui.widgets.ZoomControl -import org.koitharu.kotatsu.core.util.GridTouchHelper -import org.koitharu.kotatsu.core.util.IdlingDetector -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.isRtl -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.postDelayed -import org.koitharu.kotatsu.core.util.ext.setValueRounded -import org.koitharu.kotatsu.core.util.ext.zipWithPrevious -import org.koitharu.kotatsu.databinding.ActivityReaderBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener -import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet -import org.koitharu.kotatsu.settings.SettingsActivity -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@AndroidEntryPoint -class ReaderActivity : - BaseFullscreenActivity(), - ChaptersSheet.OnChapterChangeListener, - GridTouchHelper.OnGridTouchListener, - OnPageSelectListener, - ReaderConfigSheet.Callback, - ReaderControlDelegate.OnInteractionListener, - OnApplyWindowInsetsListener, - IdlingDetector.Callback, - ZoomControl.ZoomControlListener { - - @Inject - lateinit var settings: AppSettings - - private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this) - - private val viewModel: ReaderViewModel by viewModels() - - override val readerMode: ReaderMode? - get() = readerManager.currentMode - - override var isAutoScrollEnabled: Boolean - get() = scrollTimer.isEnabled - set(value) { - scrollTimer.isEnabled = value - } - - @Inject - lateinit var scrollTimerFactory: ScrollTimer.Factory - - private lateinit var scrollTimer: ScrollTimer - private lateinit var touchHelper: GridTouchHelper - private lateinit var controlDelegate: ReaderControlDelegate - private var gestureInsets: Insets = Insets.NONE - private lateinit var readerManager: ReaderManager - private val hideUiRunnable = Runnable { setUiIsVisible(false) } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityReaderBinding.inflate(layoutInflater)) - readerManager = ReaderManager(supportFragmentManager, R.id.container) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - touchHelper = GridTouchHelper(this, this) - scrollTimer = scrollTimerFactory.create(this, this) - controlDelegate = ReaderControlDelegate(resources, settings, this, this) - viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) - viewBinding.slider.setLabelFormatter(PageLabelFormatter()) - viewBinding.zoomControl.listener = this - ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider) - insetsDelegate.interceptingWindowInsetsListener = this - idlingDetector.bindToLifecycle(this) - - viewModel.onError.observeEvent( - this, - DialogErrorObserver( - host = viewBinding.container, - fragment = null, - resolver = exceptionResolver, - onResolved = { isResolved -> - if (isResolved) { - viewModel.reload() - } else if (viewModel.content.value.pages.isEmpty()) { - finishAfterTransition() - } - }, - ), - ) - viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader) - viewModel.onPageSaved.observeEvent(this, this::onPageSaved) - viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged) - viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.content.observe(this) { - onLoadingStateChanged(viewModel.isLoading.value) - } - viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) - viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) - viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) - viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) - viewModel.onShowToast.observeEvent(this) { msgId -> - Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) - .setAnchorView(viewBinding.appbarBottom) - .show() - } - viewModel.isZoomControlsEnabled.observe(this) { - viewBinding.zoomControl.isVisible = it - } - } - - override fun getParentActivityIntent(): Intent? { - val manga = viewModel.manga?.toManga() ?: return null - return DetailsActivity.newIntent(this, manga) - } - - override fun onUserInteraction() { - super.onUserInteraction() - scrollTimer.onUserInteraction() - idlingDetector.onUserInteraction() - } - - override fun onIdle() { - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - } - - override fun onZoomIn() { - readerManager.currentReader?.onZoomIn() - } - - override fun onZoomOut() { - readerManager.currentReader?.onZoomOut() - } - - private fun onInitReader(mode: ReaderMode?) { - if (mode == null) { - return - } - if (readerManager.currentMode != mode) { - readerManager.replace(mode) - } - if (viewBinding.appbarTop.isVisible) { - lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable) - } - viewBinding.slider.isRtl = mode == ReaderMode.REVERSED - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.opt_reader_top, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_settings -> { - startActivity(SettingsActivity.newReaderSettingsIntent(this)) - } - - R.id.action_chapters -> { - ChaptersSheet.show(supportFragmentManager) - } - - R.id.action_pages_thumbs -> { - val state = viewModel.getCurrentState() ?: return false - PagesThumbnailsSheet.show( - supportFragmentManager, - viewModel.manga?.toManga() ?: return false, - state.chapterId, - state.page, - ) - } - - R.id.action_bookmark -> { - if (viewModel.isBookmarkAdded.value) { - viewModel.removeBookmark() - } else { - viewModel.addBookmark() - } - } - - R.id.action_options -> { - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - val currentMode = readerManager.currentMode ?: return false - ReaderConfigSheet.show(supportFragmentManager, currentMode) - } - - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - val hasPages = viewModel.content.value.pages.isNotEmpty() - viewBinding.layoutLoading.isVisible = isLoading && !hasPages - if (isLoading && hasPages) { - viewBinding.toastView.show(R.string.loading_) - } else { - viewBinding.toastView.hide() - } - val menu = viewBinding.toolbarBottom.menu - menu.findItem(R.id.action_bookmark).isVisible = hasPages - menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages - } - - override fun onGridTouch(area: Int) { - controlDelegate.onGridTouch(area, viewBinding.container) - } - - override fun onProcessTouch(rawX: Int, rawY: Int): Boolean { - return if ( - rawX <= gestureInsets.left || - rawY <= gestureInsets.top || - rawX >= viewBinding.root.width - gestureInsets.right || - rawY >= viewBinding.root.height - gestureInsets.bottom || - viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) || - viewBinding.appbarBottom?.hasGlobalPoint(rawX, rawY) == true - ) { - false - } else { - val touchables = window.peekDecorView()?.touchables - touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true - } - } - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - touchHelper.dispatchTouchEvent(ev) - scrollTimer.onTouchEvent(ev) - return super.dispatchTouchEvent(ev) - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) - } - - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) - } - - override fun onChapterChanged(chapter: MangaChapter) { - viewModel.switchChapter(chapter.id, 0) - } - - override fun onPageSelected(page: ReaderPage) { - lifecycleScope.launch(Dispatchers.Default) { - val pages = viewModel.content.value.pages - val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id } - if (index != -1) { - withContext(Dispatchers.Main) { - readerManager.currentReader?.switchPageTo(index, true) - } - } else { - viewModel.switchChapter(page.chapterId, page.index) - } - } - } - - override fun onReaderModeChanged(mode: ReaderMode) { - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - viewModel.switchMode(mode) - } - - private fun onPageSaved(uri: Uri?) { - if (uri != null) { - Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG) - .setAction(R.string.share) { - ShareHelper(this).shareImage(uri) - } - } else { - Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT) - }.setAnchorView(viewBinding.appbarBottom) - .show() - } - - private fun setWindowSecure(isSecure: Boolean) { - if (isSecure) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } - - private fun setKeepScreenOn(isKeep: Boolean) { - if (isKeep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - private fun setUiIsVisible(isUiVisible: Boolean) { - if (viewBinding.appbarTop.isVisible != isUiVisible) { - if (isAnimationsEnabled) { - val transition = TransitionSet() - .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)) - } - TransitionManager.beginDelayedTransition(viewBinding.root, transition) - } - viewBinding.appbarTop.isVisible = isUiVisible - viewBinding.appbarBottom?.isVisible = isUiVisible - viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value) - systemUiController.setSystemUiVisible(isUiVisible) - } - } - - override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { - gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures()) - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - viewBinding.appbarTop.updatePadding( - top = systemBars.top, - right = systemBars.right, - left = systemBars.left, - ) - viewBinding.appbarBottom?.updateLayoutParams { - bottomMargin = systemBars.bottom + topMargin - rightMargin = systemBars.right + topMargin - leftMargin = systemBars.left + topMargin - } - return WindowInsetsCompat.Builder(insets) - .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) - .build() - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - override fun switchPageBy(delta: Int) { - readerManager.currentReader?.switchPageBy(delta) - } - - override fun scrollBy(delta: Int, smooth: Boolean): Boolean { - return readerManager.currentReader?.scrollBy(delta, smooth) ?: false - } - - override fun toggleUiVisibility() { - setUiIsVisible(!viewBinding.appbarTop.isVisible) - } - - override fun isReaderResumed(): Boolean { - val reader = readerManager.currentReader ?: return false - return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader - } - - private fun onReaderBarChanged(isBarEnabled: Boolean) { - viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone - } - - private fun onBookmarkStateChanged(isAdded: Boolean) { - val menuItem = viewBinding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return - menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add) - menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) - } - - private fun onUiStateChanged(pair: Pair) { - val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair - title = uiState?.resolveTitle(this) ?: getString(R.string.loading_) - viewBinding.infoBar.update(uiState) - if (uiState == null) { - supportActionBar?.subtitle = null - viewBinding.slider.isVisible = false - return - } - supportActionBar?.subtitle = uiState.chapterName - if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) { - if (!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 - } - } - - 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 - } - - 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 - } -} 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 deleted file mode 100644 index b45834d2e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt +++ /dev/null @@ -1,199 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Rect -import android.util.AttributeSet -import android.view.View -import android.view.WindowInsets -import androidx.annotation.AttrRes -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils -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.measureDimension -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.parsers.util.format -import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import com.google.android.material.R as materialR - -class ReaderInfoBarView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - @AttrRes defStyleAttr: Int = 0, -) : View(context, attrs, defStyleAttr) { - - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - private val textBounds = Rect() - private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - private val timeReceiver = TimeReceiver() - private var insetLeft: Int = 0 - private var insetRight: Int = 0 - private var insetTop: Int = 0 - private var cutoutInsetLeft = 0 - private var cutoutInsetRight = 0 - 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 timeText = timeFormat.format(LocalTime.now()) - private var text: String = "" - - private val innerHeight - get() = height - paddingTop - paddingBottom - insetTop - - private val innerWidth - get() = width - paddingLeft - paddingRight - insetLeft - insetRight - - init { - paint.strokeWidth = context.resources.resolveDp(2f) - val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding") - val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback) - val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner - val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner - val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL - insetLeft = if (isRtl) insetEnd else insetStart - insetRight = if (isRtl) insetStart else insetEnd - insetTop = minOf(insetLeft, insetRight) - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight - val desiredHeight = suggestedMinimumHeight + paddingTop + paddingBottom + insetTop - setMeasuredDimension( - measureDimension(desiredWidth, widthMeasureSpec), - measureDimension(desiredHeight, heightMeasureSpec), - ) - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom - paint.textAlign = Paint.Align.LEFT - canvas.drawTextOutline( - text, - (paddingLeft + insetLeft + cutoutInsetLeft).toFloat(), - paddingTop + insetTop + ty, - ) - paint.textAlign = Paint.Align.RIGHT - canvas.drawTextOutline( - timeText, - (width - paddingRight - insetRight - cutoutInsetRight).toFloat(), - paddingTop + insetTop + ty, - ) - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - updateCutoutInsets(ViewCompat.getRootWindowInsets(this)) - updateTextSize() - } - - override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { - updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets)) - return super.onApplyWindowInsets(insets) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - ContextCompat.registerReceiver( - context, - timeReceiver, - IntentFilter(Intent.ACTION_TIME_TICK), - ContextCompat.RECEIVER_EXPORTED, - ) - updateCutoutInsets(ViewCompat.getRootWindowInsets(this)) - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - context.unregisterReceiver(timeReceiver) - } - - fun update(state: ReaderUiState?) { - text = if (state != null) { - context.getString( - R.string.reader_info_pattern, - state.chapterNumber, - state.chaptersTotal, - state.currentPage + 1, - state.totalPages, - ) + if (state.percent in 0f..1f) { - " " + context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) - } else { - "" - } - } else { - "" - } - updateTextSize() - invalidate() - } - - private fun updateTextSize() { - val str = text + timeText - val testTextSize = 48f - paint.textSize = testTextSize - paint.getTextBounds(str, 0, str.length, textBounds) - paint.textSize = testTextSize * innerHeight / textBounds.height() - paint.getTextBounds(str, 0, str.length, textBounds) - } - - 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 updateCutoutInsets(insetsCompat: WindowInsetsCompat?) { - val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty() - cutoutInsetLeft = 0 - cutoutInsetRight = 0 - for (rect in cutouts) { - if (rect.left <= paddingLeft) { - cutoutInsetLeft += rect.width() - } - if (rect.right >= width - paddingRight) { - cutoutInsetRight += rect.width() - } - } - } - - private inner class TimeReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context?, intent: Intent?) { - timeText = timeFormat.format(LocalTime.now()) - invalidate() - } - } - - @SuppressLint("DiscouragedApi") - private fun getSystemUiDimensionOffset(name: String, fallback: Int = 0): Int = runCatching { - val manager = context.packageManager - val resources = manager.getResourcesForApplication("com.android.systemui") - val resId = resources.getIdentifier(name, "dimen", "com.android.systemui") - resources.getDimensionPixelOffset(resId) - }.onFailure { - it.printStackTraceDebug() - }.getOrDefault(fallback) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt deleted file mode 100644 index 50e87a4d9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import com.google.android.material.slider.Slider -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener - -class ReaderSliderListener( - private val pageSelectListener: OnPageSelectListener, - private val viewModel: ReaderViewModel, -) : Slider.OnChangeListener, Slider.OnSliderTouchListener { - - private var isChanged = false - private var isTracking = false - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - if (fromUser) { - if (isTracking) { - isChanged = true - } else { - switchPageToIndex(value.toInt()) - } - } - } - - override fun onStartTrackingTouch(slider: Slider) { - isChanged = false - isTracking = true - } - - override fun onStopTrackingTouch(slider: Slider) { - isTracking = false - if (isChanged) { - switchPageToIndex(slider.value.toInt()) - } - } - - fun attachToSlider(slider: Slider) { - slider.addOnChangeListener(this) - slider.addOnSliderTouchListener(this) - } - - private fun switchPageToIndex(index: Int) { - val pages = viewModel.getCurrentChapterPages() - val page = pages?.getOrNull(index) ?: return - val chapterId = viewModel.getCurrentState()?.chapterId ?: return - pageSelectListener.onPageSelected(ReaderPage(page, index, chapterId)) - } -} 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 deleted file mode 100644 index 5ce6a348d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ /dev/null @@ -1,417 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.net.Uri -import androidx.activity.result.ActivityResultLauncher -import androidx.annotation.AnyThread -import androidx.annotation.MainThread -import androidx.annotation.WorkerThread -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -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 -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository -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.ScreenshotsPolicy -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.requireValue -import org.koitharu.kotatsu.details.data.MangaDetails -import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.history.data.PROGRESS_NONE -import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.reader.domain.ChaptersLoader -import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings -import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import java.time.Instant -import javax.inject.Inject - -private const val BOUNDS_PAGE_OFFSET = 2 -private const val PREFETCH_LIMIT = 10 - -@HiltViewModel -class ReaderViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val dataRepository: MangaDataRepository, - private val historyRepository: HistoryRepository, - private val bookmarksRepository: BookmarksRepository, - private val settings: AppSettings, - private val pageSaveHelper: PageSaveHelper, - private val pageLoader: PageLoader, - private val chaptersLoader: ChaptersLoader, - private val appShortcutManager: AppShortcutManager, - private val detailsLoadUseCase: DetailsLoadUseCase, - private val historyUpdateUseCase: HistoryUpdateUseCase, - private val detectReaderModeUseCase: DetectReaderModeUseCase, -) : BaseViewModel() { - - private val intent = MangaIntent(savedStateHandle) - private val preselectedBranch = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) - private val isIncognito = savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) ?: false - - private var loadingJob: Job? = null - private var pageSaveJob: Job? = null - private var bookmarkJob: Job? = null - private var stateChangeJob: Job? = null - private val currentState = MutableStateFlow(savedStateHandle[ReaderActivity.EXTRA_STATE]) - private val mangaData = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) - private val mangaFlow: Flow - get() = mangaData.map { it?.toManga() } - - val readerMode = MutableStateFlow(null) - val onPageSaved = MutableEventFlow() - val onShowToast = MutableEventFlow() - val uiState = MutableStateFlow(null) - - val content = MutableStateFlow(ReaderContent(emptyList(), null)) - val manga: MangaDetails? - get() = mangaData.value - - val pageAnimation = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_ANIMATION, - valueProducer = { readerAnimation }, - ) - - val isInfoBarEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_BAR, - valueProducer = { isReaderBarEnabled }, - ) - - val isKeepScreenOnEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_READER_SCREEN_ON, - valueProducer = { isReaderKeepScreenOn }, - ) - - val isWebtoonZooEnabled = observeIsWebtoonZoomEnabled() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom -> - if (zoom) { - combine(readerMode, isWebtoonZooEnabled) { mode, ze -> ze || mode != ReaderMode.WEBTOON } - } else { - flowOf(false) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val readerSettings = ReaderSettings( - parentScope = viewModelScope, - settings = settings, - colorFilterFlow = mangaFlow.flatMapLatest { - if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null), - ) - - val isScreenshotsBlockEnabled = combine( - mangaFlow, - settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy }, - ) { manga, policy -> - policy == ScreenshotsPolicy.BLOCK_ALL || - (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val isBookmarkAdded = currentState.flatMapLatest { state -> - val manga = mangaData.value?.toManga() - if (state == null || manga == null) { - flowOf(false) - } else { - bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) - .map { - it != null && it.chapterId == state.chapterId && it.page == state.page - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - - init { - loadImpl() - settings.observe() - .onEach { key -> - if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() - }.launchIn(viewModelScope + Dispatchers.Default) - launchJob(Dispatchers.Default) { - val mangaId = mangaFlow.filterNotNull().first().id - appShortcutManager.notifyMangaOpened(mangaId) - } - } - - fun reload() { - loadingJob?.cancel() - loadImpl() - } - - fun switchMode(newMode: ReaderMode) { - launchJob { - val manga = checkNotNull(mangaData.value?.toManga()) - dataRepository.saveReaderMode( - manga = manga, - mode = newMode, - ) - readerMode.value = newMode - content.update { - it.copy(state = getCurrentState()) - } - } - } - - fun saveCurrentState(state: ReaderState? = null) { - if (state != null) { - currentState.value = state - } - if (isIncognito) { - return - } - val readerState = state ?: currentState.value ?: return - historyUpdateUseCase.invokeAsync( - manga = mangaData.value?.toManga() ?: return, - readerState = readerState, - percent = computePercent(readerState.chapterId, readerState.page), - ) - } - - fun getCurrentState() = currentState.value - - fun getCurrentChapterPages(): List? { - val chapterId = currentState.value?.chapterId ?: return null - return chaptersLoader.getPages(chapterId).map { it.toMangaPage() } - } - - fun saveCurrentPage( - page: MangaPage, - saveLauncher: ActivityResultLauncher, - ) { - val prevJob = pageSaveJob - pageSaveJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - try { - val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher) - onPageSaved.call(dest) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTraceDebug() - onPageSaved.call(null) - } - } - } - - fun onActivityResult(uri: Uri?) { - if (uri != null) { - pageSaveHelper.onActivityResult(uri) - } else { - pageSaveJob?.cancel() - pageSaveJob = null - } - } - - fun getCurrentPage(): MangaPage? { - val state = currentState.value ?: return null - return content.value.pages.find { - it.chapterId == state.chapterId && it.index == state.page - }?.toMangaPage() - } - - fun switchChapter(id: Long, page: Int) { - val prevJob = loadingJob - loadingJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - content.value = ReaderContent(emptyList(), null) - chaptersLoader.loadSingleChapter(id) - content.value = ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0)) - } - } - - @MainThread - fun onCurrentPageChanged(position: Int) { - val prevJob = stateChangeJob - val pages = content.value.pages // capture immediately - stateChangeJob = launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - loadingJob?.join() - if (pages.size != content.value.pages.size) { - return@launchJob // TODO - } - pages.getOrNull(position)?.let { page -> - currentState.update { cs -> - cs?.copy(chapterId = page.chapterId, page = page.index) - } - } - notifyStateChanged() - if (pages.isEmpty() || loadingJob?.isActive == true) { - return@launchJob - } - ensureActive() - if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.last().chapterId, isNext = true) - } - if (position <= BOUNDS_PAGE_OFFSET) { - loadPrevNextChapter(pages.first().chapterId, isNext = false) - } - if (pageLoader.isPrefetchApplicable()) { - pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT)) - } - } - } - - fun addBookmark() { - if (bookmarkJob?.isActive == true) { - return - } - bookmarkJob = launchJob(Dispatchers.Default) { - loadingJob?.join() - val state = checkNotNull(currentState.value) - val page = checkNotNull(getCurrentPage()) { "Page not found" } - val bookmark = Bookmark( - manga = mangaData.requireValue().toManga(), - pageId = page.id, - chapterId = state.chapterId, - page = state.page, - scroll = state.scroll, - imageUrl = page.preview.ifNullOrEmpty { page.url }, - createdAt = Instant.now(), - percent = computePercent(state.chapterId, state.page), - ) - bookmarksRepository.addBookmark(bookmark) - onShowToast.call(R.string.bookmark_added) - } - } - - fun removeBookmark() { - if (bookmarkJob?.isActive == true) { - return - } - bookmarkJob = launchJob { - loadingJob?.join() - val manga = mangaData.requireValue().toManga() - val state = checkNotNull(getCurrentState()) - bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) - onShowToast.call(R.string.bookmark_removed) - } - } - - private fun loadImpl() { - loadingJob = launchLoadingJob(Dispatchers.Default) { - val details = detailsLoadUseCase.invoke(intent).first { x -> x.isLoaded } - mangaData.value = details - chaptersLoader.init(details) - val manga = details.toManga() - // obtain state - if (currentState.value == null) { - currentState.value = historyRepository.getOne(manga)?.let { - ReaderState(it) - } ?: ReaderState(manga, preselectedBranch) - } - val mode = detectReaderModeUseCase.invoke(manga, currentState.value) - val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch - mangaData.value = details.filterChapters(branch) - readerMode.value = mode - - chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId) - // save state - if (!isIncognito) { - currentState.value?.let { - val percent = computePercent(it.chapterId, it.page) - historyUpdateUseCase.invoke(manga, it, percent) - } - } - notifyStateChanged() - content.value = ReaderContent(chaptersLoader.snapshot(), currentState.value) - } - } - - @AnyThread - private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) { - val prevJob = loadingJob - loadingJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.join() - chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) - content.value = ReaderContent(chaptersLoader.snapshot(), null) - } - } - - private fun List.trySublist(fromIndex: Int, toIndex: Int): List { - val fromIndexBounded = fromIndex.coerceAtMost(lastIndex) - val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex) - return if (fromIndexBounded == toIndexBounded) { - emptyList() - } else { - subList(fromIndexBounded, toIndexBounded) - } - } - - @WorkerThread - private fun notifyStateChanged() { - val state = getCurrentState() - val chapter = state?.chapterId?.let { chaptersLoader.peekChapter(it) } - val newState = ReaderUiState( - mangaName = manga?.toManga()?.title, - branch = chapter?.branch, - chapterName = chapter?.name, - chapterNumber = chapter?.number ?: 0, - chaptersTotal = manga?.chapters?.get(chapter?.branch)?.size ?: 0, - totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0, - currentPage = state?.page ?: 0, - isSliderEnabled = settings.isReaderSliderEnabled, - percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE, - ) - uiState.value = newState - } - - private fun computePercent(chapterId: Long, pageIndex: Int): Float { - val branch = chaptersLoader.peekChapter(chapterId)?.branch - val chapters = manga?.chapters?.get(branch) ?: return PROGRESS_NONE - val chaptersCount = chapters.size - val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } - val pagesCount = chaptersLoader.getPagesCount(chapterId) - if (chaptersCount == 0 || pagesCount == 0) { - return PROGRESS_NONE - } - val pagePercent = (pageIndex + 1) / pagesCount.toFloat() - val ppc = 1f / chaptersCount - return ppc * chapterIndex + ppc * pagePercent - } - - private fun observeIsWebtoonZoomEnabled() = settings.observeAsFlow( - key = AppSettings.KEY_WEBTOON_ZOOM, - valueProducer = { isWebtoonZoomEnable }, - ) - - private fun getObserveIsZoomControlEnabled() = settings.observeAsFlow( - key = AppSettings.KEY_READER_ZOOM_BUTTONS, - valueProducer = { isReaderZoomButtonsEnabled }, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt deleted file mode 100644 index ec09b8adb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScrollTimer.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.view.MotionEvent -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import kotlin.math.roundToLong - -private const val MAX_DELAY = 8L -private const val MAX_SWITCH_DELAY = 10_000L -private const val INTERACTION_SKIP_MS = 2_000L -private const val SPEED_FACTOR_DELTA = 0.02f - -class ScrollTimer @AssistedInject constructor( - @Assisted private val listener: ReaderControlDelegate.OnInteractionListener, - @Assisted lifecycleOwner: LifecycleOwner, - settings: AppSettings, -) { - - private val coroutineScope = lifecycleOwner.lifecycleScope - private var job: Job? = null - private var delayMs: Long = 10L - private var pageSwitchDelay: Long = 100L - private var resumeAt = 0L - private var isTouchDown = MutableStateFlow(false) - - var isEnabled: Boolean = false - set(value) { - if (field != value) { - field = value - restartJob() - } - } - - init { - settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) { - readerAutoscrollSpeed - }.flowOn(Dispatchers.Default) - .onEach { - onSpeedChanged(it) - }.launchIn(coroutineScope) - } - - fun onUserInteraction() { - resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS - } - - fun onTouchEvent(event: MotionEvent) { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - isTouchDown.value = true - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - isTouchDown.value = false - } - } - } - - private fun onSpeedChanged(speed: Float) { - if (speed <= 0f) { - delayMs = 0L - pageSwitchDelay = 0L - } else { - val speedFactor = 1 - speed - delayMs = (MAX_DELAY * speedFactor).roundToLong() - pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong() - } - if ((job == null) != (delayMs == 0L)) { - restartJob() - } - } - - private fun restartJob() { - job?.cancel() - resumeAt = 0L - if (!isEnabled || delayMs == 0L) { - job = null - return - } - job = coroutineScope.launch { - var accumulator = 0L - var speedFactor = 1f - while (isActive) { - if (isPaused()) { - speedFactor = (speedFactor - SPEED_FACTOR_DELTA).coerceAtLeast(0f) - } else if (speedFactor < 1f) { - speedFactor = (speedFactor + SPEED_FACTOR_DELTA).coerceAtMost(1f) - } - if (speedFactor == 1f) { - delay(delayMs) - } else if (speedFactor == 0f) { - delayUntilResumed() - continue - } else { - delay((delayMs * (1f + speedFactor * 2)).toLong()) - } - if (!listener.isReaderResumed()) { - continue - } - if (!listener.scrollBy(1, false)) { - accumulator += delayMs - } - if (accumulator >= pageSwitchDelay) { - listener.switchPageBy(1) - accumulator -= pageSwitchDelay - } - } - } - } - - private fun isPaused(): Boolean { - return isTouchDown.value || resumeAt > System.currentTimeMillis() - } - - private suspend fun delayUntilResumed() { - while (isPaused()) { - val delayTime = resumeAt - System.currentTimeMillis() - if (delayTime > 0) { - delay(delayTime) - } else { - yield() - } - isTouchDown.first { !it } - } - } - - @AssistedFactory - interface Factory { - - fun create( - lifecycleOwner: LifecycleOwner, - listener: ReaderControlDelegate.OnInteractionListener, - ): ScrollTimer - } -} 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 deleted file mode 100644 index 8fb632c59..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.colorfilter - -import android.content.Context -import android.content.Intent -import android.content.res.Resources -import android.graphics.Bitmap -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Scale -import coil.size.ViewSizeResolver -import com.google.android.material.dialog.MaterialAlertDialogBuilder -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 -import org.koitharu.kotatsu.core.util.ext.indicator -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.setChecked -import org.koitharu.kotatsu.core.util.ext.setValueRounded -import org.koitharu.kotatsu.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.reader.domain.ReaderColorFilter -import javax.inject.Inject -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class ColorFilterConfigActivity : - BaseActivity(), - Slider.OnChangeListener, - View.OnClickListener, CompoundButton.OnCheckedChangeListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel: ColorFilterConfigViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityColorFilterBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } - viewBinding.sliderBrightness.addOnChangeListener(this) - viewBinding.sliderContrast.addOnChangeListener(this) - val formatter = PercentLabelFormatter(resources) - viewBinding.sliderContrast.setLabelFormatter(formatter) - viewBinding.sliderBrightness.setLabelFormatter(formatter) - viewBinding.switchInvert.setOnCheckedChangeListener(this) - viewBinding.switchGrayscale.setOnCheckedChangeListener(this) - viewBinding.buttonDone.setOnClickListener(this) - viewBinding.buttonReset.setOnClickListener(this) - - onBackPressedDispatcher.addCallback(ColorFilterConfigBackPressedDispatcher(this, viewModel)) - - viewModel.colorFilter.observe(this, this::onColorFilterChanged) - viewModel.isLoading.observe(this, this::onLoadingChanged) - viewModel.onDismiss.observeEvent(this) { - finishAfterTransition() - } - loadPreview(viewModel.preview) - } - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - if (fromUser) { - when (slider.id) { - R.id.slider_brightness -> viewModel.setBrightness(value) - R.id.slider_contrast -> viewModel.setContrast(value) - } - } - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - when (buttonView.id) { - R.id.switch_invert -> viewModel.setInversion(isChecked) - R.id.switch_grayscale -> viewModel.setGrayscale(isChecked) - } - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_done -> showSaveConfirmation() - R.id.button_reset -> viewModel.reset() - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.scrollView.updatePadding( - bottom = insets.bottom, - ) - viewBinding.toolbar.updateLayoutParams { - topMargin = insets.top - } - } - - fun showSaveConfirmation() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.apply) - .setMessage(R.string.color_correction_apply_text) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.this_manga) { _, _ -> - viewModel.save() - }.setNeutralButton(R.string.globally) { _, _ -> - viewModel.saveGlobally() - }.show() - } - - 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.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() - } - - private fun loadPreview(page: MangaPage) { - val data: Any = page.preview?.takeUnless { it.isEmpty() } ?: page - ImageRequest.Builder(this@ColorFilterConfigActivity) - .data(data) - .scale(Scale.FILL) - .decodeRegion() - .tag(page.source) - .bitmapConfig(if (viewModel.is32BitColorsEnabled) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565) - .indicator(listOf(viewBinding.progressBefore, viewBinding.progressAfter)) - .error(R.drawable.ic_error_placeholder) - .size(ViewSizeResolver(viewBinding.imageViewBefore)) - .target(DoubleViewTarget(viewBinding.imageViewBefore, viewBinding.imageViewAfter)) - .enqueueWith(coil) - } - - private fun onLoadingChanged(isLoading: Boolean) { - viewBinding.sliderContrast.isEnabled = !isLoading - viewBinding.sliderBrightness.isEnabled = !isLoading - viewBinding.switchInvert.isEnabled = !isLoading - viewBinding.switchGrayscale.isEnabled = !isLoading - viewBinding.buttonDone.isEnabled = !isLoading - } - - private class PercentLabelFormatter(resources: Resources) : LabelFormatter { - - private val pattern = resources.getString(R.string.percent_string_pattern) - - override fun getFormattedValue(value: Float): String { - val percent = ((value + 1f) * 100).format(0) - 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/ColorFilterConfigBackPressedDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt deleted file mode 100644 index 7a7a5480a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.colorfilter - -import android.content.DialogInterface -import androidx.activity.OnBackPressedCallback -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.call - -class ColorFilterConfigBackPressedDispatcher( - private val activity: ColorFilterConfigActivity, - private val viewModel: ColorFilterConfigViewModel, -) : OnBackPressedCallback(true), DialogInterface.OnClickListener { - - override fun handleOnBackPressed() { - if (viewModel.isChanged) { - showConfirmation() - } else { - viewModel.onDismiss.call(Unit) - } - } - - override fun onClick(dialog: DialogInterface, which: Int) { - when (which) { - DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit) - DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss() - DialogInterface.BUTTON_POSITIVE -> activity.showSaveConfirmation() - } - } - - private fun showConfirmation() { - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.color_correction) - .setMessage(R.string.text_unsaved_changes_prompt) - .setNegativeButton(R.string.discard, this) - .setNeutralButton(android.R.string.cancel, this) - .setPositiveButton(R.string.save, this) - .show() - } -} 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 deleted file mode 100644 index 0d38b4738..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.colorfilter - -import androidx.lifecycle.SavedStateHandle -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.model.parcelable.ParcelableMangaPage -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.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 -class ColorFilterConfigViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val settings: AppSettings, - private val mangaDataRepository: MangaDataRepository, -) : BaseViewModel() { - - private val manga = savedStateHandle.require(EXTRA_MANGA).manga - - private var initialColorFilter: ReaderColorFilter? = null - val colorFilter = MutableStateFlow(null) - val onDismiss = MutableEventFlow() - val preview = savedStateHandle.require(ColorFilterConfigActivity.EXTRA_PAGES).page - - val isChanged: Boolean - get() = colorFilter.value != initialColorFilter - - val is32BitColorsEnabled: Boolean - get() = settings.is32BitColorsEnabled - - init { - launchLoadingJob { - initialColorFilter = mangaDataRepository.getColorFilter(manga.id) ?: settings.readerColorFilter - colorFilter.value = initialColorFilter - } - } - - fun setBrightness(brightness: Float) { - updateColorFilter { it.copy(brightness = brightness) } - } - - fun setContrast(contrast: Float) { - updateColorFilter { it.copy(contrast = contrast) } - } - - fun setInversion(invert: Boolean) { - updateColorFilter { it.copy(isInverted = invert) } - } - - fun setGrayscale(grayscale: Boolean) { - updateColorFilter { it.copy(isGrayscale = grayscale) } - } - - fun reset() { - colorFilter.value = null - } - - fun save() { - launchLoadingJob(Dispatchers.Default) { - mangaDataRepository.saveColorFilter(manga, colorFilter.value) - onDismiss.call(Unit) - } - } - - fun saveGlobally() { - launchLoadingJob(Dispatchers.Default) { - settings.readerColorFilter = colorFilter.value - if (mangaDataRepository.getColorFilter(manga.id) != null) { - mangaDataRepository.saveColorFilter(manga, colorFilter.value) - } - onDismiss.call(Unit) - } - } - - private inline fun updateColorFilter(block: (ReaderColorFilter) -> ReaderColorFilter) { - colorFilter.value = block( - colorFilter.value ?: ReaderColorFilter.EMPTY, - ).takeUnless { it.isEmpty } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt deleted file mode 100644 index 28bf4f38c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/DoubleViewTarget.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.colorfilter - -import android.graphics.drawable.Drawable -import android.widget.ImageView -import coil.target.ImageViewTarget - -class DoubleViewTarget( - primaryView: ImageView, - private val secondaryView: ImageView, -) : ImageViewTarget(primaryView) { - - override var drawable: Drawable? - get() = super.drawable - set(value) { - super.drawable = value - secondaryView.setImageDrawable(value?.constantState?.newDrawable()) - } -} 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 deleted file mode 100644 index bf26e61e5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ /dev/null @@ -1,215 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.config - -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import androidx.activity.result.ActivityResultCallback -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 -import com.google.android.material.slider.Slider -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ReaderMode -import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ScreenOrientationHelper -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.ui.PageSaveContract -import org.koitharu.kotatsu.reader.ui.ReaderViewModel -import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity -import org.koitharu.kotatsu.settings.SettingsActivity -import javax.inject.Inject - -@AndroidEntryPoint -class ReaderConfigSheet : - BaseAdaptiveSheet(), - ActivityResultCallback, - View.OnClickListener, - MaterialButtonToggleGroup.OnButtonCheckedListener, - Slider.OnChangeListener, - CompoundButton.OnCheckedChangeListener { - - private val viewModel by activityViewModels() - private val savePageRequest = registerForActivityResult(PageSaveContract(), this) - - @Inject - lateinit var orientationHelper: ScreenOrientationHelper - - private lateinit var mode: ReaderMode - - @Inject - lateinit var settings: AppSettings - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - mode = arguments?.getInt(ARG_MODE) - ?.let { ReaderMode.valueOf(it) } - ?: ReaderMode.STANDARD - } - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ): SheetReaderConfigBinding { - return SheetReaderConfigBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated( - binding: SheetReaderConfigBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - observeScreenOrientation() - binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD - binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED - binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON - - binding.checkableGroup.addOnButtonCheckedListener(this) - binding.buttonSavePage.setOnClickListener(this) - binding.buttonScreenRotate.setOnClickListener(this) - binding.buttonSettings.setOnClickListener(this) - binding.buttonColorFilter.setOnClickListener(this) - binding.sliderTimer.addOnChangeListener(this) - binding.switchScrollTimer.setOnCheckedChangeListener(this) - - settings.observeAsStateFlow( - scope = lifecycleScope + Dispatchers.Default, - key = AppSettings.KEY_READER_AUTOSCROLL_SPEED, - valueProducer = { readerAutoscrollSpeed }, - ).observe(viewLifecycleOwner) { - binding.sliderTimer.value = it.coerceIn( - binding.sliderTimer.valueFrom, - binding.sliderTimer.valueTo, - ) - } - findCallback()?.run { - binding.switchScrollTimer.isChecked = isAutoScrollEnabled - } - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_settings -> { - startActivity(SettingsActivity.newReaderSettingsIntent(v.context)) - dismissAllowingStateLoss() - } - - R.id.button_save_page -> { - val page = viewModel.getCurrentPage() ?: return - viewModel.saveCurrentPage(page, savePageRequest) - } - - R.id.button_screen_rotate -> { - orientationHelper.isLandscape = !orientationHelper.isLandscape - } - - R.id.button_color_filter -> { - val page = viewModel.getCurrentPage() ?: return - val manga = viewModel.manga?.toManga() ?: return - startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) - } - } - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - when (buttonView.id) { - R.id.switch_scroll_timer -> { - findCallback()?.isAutoScrollEnabled = isChecked - requireViewBinding().layoutTimer.isVisible = isChecked - requireViewBinding().sliderTimer.isVisible = isChecked - } - - R.id.switch_screen_lock_rotation -> { - orientationHelper.isLocked = isChecked - } - } - } - - override fun onButtonChecked( - group: MaterialButtonToggleGroup?, - checkedId: Int, - isChecked: Boolean, - ) { - if (!isChecked) { - return - } - val newMode = when (checkedId) { - R.id.button_standard -> ReaderMode.STANDARD - R.id.button_webtoon -> ReaderMode.WEBTOON - R.id.button_reversed -> ReaderMode.REVERSED - else -> return - } - if (newMode == mode) { - return - } - findCallback()?.onReaderModeChanged(newMode) ?: return - mode = newMode - } - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - if (fromUser) { - settings.readerAutoscrollSpeed = value - } - (viewBinding ?: return).labelTimerValue.text = getString(R.string.speed_value, value * 10f) - } - - override fun onActivityResult(result: Uri?) { - viewModel.onActivityResult(result) - dismissAllowingStateLoss() - } - - private fun observeScreenOrientation() { - orientationHelper.observeAutoOrientation() - .onEach { - with(requireViewBinding()) { - buttonScreenRotate.isGone = it - switchScreenLockRotation.isVisible = it - updateOrientationLockSwitch() - } - }.launchIn(viewLifecycleScope) - } - - private fun updateOrientationLockSwitch() { - val switch = viewBinding?.switchScreenLockRotation ?: return - switch.setOnCheckedChangeListener(null) - switch.isChecked = orientationHelper.isLocked - switch.setOnCheckedChangeListener(this) - } - - private fun findCallback(): Callback? { - return (parentFragment as? Callback) ?: (activity as? Callback) - } - - interface Callback { - - var isAutoScrollEnabled: Boolean - - fun onReaderModeChanged(mode: ReaderMode) - } - - 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 deleted file mode 100644 index 60708cfcc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ /dev/null @@ -1,123 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.config - -import android.content.SharedPreferences -import android.graphics.Bitmap -import android.view.View -import androidx.annotation.CheckResult -import androidx.lifecycle.MediatorLiveData -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder -import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder -import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.StateFlow -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.util.ext.isLowRamDevice -import org.koitharu.kotatsu.reader.domain.ReaderColorFilter - -class ReaderSettings( - private val parentScope: CoroutineScope, - private val settings: AppSettings, - private val colorFilterFlow: StateFlow, -) : MediatorLiveData() { - - private val internalObserver = InternalObserver() - private var collectJob: Job? = null - - val zoomMode: ZoomMode - get() = settings.zoomMode - - val colorFilter: ReaderColorFilter? - get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter - - val isReaderOptimizationEnabled: Boolean - get() = settings.isReaderOptimizationEnabled - - val bitmapConfig: Bitmap.Config - get() = if (settings.is32BitColorsEnabled) { - Bitmap.Config.ARGB_8888 - } else { - Bitmap.Config.RGB_565 - } - - val isPagesNumbersEnabled: Boolean - get() = settings.isPagesNumbersEnabled - - fun applyBackground(view: View) { - val bg = settings.readerBackground - view.background = bg.resolve(view.context) - } - - @CheckResult - fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean { - val config = bitmapConfig - return if (ssiv.regionDecoderFactory.bitmapConfig != config) { - ssiv.regionDecoderFactory = if (ssiv.context.isLowRamDevice()) { - SkiaImageRegionDecoder.Factory(config) - } else { - SkiaPooledImageRegionDecoder.Factory(config) - } - ssiv.bitmapDecoderFactory = SkiaImageDecoder.Factory(config) - true - } else { - false - } - } - - override fun onInactive() { - super.onInactive() - settings.unsubscribe(internalObserver) - collectJob?.cancel() - collectJob = null - } - - override fun onActive() { - super.onActive() - settings.subscribe(internalObserver) - collectJob?.cancel() - collectJob = parentScope.launch { - colorFilterFlow.collect(internalObserver) - } - } - - override fun getValue() = this - - private fun notifyChanged() { - value = value - } - - private inner class InternalObserver : - FlowCollector, - SharedPreferences.OnSharedPreferenceChangeListener { - - private val settingsKeys = setOf( - AppSettings.KEY_ZOOM_MODE, - AppSettings.KEY_PAGES_NUMBERS, - AppSettings.KEY_READER_BACKGROUND, - AppSettings.KEY_32BIT_COLOR, - AppSettings.KEY_READER_OPTIMIZE, - AppSettings.KEY_CF_CONTRAST, - AppSettings.KEY_CF_BRIGHTNESS, - AppSettings.KEY_CF_INVERTED, - AppSettings.KEY_CF_GRAYSCALE, - ) - - override suspend fun emit(value: ReaderColorFilter?) { - withContext(Dispatchers.Main.immediate) { - notifyChanged() - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key in settingsKeys) { - notifyChanged() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt deleted file mode 100644 index 2c4827207..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager - -import android.content.Context -import androidx.annotation.CallSuper -import androidx.lifecycle.LifecycleOwner -import androidx.viewbinding.ViewBinding -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder -import org.koitharu.kotatsu.core.util.ext.isLowRamDevice -import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings -import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State - -abstract class BasePageHolder( - protected val binding: B, - loader: PageLoader, - protected val settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, - lifecycleOwner: LifecycleOwner, -) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback { - - @Suppress("LeakingThis") - protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver) - protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) - - val context: Context - get() = itemView.context - - var boundData: ReaderPage? = null - private set - - override fun onConfigChanged() { - settings.applyBackground(itemView) - } - - fun requireData(): ReaderPage { - return checkNotNull(boundData) { "Calling requireData() before bind()" } - } - - fun bind(data: ReaderPage) { - boundData = data - onBind(data) - } - - protected abstract fun onBind(data: ReaderPage) - - override fun onResume() { - super.onResume() - if (delegate.state == State.ERROR && !delegate.isLoading()) { - boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) } - } - } - - @CallSuper - open fun onAttachedToWindow() { - delegate.onAttachedToWindow() - } - - @CallSuper - open fun onDetachedFromWindow() { - delegate.onDetachedFromWindow() - } - - @CallSuper - open fun onRecycled() { - delegate.onRecycle() - } - - protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) { - downsampling = when { - isForeground || !settings.isReaderOptimizationEnabled -> 1 - context.isLowRamDevice() -> 8 - else -> 4 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt deleted file mode 100644 index 3d6ea7fdc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager - -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.fragment.app.activityViewModels -import androidx.viewbinding.ViewBinding -import org.koitharu.kotatsu.core.prefs.ReaderAnimation -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.widgets.ZoomControl -import org.koitharu.kotatsu.core.util.ext.getParcelableCompat -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.ReaderViewModel - -private const val KEY_STATE = "state" - -abstract class BaseReaderFragment : BaseFragment(), ZoomControl.ZoomControlListener { - - protected val viewModel by activityViewModels() - private var stateToSave: ReaderState? = null - - protected var readerAdapter: BaseReaderAdapter<*>? = null - private set - - override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - var restoredState = savedInstanceState?.getParcelableCompat(KEY_STATE) - readerAdapter = onCreateAdapter() - - viewModel.content.observe(viewLifecycleOwner) { - onPagesChanged(it.pages, restoredState ?: it.state) - restoredState = null - } - } - - override fun onPause() { - super.onPause() - viewModel.saveCurrentState(getCurrentState()) - } - - override fun onDestroyView() { - stateToSave = getCurrentState() - readerAdapter = null - super.onDestroyView() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - getCurrentState()?.let { - stateToSave = it - } - outState.putParcelable(KEY_STATE, stateToSave) - } - - protected fun requireAdapter() = checkNotNull(readerAdapter) { - "Adapter was not created or already destroyed" - } - - protected fun isAnimationEnabled(): Boolean { - return context?.isAnimationsEnabled == true && viewModel.pageAnimation.value != ReaderAnimation.NONE - } - - override fun onWindowInsetsChanged(insets: Insets) = Unit - - abstract fun switchPageBy(delta: Int) - - abstract fun switchPageTo(position: Int, smooth: Boolean) - - open fun scrollBy(delta: Int, smooth: Boolean): Boolean = false - - abstract fun getCurrentState(): ReaderState? - - protected abstract fun onCreateAdapter(): BaseReaderAdapter<*> - - protected abstract suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) -} 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 deleted file mode 100644 index 0b58a3c31..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ /dev/null @@ -1,209 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager - -import android.net.Uri -import androidx.lifecycle.Observer -import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.toFileOrNull -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings -import java.io.IOException - -class PageHolderDelegate( - private val loader: PageLoader, - private val readerSettings: ReaderSettings, - private val callback: Callback, - private val networkState: NetworkState, - private val exceptionResolver: ExceptionResolver, -) : DefaultOnImageEventListener, Observer { - - private val scope = loader.loaderScope + Dispatchers.Main.immediate - var state = State.EMPTY - private set - private var job: Job? = null - private var uri: Uri? = null - private var error: Throwable? = null - - init { - scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init - callback.onConfigChanged() - } - } - - fun isLoading() = job?.isActive == true - - fun onBind(page: MangaPage) { - val prevJob = job - job = scope.launch { - prevJob?.cancelAndJoin() - doLoad(page, force = false) - } - } - - fun retry(page: MangaPage, isFromUser: Boolean) { - val prevJob = job - job = scope.launch { - prevJob?.cancelAndJoin() - val e = error - if (e != null && ExceptionResolver.canResolve(e)) { - if (!isFromUser) { - return@launch - } - exceptionResolver.resolve(e) - } - doLoad(page, force = true) - } - } - - fun showErrorDetails(url: String?) { - val e = error ?: return - exceptionResolver.showDetails(e, url) - } - - fun onAttachedToWindow() { - readerSettings.observeForever(this) - } - - fun onDetachedFromWindow() { - readerSettings.removeObserver(this) - } - - fun onRecycle() { - state = State.EMPTY - uri = null - error = null - job?.cancel() - } - - fun reload() { - if (state == State.SHOWN) { - uri?.let { - callback.onImageReady(it) - } - } - } - - override fun onReady() { - state = State.SHOWING - error = null - callback.onImageShowing(readerSettings) - } - - override fun onImageLoaded() { - state = State.SHOWN - error = null - callback.onImageShown() - } - - override fun onImageLoadError(e: Throwable) { - e.printStackTraceDebug() - val uri = this.uri - error = e - if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) { - tryConvert(uri, e) - } else { - state = State.ERROR - callback.onError(e) - } - } - - override fun onChanged(value: ReaderSettings) { - if (state == State.SHOWN) { - callback.onImageShowing(readerSettings) - } - callback.onConfigChanged() - } - - private fun tryConvert(uri: Uri, e: Exception) { - val prevJob = job - job = scope.launch { - prevJob?.join() - state = State.CONVERTING - try { - val newUri = loader.convertBimap(uri) - state = State.CONVERTED - callback.onImageReady(newUri) - } catch (ce: CancellationException) { - throw ce - } catch (e2: Throwable) { - e.addSuppressed(e2) - state = State.ERROR - callback.onError(e) - } - } - } - - private suspend fun doLoad(data: MangaPage, force: Boolean) { - state = State.LOADING - error = null - callback.onLoadingStarted() - yield() - try { - val task = withContext(Dispatchers.Default) { - loader.loadPageAsync(data, force) - } - uri = coroutineScope { - val progressObserver = observeProgress(this, task.progressAsFlow()) - val file = task.await() - progressObserver.cancelAndJoin() - file - } - state = State.LOADED - callback.onImageReady(checkNotNull(uri)) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - state = State.ERROR - error = e - callback.onError(e) - if (e is IOException && !networkState.value) { - networkState.awaitForConnection() - retry(data, isFromUser = false) - } - } - } - - private fun observeProgress(scope: CoroutineScope, progress: Flow) = progress - .debounce(250) - .onEach { callback.onProgressChanged((100 * it).toInt()) } - .launchIn(scope) - - enum class State { - EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR - } - - interface Callback { - - fun onLoadingStarted() - - fun onError(e: Throwable) - - fun onImageReady(uri: Uri) - - fun onImageShowing(settings: ReaderSettings) - - fun onImageShown() - - fun onProgressChanged(progress: Int) - - fun onConfigChanged() - } -} 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 deleted file mode 100644 index 650c4439a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager - -import android.content.Context -import org.koitharu.kotatsu.R - -data class ReaderUiState( - val mangaName: String?, - val branch: String?, - val chapterName: String?, - val chapterNumber: Int, - val chaptersTotal: Int, - val currentPage: Int, - val totalPages: Int, - val percent: Float, - private val isSliderEnabled: Boolean, -) { - - fun isSliderAvailable(): Boolean { - return isSliderEnabled && totalPages > 1 && currentPage < totalPages - } - - fun resolveTitle(context: Context): String? = when { - mangaName == null -> null - branch == null -> mangaName - else -> context.getString(R.string.manga_branch_title_template, mangaName, branch) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt deleted file mode 100644 index e9ccb68ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedReaderFragment.kt +++ /dev/null @@ -1,181 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.reversed - -import android.os.Build -import android.os.Bundle -import android.view.InputDevice -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import androidx.core.view.children -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.prefs.ReaderAnimation -import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher -import org.koitharu.kotatsu.core.util.ext.doOnPageChanged -import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.recyclerView -import org.koitharu.kotatsu.core.util.ext.resetTransformations -import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer -import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder -import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier -import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment -import javax.inject.Inject -import kotlin.math.absoluteValue -import kotlin.math.sign - -@AndroidEntryPoint -class ReversedReaderFragment : BaseReaderFragment(), - View.OnGenericMotionListener { - - @Inject - lateinit var networkState: NetworkState - - @Inject - lateinit var pageLoader: PageLoader - - private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentReaderStandardBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.pager) { - adapter = readerAdapter - offscreenPageLimit = 2 - doOnPageChanged(::notifyPageChanged) - setOnGenericMotionListener(this@ReversedReaderFragment) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - recyclerView?.defaultFocusHighlightEnabled = false - } - PagerEventSupplier(this).attach() - pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also { - registerOnPageChangeCallback(it) - } - } - - viewModel.pageAnimation.observe(viewLifecycleOwner) { - val transformer = when (it) { - ReaderAnimation.NONE -> NoAnimPageTransformer() - ReaderAnimation.DEFAULT -> null - ReaderAnimation.ADVANCED -> ReversedPageAnimTransformer() - } - binding.pager.setPageTransformer(transformer) - if (transformer == null) { - binding.pager.recyclerView?.children?.forEach { v -> - v.resetTransformations() - } - } - } - } - - override fun onDestroyView() { - pagerLifecycleDispatcher = null - requireViewBinding().pager.adapter = null - super.onDestroyView() - } - - override fun onGenericMotion(v: View?, event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - if (event.actionMasked == MotionEvent.ACTION_SCROLL) { - val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) - val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 - if (!withCtrl) { - switchPageBy(-axisValue.sign.toInt()) - return true - } - } - } - return false - } - - override fun onCreateAdapter() = ReversedPagesAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) - - override fun onZoomIn() { - (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn() - } - - override fun onZoomOut() { - (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut() - } - - - override fun switchPageBy(delta: Int) { - with(requireViewBinding().pager) { - setCurrentItem(currentItem - delta, isAnimationEnabled()) - } - } - - override fun switchPageTo(position: Int, smooth: Boolean) { - with(requireViewBinding().pager) { - setCurrentItem( - reversed(position), - smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT, - ) - } - } - - override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { - val reversedPages = pages.asReversed() - val items = launch { - requireAdapter().setItems(reversedPages) - yield() - pagerLifecycleDispatcher?.invalidate() - } - if (pendingState != null) { - val position = reversedPages.indexOfLast { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - items.join() - if (position != -1) { - requireViewBinding().pager.setCurrentItem(position, false) - notifyPageChanged(position) - } else { - Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) - .show() - } - } else { - items.join() - } - } - - override fun getCurrentState(): ReaderState? = viewBinding?.run { - val adapter = pager.adapter as? BaseReaderAdapter<*> - val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null - ReaderState( - chapterId = page.chapterId, - page = page.index, - scroll = 0, - ) - } - - private fun notifyPageChanged(page: Int) { - viewModel.onCurrentPageChanged(reversed(page)) - } - - private fun reversed(position: Int): Int { - return ((readerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/NoAnimPageTransformer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/NoAnimPageTransformer.kt deleted file mode 100644 index 2ce4317a1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/NoAnimPageTransformer.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.standard - -import android.view.View -import androidx.viewpager2.widget.ViewPager2 - -class NoAnimPageTransformer : ViewPager2.PageTransformer { - - override fun transformPage(page: View, position: Float) { - page.translationX = when { - position in -0.5f..0.5f -> -position * page.width.toFloat() - position > 0 -> page.width.toFloat() - else -> -page.width.toFloat() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt deleted file mode 100644 index e70d7b950..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.standard - -import android.annotation.SuppressLint -import android.graphics.PointF -import android.net.Uri -import android.view.View -import android.view.animation.DecelerateInterpolator -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import com.davemorrissey.labs.subscaleview.ImageSource -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.model.ZoomMode -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.ui.widgets.ZoomControl -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.ifZero -import org.koitharu.kotatsu.core.util.ext.isLowRamDevice -import org.koitharu.kotatsu.databinding.ItemPageBinding -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.config.ReaderSettings -import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage - -open class PageHolder( - owner: LifecycleOwner, - binding: ItemPageBinding, - loader: PageLoader, - settings: ReaderSettings, - networkState: NetworkState, - exceptionResolver: ExceptionResolver, -) : BasePageHolder(binding, loader, settings, networkState, exceptionResolver, owner), - View.OnClickListener, - ZoomControl.ZoomControlListener { - - init { - binding.ssiv.bindToLifecycle(owner) - binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice() - binding.ssiv.addOnImageEventListener(delegate) - @Suppress("LeakingThis") - bindingInfo.buttonRetry.setOnClickListener(this) - @Suppress("LeakingThis") - bindingInfo.buttonErrorDetails.setOnClickListener(this) - binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled - } - - override fun onResume() { - super.onResume() - binding.ssiv.applyDownsampling(isForeground = true) - } - - override fun onPause() { - super.onPause() - binding.ssiv.applyDownsampling(isForeground = false) - } - - override fun onConfigChanged() { - super.onConfigChanged() - if (settings.applyBitmapConfig(binding.ssiv)) { - delegate.reload() - } - binding.ssiv.applyDownsampling(isResumed()) - } - - @SuppressLint("SetTextI18n") - override fun onBind(data: ReaderPage) { - delegate.onBind(data.toMangaPage()) - binding.textViewNumber.text = (data.index + 1).toString() - } - - override fun onRecycled() { - super.onRecycled() - binding.ssiv.recycle() - } - - override fun onLoadingStarted() { - bindingInfo.layoutError.isVisible = false - bindingInfo.progressBar.show() - binding.ssiv.recycle() - } - - override fun onProgressChanged(progress: Int) { - if (progress in 0..100) { - bindingInfo.progressBar.isIndeterminate = false - bindingInfo.progressBar.setProgressCompat(progress, true) - } else { - bindingInfo.progressBar.isIndeterminate = true - } - } - - override fun onImageReady(uri: Uri) { - binding.ssiv.setImage(ImageSource.Uri(uri)) - } - - override fun onImageShowing(settings: ReaderSettings) { - binding.ssiv.maxScale = 2f * maxOf( - binding.ssiv.width / binding.ssiv.sWidth.toFloat(), - binding.ssiv.height / binding.ssiv.sHeight.toFloat(), - ) - binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() - when (settings.zoomMode) { - ZoomMode.FIT_CENTER -> { - binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE - binding.ssiv.resetScaleAndCenter() - } - - ZoomMode.FIT_HEIGHT -> { - binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM - binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() - binding.ssiv.setScaleAndCenter( - binding.ssiv.minScale, - PointF(0f, binding.ssiv.sHeight / 2f), - ) - } - - ZoomMode.FIT_WIDTH -> { - binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM - binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() - binding.ssiv.setScaleAndCenter( - binding.ssiv.minScale, - PointF(binding.ssiv.sWidth / 2f, 0f), - ) - } - - ZoomMode.KEEP_START -> { - binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE - binding.ssiv.setScaleAndCenter( - binding.ssiv.maxScale, - PointF(0f, 0f), - ) - } - } - } - - override fun onImageShown() { - bindingInfo.progressBar.hide() - } - - final override fun onClick(v: View) { - when (v.id) { - R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true) - R.id.button_error_details -> delegate.showErrorDetails(boundData?.url) - } - } - - override fun onError(e: Throwable) { - bindingInfo.textViewError.text = e.getDisplayMessage(context.resources) - bindingInfo.buttonRetry.setText( - ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again }, - ) - bindingInfo.layoutError.isVisible = true - bindingInfo.progressBar.hide() - } - - override fun onZoomIn() { - scaleBy(1.2f) - } - - override fun onZoomOut() { - scaleBy(0.8f) - } - - private fun scaleBy(factor: Float) { - val ssiv = binding.ssiv - val center = ssiv.getCenter() ?: return - val newScale = ssiv.scale * factor - ssiv.animateScaleAndCenter(newScale, center)?.apply { - withDuration(ssiv.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()) - withInterpolator(DecelerateInterpolator()) - start() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerEventSupplier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerEventSupplier.kt deleted file mode 100644 index 9e1cb30bf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerEventSupplier.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.standard - -import android.view.KeyEvent -import android.view.View -import android.view.ViewGroup -import androidx.core.view.children -import androidx.viewpager2.widget.ViewPager2 -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import org.koitharu.kotatsu.core.util.ext.recyclerView - -class PagerEventSupplier(private val pager: ViewPager2) : View.OnKeyListener { - - fun attach() { - pager.recyclerView?.setOnKeyListener(this) - } - - override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean { - val rootView = pager.recyclerView?.findViewHolderForAdapterPosition(pager.currentItem)?.itemView as? ViewGroup - ?: return false - return rootView.children.firstNotNullOfOrNull { x -> - x as? SubsamplingScaleImageView - }?.dispatchKeyEvent(event) == true - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt deleted file mode 100644 index 70a2cbc28..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ /dev/null @@ -1,180 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.standard - -import android.os.Build -import android.os.Bundle -import android.view.InputDevice -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import androidx.core.view.children -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.prefs.ReaderAnimation -import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher -import org.koitharu.kotatsu.core.util.ext.doOnPageChanged -import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.recyclerView -import org.koitharu.kotatsu.core.util.ext.resetTransformations -import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import javax.inject.Inject -import kotlin.math.absoluteValue -import kotlin.math.sign - -@AndroidEntryPoint -class PagerReaderFragment : BaseReaderFragment(), - View.OnGenericMotionListener { - - @Inject - lateinit var networkState: NetworkState - - @Inject - lateinit var pageLoader: PageLoader - - private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentReaderStandardBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated( - binding: FragmentReaderStandardBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.pager) { - adapter = readerAdapter - offscreenPageLimit = 2 - doOnPageChanged(::notifyPageChanged) - setOnGenericMotionListener(this@PagerReaderFragment) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - recyclerView?.defaultFocusHighlightEnabled = false - } - PagerEventSupplier(this).attach() - pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also { - registerOnPageChangeCallback(it) - } - } - - viewModel.pageAnimation.observe(viewLifecycleOwner) { - val transformer = when (it) { - ReaderAnimation.NONE -> NoAnimPageTransformer() - ReaderAnimation.DEFAULT -> null - ReaderAnimation.ADVANCED -> PageAnimTransformer() - } - binding.pager.setPageTransformer(transformer) - if (transformer == null) { - binding.pager.recyclerView?.children?.forEach { view -> - view.resetTransformations() - } - } - } - } - - override fun onDestroyView() { - pagerLifecycleDispatcher = null - requireViewBinding().pager.adapter = null - super.onDestroyView() - } - - override fun onZoomIn() { - (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn() - } - - override fun onZoomOut() { - (viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut() - } - - override fun onGenericMotion(v: View?, event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - if (event.actionMasked == MotionEvent.ACTION_SCROLL) { - val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) - val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 - if (!withCtrl) { - switchPageBy(-axisValue.sign.toInt()) - return true - } - } - } - return false - } - - override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = - coroutineScope { - val items = launch { - requireAdapter().setItems(pages) - yield() - pagerLifecycleDispatcher?.invalidate() - } - if (pendingState != null) { - val position = pages.indexOfFirst { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - items.join() - if (position != -1) { - requireViewBinding().pager.setCurrentItem(position, false) - notifyPageChanged(position) - } else { - Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) - .show() - } - } else { - items.join() - } - } - - override fun onCreateAdapter() = PagesAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) - - override fun switchPageBy(delta: Int) { - with(requireViewBinding().pager) { - setCurrentItem(currentItem + delta, isAnimationEnabled()) - } - } - - override fun switchPageTo(position: Int, smooth: Boolean) { - with(requireViewBinding().pager) { - setCurrentItem( - position, - smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT, - ) - } - } - - override fun getCurrentState(): ReaderState? = viewBinding?.run { - val adapter = pager.adapter as? BaseReaderAdapter<*> - val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null - ReaderState( - chapterId = page.chapterId, - page = page.index, - scroll = 0, - ) - } - - private fun notifyPageChanged(page: Int) { - viewModel.onCurrentPageChanged(page) - } - - companion object { - - const val SMOOTH_SCROLL_LIMIT = 3 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt deleted file mode 100644 index f3c1d2f89..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.webtoon - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.view.animation.DecelerateInterpolator -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.yield -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.os.NetworkState -import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher -import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding -import org.koitharu.kotatsu.reader.domain.PageLoader -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter -import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import javax.inject.Inject - -@AndroidEntryPoint -class WebtoonReaderFragment : BaseReaderFragment() { - - @Inject - lateinit var networkState: NetworkState - - @Inject - lateinit var pageLoader: PageLoader - - private val scrollInterpolator = DecelerateInterpolator() - - private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentReaderWebtoonBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - with(binding.recyclerView) { - setHasFixedSize(true) - adapter = readerAdapter - addOnPageScrollListener(PageScrollListener()) - recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also { - addOnScrollListener(it) - } - } - viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) { - binding.frame.isZoomEnable = it - } - } - - override fun onDestroyView() { - recyclerLifecycleDispatcher = null - requireViewBinding().recyclerView.adapter = null - super.onDestroyView() - } - - override fun onCreateAdapter() = WebtoonAdapter( - lifecycleOwner = viewLifecycleOwner, - loader = pageLoader, - settings = viewModel.readerSettings, - networkState = networkState, - exceptionResolver = exceptionResolver, - ) - - override suspend fun onPagesChanged(pages: List, pendingState: ReaderState?) = coroutineScope { - val setItems = launch { - requireAdapter().setItems(pages) - yield() - viewBinding?.recyclerView?.let { rv -> - recyclerLifecycleDispatcher?.invalidate(rv) - } - } - if (pendingState != null) { - val position = pages.indexOfFirst { - it.chapterId == pendingState.chapterId && it.index == pendingState.page - } - setItems.join() - if (position != -1) { - with(requireViewBinding().recyclerView) { - firstVisibleItemPosition = position - post { - (findViewHolderForAdapterPosition(position) as? WebtoonHolder) - ?.restoreScroll(pendingState.scroll) - } - } - notifyPageChanged(position) - } else { - Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT) - .show() - } - } else { - setItems.join() - } - } - - override fun getCurrentState(): ReaderState? = viewBinding?.run { - val currentItem = recyclerView.findCenterViewPosition() - val adapter = recyclerView.adapter as? BaseReaderAdapter<*> - val page = adapter?.getItemOrNull(currentItem) ?: return@run null - ReaderState( - chapterId = page.chapterId, - page = page.index, - scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder) - ?.getScrollY() ?: 0, - ) - } - - override fun onZoomIn() { - viewBinding?.frame?.onZoomIn() - } - - override fun onZoomOut() { - viewBinding?.frame?.onZoomOut() - } - - private fun notifyPageChanged(page: Int) { - viewModel.onCurrentPageChanged(page) - } - - override fun switchPageBy(delta: Int) { - with(requireViewBinding().recyclerView) { - if (isAnimationEnabled()) { - smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator) - } else { - nestedScrollBy(0, (height * 0.9).toInt() * delta) - } - } - } - - override fun switchPageTo(position: Int, smooth: Boolean) { - requireViewBinding().recyclerView.firstVisibleItemPosition = position - } - - override fun scrollBy(delta: Int, smooth: Boolean): Boolean { - if (smooth && isAnimationEnabled()) { - requireViewBinding().recyclerView.smoothScrollBy(0, delta, scrollInterpolator) - } else { - requireViewBinding().recyclerView.nestedScrollBy(0, delta) - } - return true - } - - private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() { - - override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) { - super.onPageChanged(recyclerView, index) - notifyPageChanged(index) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonScalingFrame.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonScalingFrame.kt deleted file mode 100644 index c57858ee7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonScalingFrame.kt +++ /dev/null @@ -1,315 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.pager.webtoon - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Matrix -import android.graphics.Rect -import android.graphics.RectF -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.InputDevice -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.ScaleGestureDetector -import android.view.ViewConfiguration -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.DecelerateInterpolator -import android.widget.FrameLayout -import android.widget.OverScroller -import androidx.core.view.GestureDetectorCompat -import androidx.core.view.ViewConfigurationCompat -import org.koitharu.kotatsu.core.ui.widgets.ZoomControl -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration - -private const val MAX_SCALE = 2.5f -private const val MIN_SCALE = 0.5f - -class WebtoonScalingFrame @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyles: Int = 0, -) : FrameLayout(context, attrs, defStyles), - ScaleGestureDetector.OnScaleGestureListener, - ZoomControl.ZoomControlListener { - - private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) as WebtoonRecyclerView } - - private val scaleDetector = ScaleGestureDetector(context, this) - private val gestureDetector = GestureDetectorCompat(context, GestureListener()) - private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator()) - private val transformMatrix = Matrix() - private val matrixValues = FloatArray(9) - private val scale - get() = matrixValues[Matrix.MSCALE_X] - private val transX - get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X] - private val transY - get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y] - private var halfWidth = 0f - private var halfHeight = 0f - private val translateBounds = RectF() - private val targetHitRect = Rect() - private var animator: ValueAnimator? = null - - var isZoomEnable = false - set(value) { - field = value - if (scale != 1f) { - scaleChild(1f, halfWidth, halfHeight) - } - } - - init { - syncMatrixValues() - } - - override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - if (!isZoomEnable || ev == null) { - return super.dispatchTouchEvent(ev) - } - - if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) { - overScroller.forceFinished(true) - } - - gestureDetector.onTouchEvent(ev) - scaleDetector.onTouchEvent(ev) - - // Offset event to inside the child view - if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) { - ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f) - } - - // Send action cancel to avoid recycler jump when scale end - if (scaleDetector.isInProgress) { - ev.action = MotionEvent.ACTION_CANCEL - } - return super.dispatchTouchEvent(ev) - } - - override fun onGenericMotionEvent(event: MotionEvent): Boolean { - if (isZoomEnable && event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - if (event.actionMasked == MotionEvent.ACTION_SCROLL) { - val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0 - if (withCtrl) { - val axisValue = - event.getAxisValue(MotionEvent.AXIS_VSCROLL) * ViewConfigurationCompat.getScaledVerticalScrollFactor( - ViewConfiguration.get(context), context, - ) - val newScale = (scale + axisValue).coerceIn(MIN_SCALE, MAX_SCALE) - scaleChild(newScale, event.x, event.y) - return true - } - } - } - return super.onGenericMotionEvent(event) - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if (!isZoomEnable) { - return super.onKeyDown(keyCode, event) - } - return when (keyCode) { - KeyEvent.KEYCODE_ZOOM_IN, - KeyEvent.KEYCODE_NUMPAD_ADD, - KeyEvent.KEYCODE_PLUS -> { - onZoomIn() - true - } - - KeyEvent.KEYCODE_ZOOM_OUT, - KeyEvent.KEYCODE_NUMPAD_SUBTRACT, - KeyEvent.KEYCODE_MINUS -> { - onZoomOut() - true - } - - KeyEvent.KEYCODE_ESCAPE -> { - smoothScaleTo(1f) - true - } - - else -> super.onKeyDown(keyCode, event) - } - } - - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - return if (isZoomEnable) { - keyCode == KeyEvent.KEYCODE_NUMPAD_ADD - || keyCode == KeyEvent.KEYCODE_PLUS - || keyCode == KeyEvent.KEYCODE_NUMPAD_SUBTRACT - || keyCode == KeyEvent.KEYCODE_MINUS - || keyCode == KeyEvent.KEYCODE_ZOOM_IN - || keyCode == KeyEvent.KEYCODE_ZOOM_OUT - || keyCode == KeyEvent.KEYCODE_ESCAPE - || super.onKeyUp(keyCode, event) - } else { - super.onKeyUp(keyCode, event) - } - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - halfWidth = w / 2f - halfHeight = h / 2f - } - - override fun onZoomIn() { - smoothScaleTo(scale * 1.1f) - } - - override fun onZoomOut() { - smoothScaleTo(scale * 0.9f) - } - - private fun invalidateTarget() { - adjustBounds() - targetChild.run { - scaleX = scale - scaleY = scale - translationX = transX - translationY = transY - } - - val newHeight = if (scale < 1f) (height / scale).toInt() else height - if (newHeight != targetChild.height) { - targetChild.layoutParams.height = newHeight - targetChild.requestLayout() - targetChild.relayoutChildren() - } - - if (scale < 1) { - targetChild.getHitRect(targetHitRect) - } - } - - private fun syncMatrixValues() { - transformMatrix.getValues(matrixValues) - } - - private fun adjustBounds() { - syncMatrixValues() - val dx = when { - transX < translateBounds.left -> translateBounds.left - transX - transX > translateBounds.right -> translateBounds.right - transX - else -> 0f - } - - val dy = when { - transY < translateBounds.top -> translateBounds.top - transY - transY > translateBounds.bottom -> translateBounds.bottom - transY - else -> 0f - } - - transformMatrix.postTranslate(dx, dy) - syncMatrixValues() - } - - private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) { - val factor = newScale / scale - if (newScale > 1) { - translateBounds.set( - halfWidth * (1 - newScale), - halfHeight * (1 - newScale), - halfWidth * (newScale - 1), - halfHeight * (newScale - 1), - ) - } else { - translateBounds.set( - 0f, - halfHeight - halfHeight / newScale, - 0f, - halfHeight - halfHeight / newScale, - ) - } - transformMatrix.postScale(factor, factor, focusX, focusY) - invalidateTarget() - } - - - override fun onScale(detector: ScaleGestureDetector): Boolean { - val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) - scaleChild(newScale, detector.focusX, detector.focusY) - return true - } - - override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { - animator?.cancel() - animator = null - return true - } - - override fun onScaleEnd(p0: ScaleGestureDetector) = Unit - - private fun smoothScaleTo(target: Float) { - val newScale = target.coerceIn(MIN_SCALE, MAX_SCALE) - animator?.cancel() - animator = ValueAnimator.ofFloat(scale, newScale).apply { - setDuration(context.getAnimationDuration(android.R.integer.config_shortAnimTime)) - interpolator = DecelerateInterpolator() - addUpdateListener { scaleChild(it.animatedValue as Float, halfWidth, halfHeight) } - start() - } - } - - private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable { - - override fun onScroll( - e1: MotionEvent?, - e2: MotionEvent, - distanceX: Float, - distanceY: Float, - ): Boolean { - if (scale <= 1f) return false - transformMatrix.postTranslate(-distanceX, -distanceY) - invalidateTarget() - return true - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f - ValueAnimator.ofFloat(scale, newScale).run { - interpolator = AccelerateDecelerateInterpolator() - duration = 300 - addUpdateListener { - scaleChild(it.animatedValue as Float, e.x, e.y) - } - start() - } - return true - } - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent, - velocityX: Float, - velocityY: Float, - ): Boolean { - if (scale <= 1) return false - - overScroller.fling( - transX.toInt(), - transY.toInt(), - velocityX.toInt(), - velocityY.toInt(), - translateBounds.left.toInt(), - translateBounds.right.toInt(), - translateBounds.top.toInt(), - translateBounds.bottom.toInt(), - ) - postOnAnimation(this) - return true - } - - override fun run() { - if (overScroller.computeScrollOffset()) { - transformMatrix.postTranslate( - overScroller.currX - transX, - overScroller.currY - transY, - ) - invalidateTarget() - postOnAnimation(this) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt deleted file mode 100644 index 2861cd870..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/MangaPageFetcher.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails - -import android.content.Context -import androidx.core.net.toUri -import coil.ImageLoader -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.request.Options -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okhttp3.OkHttpClient -import okio.Path.Companion.toOkioPath -import okio.buffer -import okio.source -import org.koitharu.kotatsu.core.network.ImageProxyInterceptor -import org.koitharu.kotatsu.core.network.MangaHttpClient -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.data.isZipUri -import org.koitharu.kotatsu.local.data.util.withExtraCloseable -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.util.mimeType -import org.koitharu.kotatsu.reader.domain.PageLoader -import java.util.zip.ZipFile -import javax.inject.Inject - -class MangaPageFetcher( - private val context: Context, - private val okHttpClient: OkHttpClient, - private val pagesCache: PagesCache, - private val options: Options, - private val page: MangaPage, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val imageProxyInterceptor: ImageProxyInterceptor, -) : Fetcher { - - override suspend fun fetch(): FetchResult { - val repo = mangaRepositoryFactory.create(page.source) - val pageUrl = repo.getPageUrl(page) - pagesCache.get(pageUrl)?.let { file -> - return SourceResult( - source = ImageSource( - file = file.toOkioPath(), - metadata = MangaPageMetadata(page), - ), - mimeType = null, - dataSource = DataSource.DISK, - ) - } - return loadPage(pageUrl) - } - - private suspend fun loadPage(pageUrl: String): SourceResult { - val uri = pageUrl.toUri() - return if (uri.isZipUri()) { - runInterruptible(Dispatchers.IO) { - val zip = ZipFile(uri.schemeSpecificPart) - val entry = zip.getEntry(uri.fragment) - SourceResult( - source = ImageSource( - source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(), - context = context, - metadata = MangaPageMetadata(page), - ), - mimeType = null, - dataSource = DataSource.DISK, - ) - } - } else { - val request = PageLoader.createPageRequest(page, pageUrl) - imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response -> - check(response.isSuccessful) { - "Invalid response: ${response.code} ${response.message} at $pageUrl" - } - val body = checkNotNull(response.body) { - "Null response" - } - val mimeType = response.mimeType - val file = body.use { - pagesCache.put(pageUrl, it.source()) - } - SourceResult( - source = ImageSource( - file = file.toOkioPath(), - metadata = MangaPageMetadata(page), - ), - mimeType = mimeType, - dataSource = DataSource.NETWORK, - ) - } - } - } - - class Factory @Inject constructor( - @ApplicationContext private val context: Context, - @MangaHttpClient private val okHttpClient: OkHttpClient, - private val pagesCache: PagesCache, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val imageProxyInterceptor: ImageProxyInterceptor, - ) : Fetcher.Factory { - - override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher { - return MangaPageFetcher( - okHttpClient = okHttpClient, - pagesCache = pagesCache, - options = options, - page = data, - context = context, - mangaRepositoryFactory = mangaRepositoryFactory, - imageProxyInterceptor = imageProxyInterceptor, - ) - } - } - - class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt deleted file mode 100644 index 3b6281c64..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/OnPageSelectListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails - -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage - -fun interface OnPageSelectListener { - - fun onPageSelected(page: ReaderPage) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt deleted file mode 100644 index a0efac812..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PageThumbnail.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails - -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage - -data class PageThumbnail( - val isCurrent: Boolean, - val page: ReaderPage, -) : ListModel { - - val number - get() = page.index + 1 - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is PageThumbnail && page == other.page - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt deleted file mode 100644 index fbfe9c901..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt +++ /dev/null @@ -1,194 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails - -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 androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener -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.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.plus -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.SheetPagesBinding -import org.koitharu.kotatsu.list.ui.MangaListSpanResolver -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.parsers.model.Manga -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.reader.ui.ReaderState -import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter -import javax.inject.Inject -import kotlin.math.roundToInt - -@AndroidEntryPoint -class PagesThumbnailsSheet : - BaseAdaptiveSheet(), - AdaptiveSheetCallback, - OnListItemClickListener { - - private val viewModel by viewModels() - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - private var thumbnailsAdapter: PageThumbnailAdapter? = null - private var spanResolver: MangaListSpanResolver? = null - private var scrollListener: ScrollListener? = null - - private val spanSizeLookup = SpanSizeLookup() - private val listCommitCallback = Runnable { - spanSizeLookup.invalidateCache() - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding { - return SheetPagesBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - addSheetCallback(this) - spanResolver = MangaListSpanResolver(binding.root.resources) - thumbnailsAdapter = PageThumbnailAdapter( - coil = coil, - lifecycleOwner = viewLifecycleOwner, - clickListener = this@PagesThumbnailsSheet, - ) - with(binding.recyclerView) { - addItemDecoration(TypedListSpacingDecoration(context, false)) - adapter = thumbnailsAdapter - addOnLayoutChangeListener(spanResolver) - spanResolver?.setGridSize(settings.gridSize / 100f, this) - addOnScrollListener(ScrollListener().also { scrollListener = it }) - (layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup - } - viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) - viewModel.branch.observe(viewLifecycleOwner, ::updateTitle) - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } - } - - override fun onDestroyView() { - spanResolver = null - scrollListener = null - thumbnailsAdapter = null - spanSizeLookup.invalidateCache() - super.onDestroyView() - } - - override fun onItemClick(item: PageThumbnail, view: View) { - val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener) - if (listener != null) { - listener.onPageSelected(item.page) - } else { - val state = ReaderState(item.page.chapterId, item.page.index, 0) - val intent = IntentBuilder(view.context).manga(viewModel.manga).state(state).build() - startActivity(intent) - } - dismiss() - } - - override fun onStateChanged(sheet: View, newState: Int) { - viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED - } - - private fun updateTitle(branch: String?) { - val mangaName = viewModel.manga.title - viewBinding?.headerBar?.title = if (branch != null) { - getString(R.string.manga_branch_title_template, mangaName, branch) - } else { - mangaName - } - } - - private fun onThumbnailsChanged(list: List) { - val adapter = thumbnailsAdapter ?: return - if (adapter.itemCount == 0) { - var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent } - if (position > 0) { - val spanCount = spanResolver?.spanCount ?: 0 - val offset = if (position > spanCount + 1) { - (resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt() - } else { - position = 0 - 0 - } - val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset) - adapter.setItems(list, listCommitCallback + scrollCallback) - } else { - adapter.setItems(list, listCommitCallback) - } - } else { - adapter.setItems(list, listCommitCallback) - } - } - - private inner class ScrollListener : BoundsScrollListener(3, 3) { - - override fun onScrolledToStart(recyclerView: RecyclerView) { - viewModel.loadPrevChapter() - } - - override fun onScrolledToEnd(recyclerView: RecyclerView) { - viewModel.loadNextChapter() - } - } - - private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { - - init { - isSpanIndexCacheEnabled = true - isSpanGroupIndexCacheEnabled = true - } - - override fun getSpanSize(position: Int): Int { - val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 - return when (thumbnailsAdapter?.getItemViewType(position)) { - ListItemType.PAGE_THUMB.ordinal -> 1 - else -> total - } - } - - fun invalidateCache() { - invalidateSpanGroupIndexCache() - invalidateSpanIndexCache() - } - } - - companion object { - - const val ARG_MANGA = "manga" - const val ARG_CURRENT_PAGE = "current" - const val ARG_CHAPTER_ID = "chapter_id" - - private const val TAG = "PagesThumbnailsSheet" - - fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) { - PagesThumbnailsSheet().withArgs(3) { - putParcelable(ARG_MANGA, ParcelableManga(manga)) - putLong(ARG_CHAPTER_ID, chapterId) - putInt(ARG_CURRENT_PAGE, currentPage) - }.showDistinct(fm, TAG) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt deleted file mode 100644 index 16ad32e1a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsViewModel.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.koitharu.kotatsu.core.model.findById -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.firstNotNull -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.domain.ChaptersLoader -import javax.inject.Inject - -@HiltViewModel -class PagesThumbnailsViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val chaptersLoader: ChaptersLoader, - detailsLoadUseCase: DetailsLoadUseCase, -) : BaseViewModel() { - - private val currentPageIndex: Int = - savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 - private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L - val manga = savedStateHandle.require(PagesThumbnailsSheet.ARG_MANGA).manga - - private val mangaDetails = detailsLoadUseCase(MangaIntent.of(manga)).map { - val b = manga.chapters?.findById(initialChapterId)?.branch - branch.value = b - it.filterChapters(b) - }.withErrorHandling() - .stateIn(viewModelScope, SharingStarted.Lazily, null) - private var loadingJob: Job - private var loadingPrevJob: Job? = null - private var loadingNextJob: Job? = null - - val thumbnails = MutableStateFlow>(emptyList()) - val branch = MutableStateFlow(null) - - init { - loadingJob = launchLoadingJob(Dispatchers.Default) { - chaptersLoader.init(checkNotNull(mangaDetails.first { x -> x?.isLoaded == true })) - chaptersLoader.loadSingleChapter(initialChapterId) - updateList() - } - } - - fun loadPrevChapter() { - if (loadingJob.isActive || loadingPrevJob?.isActive == true) { - return - } - loadingPrevJob = loadPrevNextChapter(isNext = false) - } - - fun loadNextChapter() { - if (loadingJob.isActive || loadingNextJob?.isActive == true) { - return - } - loadingNextJob = loadPrevNextChapter(isNext = true) - } - - private fun loadPrevNextChapter(isNext: Boolean): Job = launchLoadingJob(Dispatchers.Default) { - val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId - chaptersLoader.loadPrevNextChapter(mangaDetails.firstNotNull(), currentId, isNext) - updateList() - } - - private fun updateList() { - val snapshot = chaptersLoader.snapshot() - val pages = buildList(snapshot.size + chaptersLoader.size + 2) { - var previousChapterId = 0L - for (page in snapshot) { - if (page.chapterId != previousChapterId) { - chaptersLoader.peekChapter(page.chapterId)?.let { - add(ListHeader(it.name)) - } - previousChapterId = page.chapterId - } - this += PageThumbnail( - isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex, - page = page, - ) - } - } - thumbnails.value = pages - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt deleted file mode 100644 index e45238a80..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import coil.size.Scale -import coil.size.Size -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.decodeRegion -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setTextColorAttr -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemPageThumbBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail -import com.google.android.material.R as materialR - -fun pageThumbnailAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }, -) { - - val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) - val thumbSize = Size( - width = gridWidth, - height = (gridWidth / 13f * 18f).toInt(), - ) - - val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener) - binding.root.setOnClickListener(clickListenerAdapter) - binding.root.setOnLongClickListener(clickListenerAdapter) - - bind { - val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage() - binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - size(thumbSize) - scale(Scale.FILL) - allowRgb565(true) - decodeRegion(0) - source(item.page.source) - enqueueWith(coil) - } - with(binding.textViewNumber) { - setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty) - setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary) - text = (item.number).toString() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt deleted file mode 100644 index a519b4a4c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.reader.ui.thumbnails.adapter - -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail - -class PageThumbnailAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - addDelegate(ListItemType.PAGE_THUMB, pageThumbnailAD(coil, lifecycleOwner, clickListener)) - addDelegate(ListItemType.HEADER, listHeaderAD(null)) - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - val list = items - for (i in (0..position).reversed()) { - val item = list.getOrNull(i) ?: continue - if (item is ListHeader) { - return item.getText(context) - } - } - return null - } -} 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 deleted file mode 100644 index 2bf15ebb2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.koitharu.kotatsu.remotelist.ui - -import android.os.Bundle -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.SearchView -import androidx.core.view.MenuProvider -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.fragment.app.viewModels -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.drop -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.util.MenuInvalidator -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.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment -import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.search.ui.SearchActivity -import org.koitharu.kotatsu.settings.SettingsActivity - -@AndroidEntryPoint -class RemoteListFragment : MangaListFragment(), FilterOwner { - - override val viewModel by viewModels() - - override val filter: MangaFilter - get() = viewModel - - override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - addMenuProvider(RemoteListMenuProvider()) - viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) - viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { - startActivity(DetailsActivity.newIntent(binding.root.context, it)) - } - viewModel.header.distinctUntilChangedBy { it.isFilterApplied } - .drop(1) - .observe(viewLifecycleOwner) { - activity?.invalidateMenu() - } - } - - override fun onScrolledToEnd() { - viewModel.loadNextPage() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - override fun onFilterClick(view: View?) { - FilterSheetFragment.show(childFragmentManager) - } - - override fun onEmptyActionClick() { - viewModel.resetFilter() - } - - private inner class RemoteListMenuProvider : - MenuProvider, - SearchView.OnQueryTextListener, - MenuItem.OnActionExpandListener { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_list_remote, menu) - val searchMenuItem = menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_source_settings -> { - startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), viewModel.source)) - true - } - - R.id.action_random -> { - viewModel.openRandom() - true - } - - R.id.action_filter -> { - onFilterClick(null) - true - } - - R.id.action_filter_reset -> { - viewModel.resetFilter() - true - } - - else -> false - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value - menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied - } - - override fun onQueryTextSubmit(query: String?): Boolean { - if (query.isNullOrEmpty()) { - return false - } - val intent = SearchActivity.newIntent( - context = this@RemoteListFragment.context ?: return false, - source = viewModel.source, - query = query, - ) - startActivity(intent) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean = false - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - (item.actionView as? SearchView)?.run { - imeOptions = if (viewModel.isIncognitoModeEnabled) { - imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } else { - imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() - } - } - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - val searchView = (item.actionView as? SearchView) ?: return false - searchView.setQuery("", false) - return true - } - } - - companion object { - - const val ARG_SOURCE = "provider" - - fun newInstance(provider: MangaSource) = RemoteListFragment().withArgs(1) { - putSerializable(ARG_SOURCE, provider) - } - } -} 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 deleted file mode 100644 index be03efa3b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.koitharu.kotatsu.remotelist.ui - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -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.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.require -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.explore.domain.ExploreRepository -import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.MangaFilter -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorFooter -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -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 javax.inject.Inject - -private const val FILTER_MIN_INTERVAL = 250L - -@HiltViewModel -open class RemoteListViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - private val filter: FilterCoordinator, - settings: AppSettings, - listExtraProvider: ListExtraProvider, - downloadScheduler: DownloadWorker.Scheduler, - private val exploreRepository: ExploreRepository, -) : MangaListViewModel(settings, downloadScheduler), MangaFilter by filter { - - val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE) - val isRandomLoading = MutableStateFlow(false) - val onOpenManga = MutableEventFlow() - - private val repository = mangaRepositoryFactory.create(source) - private val mangaList = MutableStateFlow?>(null) - private val hasNextPage = MutableStateFlow(false) - private val listError = MutableStateFlow(null) - private var loadingJob: Job? = null - private var randomJob: Job? = null - - override val content = combine( - mangaList.map { it?.distinctById()?.skipNsfwIfNeeded() }, - listMode, - listError, - hasNextPage, - ) { list, mode, error, hasNext -> - buildList(list?.size?.plus(2) ?: 2) { - when { - list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true)) - list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied)) - else -> { - list.toUi(this, mode, listExtraProvider) - when { - error != null -> add(error.toErrorFooter()) - hasNext -> add(LoadingFooter()) - } - } - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - init { - filter.observeState() - .debounce(FILTER_MIN_INTERVAL) - .onEach { filterState -> - loadingJob?.cancelAndJoin() - mangaList.value = null - loadList(filterState, false) - }.catch { error -> - listError.value = error - }.launchIn(viewModelScope) - } - - override fun onRefresh() { - loadList(filter.snapshot(), append = false) - } - - override fun onRetry() { - loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) - } - - fun loadNextPage() { - if (hasNextPage.value && listError.value == null) { - loadList(filter.snapshot(), append = true) - } - } - - fun resetFilter() = filter.reset() - - override fun onUpdateFilter(tags: Set) { - applyFilter(tags) - } - - protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job { - loadingJob?.let { - if (it.isActive) return it - } - return launchLoadingJob(Dispatchers.Default) { - try { - listError.value = null - val list = repository.getList( - offset = if (append) mangaList.value?.size ?: 0 else 0, - filter = filterState, - ) - val oldList = mangaList.getAndUpdate { oldList -> - if (!append || oldList.isNullOrEmpty()) { - list - } else { - oldList + list - } - }.orEmpty() - hasNextPage.value = if (append) { - list.isNotEmpty() - } else { - list.size > oldList.size || hasNextPage.value - } - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - e.printStackTraceDebug() - listError.value = e - if (!mangaList.value.isNullOrEmpty()) { - errorEvent.call(e) - } - hasNextPage.value = false - } - }.also { loadingJob = it } - } - - protected open fun createEmptyState(canResetFilter: Boolean) = EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = 0, - actionStringRes = if (canResetFilter) R.string.reset_filter else 0, - ) - - fun openRandom() { - if (randomJob?.isActive == true) { - return - } - randomJob = launchLoadingJob(Dispatchers.Default) { - isRandomLoading.value = true - val manga = exploreRepository.findRandomManga(source, 16) - onOpenManga.call(manga) - isRandomLoading.value = false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt deleted file mode 100644 index f45ce2123..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.koitharu.kotatsu.scrobbling - -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.ElementsIntoSet -import okhttp3.OkHttpClient -import org.koitharu.kotatsu.core.network.BaseHttpClient -import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator -import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor -import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import org.koitharu.kotatsu.scrobbling.mal.data.MALAuthenticator -import org.koitharu.kotatsu.scrobbling.mal.data.MALInterceptor -import org.koitharu.kotatsu.scrobbling.mal.domain.MALScrobbler -import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator -import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor -import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object ScrobblingModule { - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.SHIKIMORI) - fun provideShikimoriHttpClient( - @BaseHttpClient baseHttpClient: OkHttpClient, - authenticator: ShikimoriAuthenticator, - @ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage, - ): OkHttpClient = baseHttpClient.newBuilder().apply { - authenticator(authenticator) - addInterceptor(ShikimoriInterceptor(storage)) - }.build() - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.MAL) - fun provideMALHttpClient( - @BaseHttpClient baseHttpClient: OkHttpClient, - authenticator: MALAuthenticator, - @ScrobblerType(ScrobblerService.MAL) storage: ScrobblerStorage, - ): OkHttpClient = baseHttpClient.newBuilder().apply { - authenticator(authenticator) - addInterceptor(MALInterceptor(storage)) - }.build() - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.ANILIST) - fun provideAniListHttpClient( - @BaseHttpClient baseHttpClient: OkHttpClient, - authenticator: AniListAuthenticator, - @ScrobblerType(ScrobblerService.ANILIST) storage: ScrobblerStorage, - ): OkHttpClient = baseHttpClient.newBuilder().apply { - authenticator(authenticator) - addInterceptor(AniListInterceptor(storage)) - }.build() - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.ANILIST) - fun provideAniListStorage( - @ApplicationContext context: Context, - ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.ANILIST) - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.SHIKIMORI) - fun provideShikimoriStorage( - @ApplicationContext context: Context, - ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI) - - @Provides - @Singleton - @ScrobblerType(ScrobblerService.MAL) - fun provideMALStorage( - @ApplicationContext context: Context, - ): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.MAL) - - @Provides - @ElementsIntoSet - fun provideScrobblers( - shikimoriScrobbler: ShikimoriScrobbler, - aniListScrobbler: AniListScrobbler, - malScrobbler: MALScrobbler, - ): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt deleted file mode 100644 index 01c909456..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data - -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import javax.inject.Inject -import javax.inject.Provider - -class AniListAuthenticator @Inject constructor( - @ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage, - private val repositoryProvider: Provider, -) : Authenticator { - - override fun authenticate(route: Route?, response: Response): Request? { - val accessToken = storage.accessToken ?: return null - if (!isRequestWithAccessToken(response)) { - return null - } - synchronized(this) { - val newAccessToken = storage.accessToken ?: return null - if (accessToken != newAccessToken) { - return newRequestWithAccessToken(response.request, newAccessToken) - } - val updatedAccessToken = refreshAccessToken() ?: return null - return newRequestWithAccessToken(response.request, updatedAccessToken) - } - } - - private fun isRequestWithAccessToken(response: Response): Boolean { - val header = response.request.header(CommonHeaders.AUTHORIZATION) - return header?.startsWith("Bearer") == true - } - - private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { - return request.newBuilder() - .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") - .build() - } - - private fun refreshAccessToken(): String? = runCatching { - val repository = repositoryProvider.get() - runBlocking { repository.authorize(null) } - return storage.accessToken - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt deleted file mode 100644 index 25045c83e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListInterceptor.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data - -import okhttp3.Interceptor -import okhttp3.Response -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage - -private const val JSON = "application/json" - -class AniListInterceptor(private val storage: ScrobblerStorage) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val sourceRequest = chain.request() - val request = sourceRequest.newBuilder() - request.header(CommonHeaders.CONTENT_TYPE, JSON) - request.header(CommonHeaders.ACCEPT, JSON) - if (!sourceRequest.url.pathSegments.contains("oauth")) { - storage.accessToken?.let { - request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") - } - } - return chain.proceed(request.build()) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt deleted file mode 100644 index bd4852bf5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListRepository.kt +++ /dev/null @@ -1,274 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import okhttp3.FormBody -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.parsers.exception.GraphQLException -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.json.getStringOrNull -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.toIntUp -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.math.roundToInt - -private const val REDIRECT_URI = "kotatsu://anilist-auth" -private const val BASE_URL = "https://anilist.co/api/v2/" -private const val ENDPOINT = "https://graphql.anilist.co" -private const val MANGA_PAGE_SIZE = 10 -private const val REQUEST_QUERY = "query" -private const val REQUEST_MUTATION = "mutation" -private const val KEY_SCORE_FORMAT = "score_format" - -@Singleton -class AniListRepository @Inject constructor( - @ApplicationContext context: Context, - @ScrobblerType(ScrobblerService.ANILIST) private val okHttp: OkHttpClient, - @ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage, - private val db: MangaDatabase, -) : ScrobblerRepository { - - private val clientId = context.getString(R.string.anilist_clientId) - private val clientSecret = context.getString(R.string.anilist_clientSecret) - - override val oauthUrl: String - get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" + - "redirect_uri=${REDIRECT_URI}&response_type=code" - - override val isAuthorized: Boolean - get() = storage.accessToken != null - - private val shrinkRegex = Regex("\\t+") - - override suspend fun authorize(code: String?) { - val body = FormBody.Builder() - body.add("client_id", clientId) - body.add("client_secret", clientSecret) - if (code != null) { - body.add("grant_type", "authorization_code") - body.add("redirect_uri", REDIRECT_URI) - body.add("code", code) - } else { - body.add("grant_type", "refresh_token") - body.add("refresh_token", checkNotNull(storage.refreshToken)) - } - val request = Request.Builder() - .post(body.build()) - .url("${BASE_URL}oauth/token") - val response = okHttp.newCall(request.build()).await().parseJson() - storage.accessToken = response.getString("access_token") - storage.refreshToken = response.getString("refresh_token") - } - - override suspend fun loadUser(): ScrobblerUser { - val response = doRequest( - REQUEST_QUERY, - """ - AniChartUser { - user { - id - name - avatar { - medium - } - mediaListOptions { - scoreFormat - } - } - } - """, - ) - val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user") - storage[KEY_SCORE_FORMAT] = jo.getJSONObject("mediaListOptions").getString("scoreFormat") - return AniListUser(jo).also { storage.user = it } - } - - override val cachedUser: ScrobblerUser? - get() { - return storage.user - } - - override suspend fun unregister(mangaId: Long) { - return db.getScrobblingDao().delete(ScrobblerService.ANILIST.id, mangaId) - } - - override fun logout() { - storage.clear() - } - - override suspend fun findManga(query: String, offset: Int): List { - val page = (offset / MANGA_PAGE_SIZE.toFloat()).toIntUp() + 1 - val response = doRequest( - REQUEST_QUERY, - """ - Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) { - media(type: MANGA, sort: SEARCH_MATCH, search: ${JSONObject.quote(query)}) { - id - title { - userPreferred - native - } - coverImage { - medium - } - siteUrl - } - } - """, - ) - val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media") - return data.mapJSON { ScrobblerManga(it) } - } - - override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { - val response = doRequest( - REQUEST_MUTATION, - """ - SaveMediaListEntry(mediaId: $scrobblerMangaId) { - id - mediaId - status - notes - score - progress - } - """, - ) - saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) - } - - override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { - val response = doRequest( - REQUEST_MUTATION, - """ - SaveMediaListEntry(id: $rateId, progress: ${chapter.number}) { - id - mediaId - status - notes - score - progress - } - """, - ) - saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) - } - - override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { - val scoreRaw = (rating * 100f).roundToInt() - val statusString = status?.let { ", status: $it" }.orEmpty() - val notesString = comment?.let { ", notes: ${JSONObject.quote(it)}" }.orEmpty() - val response = doRequest( - REQUEST_MUTATION, - """ - SaveMediaListEntry(id: $rateId, scoreRaw: $scoreRaw$statusString$notesString) { - id - mediaId - status - notes - score - progress - } - """, - ) - saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId) - } - - override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - val response = doRequest( - REQUEST_QUERY, - """ - Media(id: $id) { - id - title { - userPreferred - } - coverImage { - large - } - description - siteUrl - } - """, - ) - return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media")) - } - - private suspend fun saveRate(json: JSONObject, mangaId: Long) { - val scoreFormat = ScoreFormat.of(storage[KEY_SCORE_FORMAT]) - val entity = ScrobblingEntity( - scrobbler = ScrobblerService.ANILIST.id, - id = json.getInt("id"), - mangaId = mangaId, - targetId = json.getLong("mediaId"), - status = json.getString("status"), - chapter = json.getInt("progress"), - comment = json.getString("notes"), - rating = scoreFormat.normalize(json.getDouble("score").toFloat()), - ) - db.getScrobblingDao().upsert(entity) - } - - private fun ScrobblerManga(json: JSONObject): ScrobblerManga { - val title = json.getJSONObject("title") - return ScrobblerManga( - id = json.getLong("id"), - name = title.getString("userPreferred"), - altName = title.getStringOrNull("native"), - cover = json.getJSONObject("coverImage").getString("medium"), - url = json.getString("siteUrl"), - ) - } - - private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( - id = json.getLong("id"), - name = json.getJSONObject("title").getString("userPreferred"), - cover = json.getJSONObject("coverImage").getString("large"), - url = json.getString("siteUrl"), - descriptionHtml = json.getString("description"), - ) - - @Suppress("FunctionName") - private fun AniListUser(json: JSONObject) = ScrobblerUser( - id = json.getLong("id"), - nickname = json.getString("name"), - avatar = json.getJSONObject("avatar").getString("medium"), - service = ScrobblerService.ANILIST, - ) - - private suspend fun doRequest(type: String, payload: String): JSONObject { - val body = JSONObject() - body.put("query", "$type { ${payload.shrink()} }") - val mediaType = "application/json; charset=utf-8".toMediaType() - val requestBody = body.toString().toRequestBody(mediaType) - val request = Request.Builder() - .post(requestBody) - .url(ENDPOINT) - val json = okHttp.newCall(request.build()).await().parseJson() - json.optJSONArray("errors")?.let { - if (it.length() != 0) { - throw GraphQLException(it) - } - } - return json - } - - private fun String.shrink() = replace(shrinkRegex, " ") -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt deleted file mode 100644 index 45a1ad85d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/ScoreFormat.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.data - -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -enum class ScoreFormat { - - POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3; - - fun normalize(score: Float): Float = when (this) { - POINT_100 -> score / 100f - POINT_10_DECIMAL, - POINT_10 -> score / 10f - - POINT_5 -> score / 5f - POINT_3 -> score / 3f - }.coerceIn(0f, 1f) - - companion object { - - fun of(rawValue: String?): ScoreFormat { - rawValue ?: return POINT_10_DECIMAL - return runCatching { valueOf(rawValue) } - .onFailure { it.printStackTraceDebug() } - .getOrDefault(POINT_10_DECIMAL) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt deleted file mode 100644 index ef4612950..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/domain/AniListScrobbler.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.anilist.domain - -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AniListScrobbler @Inject constructor( - private val repository: AniListRepository, - db: MangaDatabase, -) : Scrobbler(db, ScrobblerService.ANILIST, repository) { - - init { - statuses[ScrobblingStatus.PLANNED] = "PLANNING" - statuses[ScrobblingStatus.READING] = "CURRENT" - statuses[ScrobblingStatus.RE_READING] = "REPEATING" - statuses[ScrobblingStatus.COMPLETED] = "COMPLETED" - statuses[ScrobblingStatus.ON_HOLD] = "PAUSED" - statuses[ScrobblingStatus.DROPPED] = "DROPPED" - } - - override suspend fun updateScrobblingInfo( - mangaId: Long, - rating: Float, - status: ScrobblingStatus?, - comment: String?, - ) { - val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) - requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } - repository.updateRate( - rateId = entity.id, - mangaId = entity.mangaId, - rating = rating, - status = statuses[status], - comment = comment, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerRepository.kt deleted file mode 100644 index 35ed7bd46..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.data - -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser - -interface ScrobblerRepository { - - val oauthUrl: String - - val isAuthorized: Boolean - - val cachedUser: ScrobblerUser? - - suspend fun authorize(code: String?) - - suspend fun loadUser(): ScrobblerUser - - fun logout() - - suspend fun unregister(mangaId: Long) - - suspend fun findManga(query: String, offset: Int): List - - suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo - - suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) - - suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) - - suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerStorage.kt deleted file mode 100644 index b5ab50d02..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/data/ScrobblerStorage.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.data - -import android.content.Context -import androidx.core.content.edit -import org.jsoup.internal.StringUtil.StringJoiner -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser - -private const val KEY_ACCESS_TOKEN = "access_token" -private const val KEY_REFRESH_TOKEN = "refresh_token" -private const val KEY_USER = "user" - -class ScrobblerStorage(context: Context, service: ScrobblerService) { - - private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE) - - var accessToken: String? - get() = prefs.getString(KEY_ACCESS_TOKEN, null) - set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) } - - var refreshToken: String? - get() = prefs.getString(KEY_REFRESH_TOKEN, null) - set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) } - - var user: ScrobblerUser? - get() = prefs.getString(KEY_USER, null)?.let { - val lines = it.lines() - if (lines.size != 4) { - return@let null - } - ScrobblerUser( - id = lines[0].toLong(), - nickname = lines[1], - avatar = lines[2], - service = ScrobblerService.valueOf(lines[3]), - ) - } - set(value) = prefs.edit { - if (value == null) { - remove(KEY_USER) - return@edit - } - val str = StringJoiner("\n") - .add(value.id) - .add(value.nickname) - .add(value.avatar) - .add(value.service.name) - .complete() - putString(KEY_USER, str) - } - - operator fun get(key: String): String? = prefs.getString(key, null) - - operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) } - - fun clear() = prefs.edit { - clear() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt deleted file mode 100644 index 0a89ecaf6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/Scrobbler.kt +++ /dev/null @@ -1,139 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain - -import androidx.collection.LongSparseArray -import androidx.collection.getOrElse -import androidx.core.text.parseAsHtml -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.util.ext.findKeyByValue -import org.koitharu.kotatsu.core.util.ext.sanitize -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo -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.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.util.EnumMap - -abstract class Scrobbler( - protected val db: MangaDatabase, - val scrobblerService: ScrobblerService, - private val repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository, -) { - - private val infoCache = LongSparseArray() - protected val statuses = EnumMap(ScrobblingStatus::class.java) - - val user: Flow = flow { - repository.cachedUser?.let { - emit(it) - } - runCatchingCancellable { - repository.loadUser() - }.onSuccess { - emit(it) - }.onFailure { - it.printStackTraceDebug() - } - } - - val isAvailable: Boolean - get() = repository.isAuthorized - - suspend fun authorize(authCode: String): ScrobblerUser { - repository.authorize(authCode) - return repository.loadUser() - } - - fun logout() { - repository.logout() - } - - suspend fun findManga(query: String, offset: Int): List { - return repository.findManga(query, offset) - } - - suspend fun linkManga(mangaId: Long, targetId: Long) { - repository.createRate(mangaId, targetId) - } - - suspend fun scrobble(mangaId: Long, chapter: MangaChapter) { - val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return - repository.updateRate(entity.id, entity.mangaId, chapter) - } - - suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? { - val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return null - return entity.toScrobblingInfo() - } - - abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?) - - fun observeScrobblingInfo(mangaId: Long): Flow { - return db.getScrobblingDao().observe(scrobblerService.id, mangaId) - .map { it?.toScrobblingInfo() } - } - - fun observeAllScrobblingInfo(): Flow> { - return db.getScrobblingDao().observe(scrobblerService.id) - .map { entities -> - coroutineScope { - entities.map { - async { - it.toScrobblingInfo() - } - }.awaitAll() - }.filterNotNull() - } - } - - suspend fun unregisterScrobbling(mangaId: Long) { - repository.unregister(mangaId) - } - - protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - return repository.getMangaInfo(id) - } - - private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? { - val mangaInfo = infoCache.getOrElse(targetId) { - runCatchingCancellable { - getMangaInfo(targetId) - }.onFailure { - it.printStackTraceDebug() - }.onSuccess { - infoCache.put(targetId, it) - }.getOrNull() ?: return null - } - return ScrobblingInfo( - scrobbler = scrobblerService, - mangaId = mangaId, - targetId = targetId, - status = statuses.findKeyByValue(status), - chapter = chapter, - comment = comment, - rating = rating, - title = mangaInfo.name, - coverUrl = mangaInfo.cover, - description = mangaInfo.descriptionHtml.parseAsHtml().sanitize(), - externalUrl = mangaInfo.url, - ) - } -} - -suspend fun Scrobbler.tryScrobble(mangaId: Long, chapter: MangaChapter): Boolean { - return runCatchingCancellable { - scrobble(mangaId, chapter) - }.onFailure { - it.printStackTraceDebug() - }.isSuccess -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerManga.kt deleted file mode 100644 index f4d3be9bc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerManga.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class ScrobblerManga( - val id: Long, - val name: String, - val altName: String?, - val cover: String, - val url: String, -) : ListModel { - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ScrobblerManga && other.id == id - } - - override fun toString(): String { - return "ScrobblerManga #$id \"$name\" $url" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerType.kt deleted file mode 100644 index 8920e295a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerType.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model - -import javax.inject.Qualifier - -@Qualifier -annotation class ScrobblerType( - val service: ScrobblerService -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerUser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerUser.kt deleted file mode 100644 index d79d0a3c7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblerUser.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model - -data class ScrobblerUser( - val id: Long, - val nickname: String, - val avatar: String, - val service: ScrobblerService, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingInfo.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingInfo.kt deleted file mode 100644 index ff106007f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingInfo.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class ScrobblingInfo( - val scrobbler: ScrobblerService, - val mangaId: Long, - val targetId: Long, - val status: ScrobblingStatus?, - val chapter: Int, - val comment: String?, - val rating: Float, - val title: String, - val coverUrl: String, - val description: CharSequence?, - val externalUrl: String, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ScrobblingInfo && other.scrobbler == scrobbler - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingStatus.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingStatus.kt deleted file mode 100644 index 33ac263f2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/domain/model/ScrobblingStatus.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.domain.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -enum class ScrobblingStatus : ListModel { - - PLANNED, READING, RE_READING, COMPLETED, ON_HOLD, DROPPED; - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ScrobblingStatus && other.ordinal == ordinal - } -} 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 deleted file mode 100644 index 52d42edf0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ /dev/null @@ -1,144 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.config - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import coil.ImageLoader -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.ui.BaseActivity -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.newImageRequest -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.showOrHide -import org.koitharu.kotatsu.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 -import javax.inject.Inject -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class ScrobblerConfigActivity : BaseActivity(), - OnListItemClickListener, View.OnClickListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel: ScrobblerConfigViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityScrobblerConfigBinding.inflate(layoutInflater)) - setTitle(viewModel.titleResId) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - val listAdapter = ScrobblingMangaAdapter(this, coil, this) - with(viewBinding.recyclerView) { - adapter = listAdapter - setHasFixedSize(true) - val decoration = TypedListSpacingDecoration(context, false) - addItemDecoration(decoration) - } - viewBinding.imageViewAvatar.setOnClickListener(this) - - viewModel.content.observe(this, listAdapter::setItems) - viewModel.user.observe(this, this::onUserChanged) - viewModel.isLoading.observe(this, this::onLoadingStateChanged) - viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onLoggedOut.observeEvent(this) { - finishAfterTransition() - } - - processIntent(intent) - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - if (intent != null) { - setIntent(intent) - processIntent(intent) - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - val rv = viewBinding.recyclerView - rv.updatePadding( - left = insets.left + rv.paddingTop, - right = insets.right + rv.paddingTop, - bottom = insets.bottom + rv.paddingTop, - ) - } - - override fun onItemClick(item: ScrobblingInfo, view: View) { - startActivity( - DetailsActivity.newIntent(this, item.mangaId), - ) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.imageView_avatar -> showUserDialog() - } - } - - private fun processIntent(intent: Intent) { - if (intent.action == Intent.ACTION_VIEW) { - val uri = intent.data ?: return - val code = uri.getQueryParameter("code") - if (!code.isNullOrEmpty()) { - viewModel.onAuthCodeReceived(code) - } - } - } - - private fun onUserChanged(user: ScrobblerUser?) { - if (user == null) { - viewBinding.imageViewAvatar.disposeImageRequest() - viewBinding.imageViewAvatar.setImageResource(materialR.drawable.abc_ic_menu_overflow_material) - return - } - viewBinding.imageViewAvatar.newImageRequest(this, user.avatar) - ?.placeholder(R.drawable.bg_badge_empty) - ?.enqueueWith(coil) - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.showOrHide(isLoading) - } - - private fun showUserDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(getString(R.string.logged_in_as, viewModel.user.value?.nickname)) - .setNegativeButton(R.string.close, null) - .setPositiveButton(R.string.logout) { _, _ -> - viewModel.logout() - }.show() - } - - 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" - - 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 deleted file mode 100644 index c18aaca7a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.config - -import android.net.Uri -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -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.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.onFirst -import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -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.domain.model.ScrobblingStatus -import javax.inject.Inject - -@HiltViewModel -class ScrobblerConfigViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - scrobblers: Set<@JvmSuppressWildcards Scrobbler>, -) : BaseViewModel() { - - private val scrobblerService = getScrobblerService(savedStateHandle) - private val scrobbler = scrobblers.first { it.scrobblerService == scrobblerService } - - val titleResId = scrobbler.scrobblerService.titleResId - - val user = MutableStateFlow(null) - val onLoggedOut = MutableEventFlow() - - val content = scrobbler.observeAllScrobblingInfo() - .onStart { loadingCounter.increment() } - .onFirst { loadingCounter.decrement() } - .catch { errorEvent.call(it) } - .map { buildContentList(it) } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - init { - scrobbler.user - .onEach { user.value = it } - .launchIn(viewModelScope + Dispatchers.Default) - } - - fun onAuthCodeReceived(authCode: String) { - launchLoadingJob(Dispatchers.Default) { - val newUser = scrobbler.authorize(authCode) - user.value = newUser - } - } - - fun logout() { - launchLoadingJob(Dispatchers.Default) { - scrobbler.logout() - user.value = null - onLoggedOut.call(Unit) - } - } - - private fun buildContentList(list: List): List { - if (list.isEmpty()) { - return listOf( - EmptyState( - icon = R.drawable.ic_empty_history, - textPrimary = R.string.nothing_here, - textSecondary = R.string.scrobbling_empty_hint, - actionStringRes = 0, - ), - ) - } - val grouped = list.groupBy { it.status } - val statuses = ScrobblingStatus.entries - val result = ArrayList(list.size + statuses.size) - for (st in statuses) { - val subList = grouped[st] - if (subList.isNullOrEmpty()) { - continue - } - result.add(st) - result.addAll(subList) - } - return result - } - - private fun getScrobblerService( - savedStateHandle: SavedStateHandle, - ): ScrobblerService { - val serviceId = savedStateHandle.get(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0 - if (serviceId != 0) { - return ScrobblerService.entries.first { it.id == serviceId } - } - val uri = savedStateHandle.require(BaseActivity.EXTRA_DATA) - return when (uri.host) { - ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI - ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST - ScrobblerConfigActivity.HOST_MAL_AUTH -> ScrobblerService.MAL - else -> error("Wrong scrobbler uri: $uri") - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingHeaderAD.kt deleted file mode 100644 index 6a3b088a7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingHeaderAD.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter - -import androidx.core.view.isInvisible -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus - -fun scrobblingHeaderAD() = adapterDelegateViewBinding( - { inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) }, -) { - - binding.buttonMore.isInvisible = true - val strings = context.resources.getStringArray(R.array.scrobbling_statuses) - - bind { - binding.textViewTitle.text = strings.getOrNull(item.ordinal) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt deleted file mode 100644 index 25e83d14c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAD.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.databinding.ItemScrobblingMangaBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo - -fun scrobblingMangaAD( - clickListener: OnListItemClickListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemScrobblingMangaBinding.inflate(layoutInflater, parent, false) }, -) { - - val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener) - itemView.setOnClickListener(clickListenerAdapter) - - bind { - binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(R.drawable.ic_placeholder) - error(R.drawable.ic_error_placeholder) - enqueueWith(coil) - } - binding.textViewTitle.text = item.title - binding.ratingBar.rating = item.rating * binding.ratingBar.numStars - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt deleted file mode 100644 index b43004a6d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/adapter/ScrobblingMangaAdapter.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.config.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo - -class ScrobblingMangaAdapter( - clickListener: OnListItemClickListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.HEADER, scrobblingHeaderAD()) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null)) - addDelegate(ListItemType.MANGA_SCROBBLING, scrobblingMangaAD(clickListener, coil, lifecycleOwner)) - } -} 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 deleted file mode 100644 index 3f098e3e0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt +++ /dev/null @@ -1,213 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -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 coil.ImageLoader -import com.google.android.material.tabs.TabLayout -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.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -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.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.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 - -@AndroidEntryPoint -class ScrobblingSelectorSheet : - BaseAdaptiveSheet(), - OnListItemClickListener, - PaginationScrollListener.Callback, - View.OnClickListener, - MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener, - TabLayout.OnTabSelectedListener, - ListStateHolderListener { - - @Inject - lateinit var coil: ImageLoader - - private var collapsibleActionViewCallback: CollapseActionViewCallback? = null - - private val viewModel by viewModels() - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingSelectorBinding { - return SheetScrobblingSelectorBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetScrobblingSelectorBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - disableFitToContents() - val listAdapter = ScrobblerSelectorAdapter(viewLifecycleOwner, coil, this, this) - val decoration = ScrobblerMangaSelectionDecoration(binding.root.context) - with(binding.recyclerView) { - adapter = listAdapter - addItemDecoration(decoration) - addItemDecoration(TypedListSpacingDecoration(context, false)) - addOnScrollListener(PaginationScrollListener(4, this@ScrobblingSelectorSheet)) - } - binding.buttonDone.setOnClickListener(this) - initOptionsMenu() - initTabs() - - viewModel.content.observe(viewLifecycleOwner) { listAdapter.items = it } - viewModel.selectedItemId.observe(viewLifecycleOwner) { - decoration.checkedItemId = it - binding.recyclerView.invalidateItemDecorations() - } - viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) - viewModel.onClose.observeEvent(viewLifecycleOwner) { - dismiss() - } - viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index -> - val tab = binding.tabs.getTabAt(index) - if (tab != null && !tab.isSelected) { - tab.select() - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - collapsibleActionViewCallback = null - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_done -> viewModel.onDoneClick() - } - } - - override fun onItemClick(item: ScrobblerManga, view: View) { - viewModel.selectedItemId.value = item.id - } - - override fun onRetryClick(error: Throwable) { - viewModel.retry() - } - - override fun onEmptyActionClick() { - openSearch() - } - - override fun onScrolledToEnd() { - viewModel.loadNextPage() - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - setExpanded(isExpanded = true, isLocked = true) - collapsibleActionViewCallback?.onMenuItemActionExpand(item) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - val searchView = (item.actionView as? SearchView) ?: return false - searchView.setQuery("", false) - searchView.post { setExpanded(isExpanded = false, isLocked = false) } - collapsibleActionViewCallback?.onMenuItemActionCollapse(item) - return true - } - - override fun onQueryTextSubmit(query: String?): Boolean { - if (query == null || query.length < 3) { - return false - } - viewModel.search(query) - requireViewBinding().toolbar.menu.findItem(R.id.action_search)?.collapseActionView() - return true - } - - override fun onQueryTextChange(newText: String?): Boolean = false - - override fun onTabSelected(tab: TabLayout.Tab) { - viewModel.setScrobblerIndex(tab.position) - } - - override fun onTabUnselected(tab: TabLayout.Tab?) = Unit - - override fun onTabReselected(tab: TabLayout.Tab?) { - if (!isExpanded) { - setExpanded(isExpanded = true, isLocked = behavior?.isDraggable == false) - } - requireViewBinding().recyclerView.firstVisibleItemPosition = 0 - } - - private fun openSearch() { - val menuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) ?: return - menuItem.expandActionView() - } - - private fun onError(e: Throwable) { - Toast.makeText(requireContext(), e.getDisplayMessage(resources), Toast.LENGTH_LONG).show() - if (viewModel.isEmpty) { - dismissAllowingStateLoss() - } - } - - private fun initOptionsMenu() { - requireViewBinding().toolbar.inflateMenu(R.menu.opt_shiki_selector) - val searchMenuItem = requireViewBinding().toolbar.menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title - collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also { - onBackPressedDispatcher.addCallback(it) - } - } - - private fun initTabs() { - val entries = viewModel.availableScrobblers - val tabs = requireViewBinding().tabs - val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1 - tabs.removeAllTabs() - tabs.clearOnTabSelectedListeners() - tabs.addOnTabSelectedListener(this) - for (entry in entries) { - val tab = tabs.newTab() - tab.tag = entry.scrobblerService - tab.setIcon(entry.scrobblerService.iconResId) - tab.setText(entry.scrobblerService.titleResId) - tabs.addTab(tab) - if (entry.scrobblerService.id == selectedId) { - tab.select() - } - } - } - - 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 deleted file mode 100644 index 54c28c99f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ /dev/null @@ -1,183 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import androidx.recyclerview.widget.RecyclerView.NO_ID -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -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.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.require -import org.koitharu.kotatsu.core.util.ext.requireValue -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.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint -import javax.inject.Inject - -@HiltViewModel -class ScrobblingSelectorViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - scrobblers: Set<@JvmSuppressWildcards Scrobbler>, -) : BaseViewModel() { - - val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga - - val availableScrobblers = scrobblers.filter { it.isAvailable } - - val selectedScrobblerIndex = MutableStateFlow(0) - - private val scrobblerMangaList = MutableStateFlow>(emptyList()) - private val hasNextPage = MutableStateFlow(true) - private val listError = MutableStateFlow(null) - private var loadingJob: Job? = null - private var doneJob: Job? = null - private var initJob: Job? = null - - private val currentScrobbler: Scrobbler - get() = availableScrobblers[selectedScrobblerIndex.requireValue()] - - val content: StateFlow> = combine( - scrobblerMangaList.map { it.distinctBy { x -> x.id } }, - listError, - hasNextPage, - ) { list, error, isHasNextPage -> - if (list.isNotEmpty()) { - if (isHasNextPage) { - list + LoadingFooter() - } else { - list - } - } else { - listOf( - when { - error != null -> errorHint(error) - isHasNextPage -> LoadingFooter() - else -> emptyResultsHint() - }, - ) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - val selectedItemId = MutableStateFlow(NO_ID) - val searchQuery = MutableStateFlow(manga.title) - val onClose = MutableEventFlow() - - val isEmpty: Boolean - get() = scrobblerMangaList.value.isEmpty() - - init { - initialize() - } - - fun search(query: String) { - loadingJob?.cancel() - searchQuery.value = query - loadList(append = false) - } - - fun loadNextPage() { - if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) { - loadList(append = true) - } - } - - fun retry() { - loadingJob?.cancel() - hasNextPage.value = true - scrobblerMangaList.value = emptyList() - loadList(append = false) - } - - private fun loadList(append: Boolean) { - if (loadingJob?.isActive == true) { - return - } - loadingJob = launchLoadingJob(Dispatchers.Default) { - listError.value = null - val offset = if (append) scrobblerMangaList.value.size else 0 - runCatchingCancellable { - currentScrobbler.findManga(checkNotNull(searchQuery.value), offset) - }.onSuccess { list -> - if (!append) { - scrobblerMangaList.value = list - } else if (list.isNotEmpty()) { - scrobblerMangaList.value += list - } - hasNextPage.value = list.isNotEmpty() - }.onFailure { error -> - error.printStackTraceDebug() - listError.value = error - } - } - } - - fun onDoneClick() { - if (doneJob?.isActive == true) { - return - } - val targetId = selectedItemId.value - if (targetId == NO_ID) { - onClose.call(Unit) - } - doneJob = launchJob(Dispatchers.Default) { - currentScrobbler.linkManga(manga.id, targetId) - onClose.call(Unit) - } - } - - fun setScrobblerIndex(index: Int) { - if (index == selectedScrobblerIndex.value || index !in availableScrobblers.indices) return - selectedScrobblerIndex.value = index - initialize() - } - - private fun initialize() { - initJob?.cancel() - loadingJob?.cancel() - hasNextPage.value = true - scrobblerMangaList.value = emptyList() - initJob = launchJob(Dispatchers.Default) { - try { - val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) - if (info != null) { - selectedItemId.value = info.targetId - } - } finally { - loadList(append = false) - } - } - } - - private fun emptyResultsHint() = ScrobblerHint( - icon = R.drawable.ic_empty_history, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_search_holder_secondary, - error = null, - actionStringRes = R.string.search, - ) - - private fun errorHint(e: Throwable) = ScrobblerHint( - icon = R.drawable.ic_error_large, - textPrimary = R.string.error_occurred, - error = e, - textSecondary = 0, - actionStringRes = R.string.try_again, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerHintAD.kt deleted file mode 100644 index ea14d0a6c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerHintAD.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding -import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint - -fun scrobblerHintAD( - listener: ListStateHolderListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, -) { - - binding.buttonRetry.setOnClickListener { - val e = item.error - if (e != null) { - listener.onRetryClick(e) - } else { - listener.onEmptyActionClick() - } - } - - bind { - binding.icon.setImageResource(item.icon) - binding.textPrimary.setText(item.textPrimary) - if (item.error != null) { - binding.textSecondary.textAndVisible = item.error?.getDisplayMessage(context.resources) - } else { - binding.textSecondary.setTextAndVisible(item.textSecondary) - } - binding.buttonRetry.setTextAndVisible(item.actionStringRes) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt deleted file mode 100644 index f8d230fd0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/adapter/ScrobblerSelectorAdapter.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener -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.scrobbling.common.domain.model.ScrobblerManga - -class ScrobblerSelectorAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - clickListener: OnListItemClickListener, - stateHolderListener: ListStateHolderListener, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.MANGA_SCROBBLING, scrobblingMangaAD(lifecycleOwner, coil, clickListener)) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.HINT_EMPTY, scrobblerHintAD(stateHolderListener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/model/ScrobblerHint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/model/ScrobblerHint.kt deleted file mode 100644 index 9b2b42eac..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/model/ScrobblerHint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.common.ui.selector.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class ScrobblerHint( - @DrawableRes val icon: Int, - @StringRes val textPrimary: Int, - @StringRes val textSecondary: Int, - val error: Throwable?, - @StringRes val actionStringRes: Int, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is ScrobblerHint && other.textPrimary == textPrimary - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt deleted file mode 100644 index a365e4d09..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.mal.data - -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import javax.inject.Inject -import javax.inject.Provider - -class MALAuthenticator @Inject constructor( - @ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage, - private val repositoryProvider: Provider, -) : Authenticator { - - override fun authenticate(route: Route?, response: Response): Request? { - val accessToken = storage.accessToken ?: return null - if (!isRequestWithAccessToken(response)) { - return null - } - synchronized(this) { - val newAccessToken = storage.accessToken ?: return null - if (accessToken != newAccessToken) { - return newRequestWithAccessToken(response.request, newAccessToken) - } - val updatedAccessToken = refreshAccessToken() ?: return null - return newRequestWithAccessToken(response.request, updatedAccessToken) - } - } - - private fun isRequestWithAccessToken(response: Response): Boolean { - val header = response.request.header(CommonHeaders.AUTHORIZATION) - return header?.startsWith("Bearer") == true - } - - private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { - return request.newBuilder() - .header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken") - .build() - } - - private fun refreshAccessToken(): String? = runCatching { - val repository = repositoryProvider.get() - runBlocking { repository.authorize(null) } - return storage.accessToken - }.onFailure { - it.printStackTraceDebug() - }.getOrNull() - -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALInterceptor.kt deleted file mode 100644 index f023d5f75..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALInterceptor.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.mal.data - -import okhttp3.Interceptor -import okhttp3.Response -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage - -private const val JSON = "application/json" - -class MALInterceptor(private val storage: ScrobblerStorage) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val sourceRequest = chain.request() - val request = sourceRequest.newBuilder() - request.header(CommonHeaders.CONTENT_TYPE, JSON) - request.header(CommonHeaders.ACCEPT, JSON) - if (!sourceRequest.url.pathSegments.contains("oauth")) { - storage.accessToken?.let { - request.header(CommonHeaders.AUTHORIZATION, "Bearer $it") - } - } - return chain.proceed(request.build()) - } - -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALRepository.kt deleted file mode 100644 index d2887c30c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALRepository.kt +++ /dev/null @@ -1,221 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.mal.data - -import android.content.Context -import android.util.Base64 -import dagger.hilt.android.qualifiers.ApplicationContext -import okhttp3.FormBody -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONObject -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage -import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser -import java.security.SecureRandom -import javax.inject.Inject -import javax.inject.Singleton - -private const val REDIRECT_URI = "kotatsu://mal-auth" -private const val BASE_WEB_URL = "https://myanimelist.net" -private const val BASE_API_URL = "https://api.myanimelist.net/v2" -private const val AVATAR_STUB = "https://cdn.myanimelist.net/images/questionmark_50.gif" - -@Singleton -class MALRepository @Inject constructor( - @ApplicationContext context: Context, - @ScrobblerType(ScrobblerService.MAL) private val okHttp: OkHttpClient, - @ScrobblerType(ScrobblerService.MAL) private val storage: ScrobblerStorage, - private val db: MangaDatabase, -) : ScrobblerRepository { - - private val clientId = context.getString(R.string.mal_clientId) - private val codeVerifier: String by lazy(::generateCodeVerifier) - - override val oauthUrl: String - get() = "$BASE_WEB_URL/v1/oauth2/authorize?" + - "response_type=code" + - "&client_id=$clientId" + - "&redirect_uri=$REDIRECT_URI" + - "&code_challenge=$codeVerifier" + - "&code_challenge_method=plain" - - override val isAuthorized: Boolean - get() = storage.accessToken != null - - override val cachedUser: ScrobblerUser? - get() { - return storage.user - } - - override suspend fun authorize(code: String?) { - val body = FormBody.Builder() - if (code != null) { - body.add("client_id", clientId) - body.add("grant_type", "authorization_code") - body.add("code", code) - body.add("redirect_uri", REDIRECT_URI) - body.add("code_verifier", codeVerifier) - } - val request = Request.Builder() - .post(body.build()) - .url("${BASE_WEB_URL}/v1/oauth2/token") - - val response = okHttp.newCall(request.build()).await().parseJson() - storage.accessToken = response.getString("access_token") - storage.refreshToken = response.getString("refresh_token") - } - - override suspend fun loadUser(): ScrobblerUser { - val request = Request.Builder() - .get() - .url("${BASE_API_URL}/users/@me") - val response = okHttp.newCall(request.build()).await().parseJson() - return MALUser(response).also { storage.user = it } - } - - override suspend fun unregister(mangaId: Long) { - return db.getScrobblingDao().delete(ScrobblerService.MAL.id, mangaId) - } - - override suspend fun findManga(query: String, offset: Int): List { - val url = BASE_API_URL.toHttpUrl().newBuilder() - .addPathSegment("manga") - .addQueryParameter("offset", offset.toString()) - .addQueryParameter("nsfw", "true") - // WARNING! MAL API throws a 400 when the query is over 64 characters - .addQueryParameter("q", query.take(64)) - .build() - val request = Request.Builder().url(url).get().build() - val response = okHttp.newCall(request).await().parseJson() - check(response.has("data")) { "Invalid response: \"$response\"" } - val data = response.getJSONArray("data") - return data.mapJSONNotNull { jsonToManga(it) } - } - - override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo { - val url = BASE_API_URL.toHttpUrl().newBuilder() - .addPathSegment("manga") - .addPathSegment(id.toString()) - .addQueryParameter("fields", "synopsis") - .build() - val request = Request.Builder().url(url) - val response = okHttp.newCall(request.build()).await().parseJson() - return ScrobblerMangaInfo(response) - } - - override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) { - val body = FormBody.Builder() - .add("status", "reading") - .add("score", "0") - val url = BASE_API_URL.toHttpUrl().newBuilder() - .addPathSegment("manga") - .addPathSegment(scrobblerMangaId.toString()) - .addPathSegment("my_list_status") - .addQueryParameter("fields", "synopsis") - .build() - val request = Request.Builder() - .url(url) - .put(body.build()) - .build() - val response = okHttp.newCall(request).await().parseJson() - saveRate(response, mangaId, scrobblerMangaId) - } - - override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) { - val body = FormBody.Builder() - .add("num_chapters_read", chapter.number.toString()) - val url = BASE_API_URL.toHttpUrl().newBuilder() - .addPathSegment("manga") - .addPathSegment(rateId.toString()) - .addPathSegment("my_list_status") - .build() - val request = Request.Builder() - .url(url) - .put(body.build()) - .build() - val response = okHttp.newCall(request).await().parseJson() - saveRate(response, mangaId, rateId.toLong()) - } - - override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) { - val body = FormBody.Builder() - .add("status", status.toString()) - .add("score", rating.toString()) - val url = BASE_API_URL.toHttpUrl().newBuilder() - .addPathSegment("manga") - .addPathSegment(rateId.toString()) - .addPathSegment("my_list_status") - .build() - val request = Request.Builder() - .url(url) - .put(body.build()) - .build() - val response = okHttp.newCall(request).await().parseJson() - saveRate(response, mangaId, rateId.toLong()) - } - - private suspend fun saveRate(json: JSONObject, mangaId: Long, scrobblerMangaId: Long) { - val entity = ScrobblingEntity( - scrobbler = ScrobblerService.MAL.id, - id = scrobblerMangaId.toInt(), - mangaId = mangaId, - targetId = scrobblerMangaId, - status = json.getString("status"), - chapter = json.getInt("num_chapters_read"), - comment = json.getString("comments"), - rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f), - ) - db.getScrobblingDao().upsert(entity) - } - - override fun logout() { - storage.clear() - } - - private fun jsonToManga(json: JSONObject): ScrobblerManga? { - for (i in 0 until json.length()) { - val node = json.getJSONObject("node") - return ScrobblerManga( - id = node.getLong("id"), - name = node.getString("title"), - altName = null, - cover = node.getJSONObject("main_picture").getString("large"), - url = "$BASE_WEB_URL/manga/${node.getLong("id")}", - ) - } - return null - } - - private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo( - id = json.getLong("id"), - name = json.getString("title"), - cover = json.getJSONObject("main_picture").getString("large"), - url = "$BASE_WEB_URL/manga/${json.getLong("id")}", - descriptionHtml = json.getString("synopsis"), - ) - - @Suppress("FunctionName") - private fun MALUser(json: JSONObject) = ScrobblerUser( - id = json.getLong("id"), - nickname = json.getString("name"), - avatar = json.getString("picture") ?: AVATAR_STUB, - service = ScrobblerService.MAL, - ) - - private fun generateCodeVerifier(): String { - val codeVerifier = ByteArray(50) - SecureRandom().nextBytes(codeVerifier) - return Base64.encodeToString(codeVerifier, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/domain/MALScrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/domain/MALScrobbler.kt deleted file mode 100644 index 3f83625e9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/domain/MALScrobbler.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.mal.domain - -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository -import javax.inject.Inject -import javax.inject.Singleton - -private const val RATING_MAX = 10f - -@Singleton -class MALScrobbler @Inject constructor( - private val repository: MALRepository, - db: MangaDatabase, -) : Scrobbler(db, ScrobblerService.MAL, repository) { - - init { - statuses[ScrobblingStatus.PLANNED] = "plan_to_read" - statuses[ScrobblingStatus.READING] = "reading" - statuses[ScrobblingStatus.COMPLETED] = "completed" - statuses[ScrobblingStatus.ON_HOLD] = "on_hold" - statuses[ScrobblingStatus.DROPPED] = "dropped" - } - - override suspend fun updateScrobblingInfo( - mangaId: Long, - rating: Float, - status: ScrobblingStatus?, - comment: String?, - ) { - val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) - requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } - repository.updateRate( - rateId = entity.id, - mangaId = entity.mangaId, - rating = rating * RATING_MAX, - status = statuses[status], - comment = comment, - ) - } - -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt deleted file mode 100644 index b0d720359..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/domain/ShikimoriScrobbler.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.scrobbling.shikimori.domain - -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository -import javax.inject.Inject -import javax.inject.Singleton - -private const val RATING_MAX = 10f - -@Singleton -class ShikimoriScrobbler @Inject constructor( - private val repository: ShikimoriRepository, - db: MangaDatabase, -) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository) { - - init { - statuses[ScrobblingStatus.PLANNED] = "planned" - statuses[ScrobblingStatus.READING] = "watching" - statuses[ScrobblingStatus.RE_READING] = "rewatching" - statuses[ScrobblingStatus.COMPLETED] = "completed" - statuses[ScrobblingStatus.ON_HOLD] = "on_hold" - statuses[ScrobblingStatus.DROPPED] = "dropped" - } - - override suspend fun updateScrobblingInfo( - mangaId: Long, - rating: Float, - status: ScrobblingStatus?, - comment: String?, - ) { - val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) - requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" } - repository.updateRate( - rateId = entity.id, - mangaId = entity.mangaId, - rating = rating * RATING_MAX, - status = statuses[status], - comment = comment, - ) - } -} 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 deleted file mode 100644 index 6a18aa976..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ /dev/null @@ -1,204 +0,0 @@ -package org.koitharu.kotatsu.search.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import androidx.core.graphics.Insets -import androidx.core.graphics.drawable.toDrawable -import androidx.core.os.bundleOf -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -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.MangaSource -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.model.titleRes -import org.koitharu.kotatsu.core.util.ViewBadge -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.ActivityMangaListBinding -import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment -import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.MangaFilter -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment -import org.koitharu.kotatsu.list.ui.preview.PreviewFragment -import org.koitharu.kotatsu.local.ui.LocalListFragment -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -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.remotelist.ui.RemoteListFragment -import kotlin.math.absoluteValue -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class MangaListActivity : - BaseActivity(), - AppBarOwner, View.OnClickListener, FilterOwner, AppBarLayout.OnOffsetChangedListener { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override val filter: MangaFilter - get() = checkNotNull(findFilterOwner()) { - "Cannot find FilterOwner fragment in ${supportFragmentManager.fragments}" - }.filter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityMangaListBinding.inflate(layoutInflater)) - val tags = intent.getParcelableExtraCompat(EXTRA_TAGS)?.tags - supportActionBar?.setDisplayHomeAsUpEnabled(true) - if (viewBinding.containerFilterHeader != null) { - viewBinding.appbar.addOnOffsetChangedListener(this) - } - val source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source - if (source == null) { - finishAfterTransition() - return - } - viewBinding.buttonOrder?.setOnClickListener(this) - title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title - initList(source, tags) - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.cardSide?.updateLayoutParams { - bottomMargin = marginStart + insets.bottom - topMargin = marginStart + insets.top - } - } - - override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { - val container = viewBinding.containerFilterHeader ?: return - container.background = if (verticalOffset.absoluteValue < appBarLayout.totalScrollRange) { - container.context.getThemeColor(materialR.attr.backgroundColor).toDrawable() - } else { - viewBinding.collapsingToolbarLayout?.contentScrim - } - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_order -> FilterSheetFragment.show(supportFragmentManager) - } - } - - fun showPreview(manga: Manga): Boolean = setSideFragment( - PreviewFragment::class.java, - bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga)), - ) - - fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) - - private fun initList(source: MangaSource, tags: Set?) { - val fm = supportFragmentManager - val existingFragment = fm.findFragmentById(R.id.container) - if (existingFragment is FilterOwner) { - initFilter(existingFragment) - } else { - fm.commit { - setReorderingAllowed(true) - val fragment = if (source == MangaSource.LOCAL) { - LocalListFragment.newInstance() - } else { - RemoteListFragment.newInstance(source) - } - replace(R.id.container, fragment) - runOnCommit { initFilter(fragment) } - if (!tags.isNullOrEmpty()) { - runOnCommit(ApplyFilterRunnable(fragment, tags)) - } - } - } - } - - private fun initFilter(filterOwner: FilterOwner) { - if (viewBinding.containerSide != null) { - if (supportFragmentManager.findFragmentById(R.id.container_side) == null) { - setSideFragment(FilterSheetFragment::class.java, null) - } - } else if (viewBinding.containerFilterHeader != null) { - if (supportFragmentManager.findFragmentById(R.id.container_filter_header) == null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - replace(R.id.container_filter_header, FilterHeaderFragment::class.java, null) - } - } - } - val filter = filterOwner.filter - val chipSort = viewBinding.buttonOrder - if (chipSort != null) { - val filterBadge = ViewBadge(chipSort, this) - filterBadge.setMaxCharacterCount(0) - filter.header.observe(this) { - chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) - filterBadge.counter = if (it.isFilterApplied) 1 else 0 - } - } else { - filter.header.map { - it.textSummary - }.flowOn(Dispatchers.Default) - .observe(this) { - supportActionBar?.subtitle = it - } - } - } - - private fun findFilterOwner(): FilterOwner? { - return supportFragmentManager.findFragmentById(R.id.container) as? FilterOwner - } - - private fun setSideFragment(cls: Class, args: Bundle?) = if (viewBinding.containerSide != null) { - supportFragmentManager.commit { - setReorderingAllowed(true) - replace(R.id.container_side, cls, args) - } - true - } else { - false - } - - private class ApplyFilterRunnable( - private val filterOwner: FilterOwner, - private val tags: Set, - ) : Runnable { - - override fun run() { - filterOwner.filter.applyFilter(tags) - } - } - - companion object { - - private const val EXTRA_TAGS = "tags" - private const val EXTRA_SOURCE = "source" - const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" - - fun newIntent(context: Context, tags: Set) = Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_TAGS, ParcelableMangaTags(tags)) - - fun newIntent(context: Context, source: MangaSource) = Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_SOURCE, source.name) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt deleted file mode 100644 index 6e8b9683a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.search.ui - -import android.view.Menu -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.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.MangaSource - -@AndroidEntryPoint -class SearchFragment : MangaListFragment() { - - override val viewModel by viewModels() - - override fun onScrolledToEnd() { - viewModel.loadNextPage() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - companion object { - - const val ARG_QUERY = "query" - const val ARG_SOURCE = "source" - - fun newInstance(source: MangaSource, query: String) = SearchFragment().withArgs(2) { - putSerializable(ARG_SOURCE, source) - putString(ARG_QUERY, query) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt deleted file mode 100644 index 48f88d3b5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ /dev/null @@ -1,188 +0,0 @@ -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.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -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.BaseActivity -import org.koitharu.kotatsu.core.ui.list.ListSelectionController -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ShareHelper -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.ActivitySearchMultiBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet -import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration -import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver -import org.koitharu.kotatsu.parsers.model.Manga -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.SearchActivity -import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter -import javax.inject.Inject - -@AndroidEntryPoint -class MultiSearchActivity : - BaseActivity(), - MangaListListener, - ListSelectionController.Callback2 { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - private val viewModel by viewModels() - private lateinit var adapter: MultiSearchAdapter - private lateinit var selectionController: ListSelectionController - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivitySearchMultiBinding.inflate(layoutInflater)) - title = viewModel.query - - val itemCLickListener = OnListItemClickListener { item, view -> - startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query)) - } - val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) - val selectionDecoration = MangaSelectionDecoration(this) - selectionController = ListSelectionController( - activity = this, - decoration = selectionDecoration, - registryOwner = this, - callback = this, - ) - adapter = MultiSearchAdapter( - lifecycleOwner = this, - coil = coil, - listener = this, - itemClickListener = itemCLickListener, - sizeResolver = sizeResolver, - selectionDecoration = selectionDecoration, - ) - viewBinding.recyclerView.adapter = adapter - viewBinding.recyclerView.setHasFixedSize(true) - viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, true)) - - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setSubtitle(R.string.search_results) - } - - viewModel.list.observe(this) { adapter.items = it } - viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.recyclerView)) - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, - ) - } - - override fun onItemClick(item: Manga, view: View) { - if (!selectionController.onItemClick(item.id)) { - val intent = DetailsActivity.newIntent(this, item) - startActivity(intent) - } - } - - override fun onItemLongClick(item: Manga, view: View): Boolean { - return selectionController.onItemLongClick(item.id) - } - - override fun onReadClick(manga: Manga, view: View) { - if (!selectionController.onItemClick(manga.id)) { - val intent = IntentBuilder(this).manga(manga).build() - startActivity(intent) - } - } - - override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { - if (!selectionController.onItemClick(manga.id)) { - val intent = MangaListActivity.newIntent(this, setOf(tag)) - startActivity(intent) - } - } - - override fun onRetryClick(error: Throwable) { - viewModel.retry() - } - - override fun onUpdateFilter(tags: Set) = Unit - - override fun onFilterClick(view: View?) = Unit - - override fun onEmptyActionClick() = Unit - - override fun onListHeaderClick(item: ListHeader, view: View) = Unit - - override fun onSelectionChanged(controller: ListSelectionController, count: Int) { - viewBinding.recyclerView.invalidateNestedItemDecorations() - } - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_remote, menu) - return true - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_share -> { - ShareHelper(this).shareMangaLinks(collectSelectedItems()) - mode.finish() - true - } - - R.id.action_favourite -> { - FavoriteSheet.show(supportFragmentManager, collectSelectedItems()) - mode.finish() - true - } - - R.id.action_save -> { - viewModel.download(collectSelectedItems()) - mode.finish() - true - } - - else -> false - } - } - - private fun collectSelectedItems(): Set { - return viewModel.getItems(selectionController.peekCheckedIds()) - } - - companion object { - - const val EXTRA_QUERY = "query" - - fun newIntent(context: Context, query: String) = - Intent(context, MultiSearchActivity::class.java) - .putExtra(EXTRA_QUERY, query) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt deleted file mode 100644 index 7d6f802fc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.koitharu.kotatsu.search.ui.multi - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaItemModel -import org.koitharu.kotatsu.parsers.model.MangaSource - -data class MultiSearchListModel( - val source: MangaSource, - val hasMore: Boolean, - val list: List, - val error: Throwable?, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MultiSearchListModel && source == other.source - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is MultiSearchListModel && previousState.list != list) { - ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt deleted file mode 100644 index fdf1e1b99..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.koitharu.kotatsu.search.ui.multi - -import androidx.annotation.CheckResult -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.runningFold -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.list.domain.ListExtraProvider -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.toUi -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import javax.inject.Inject - -private const val MAX_PARALLELISM = 4 -private const val MIN_HAS_MORE_ITEMS = 8 - -@HiltViewModel -class MultiSearchViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val extraProvider: ListExtraProvider, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val downloadScheduler: DownloadWorker.Scheduler, - private val sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - val onDownloadStarted = MutableEventFlow() - val query = savedStateHandle.get(MultiSearchActivity.EXTRA_QUERY).orEmpty() - - private val retryCounter = MutableStateFlow(0) - private val listData = retryCounter.flatMapLatest { - searchImpl(query).withLoading().withErrorHandling() - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - - val list: StateFlow> = combine( - listData.filterNotNull(), - isLoading, - ) { list, loading -> - when { - list.isEmpty() -> listOf( - when { - loading -> LoadingState - else -> EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_search_holder_secondary, - actionStringRes = 0, - ) - }, - ) - - loading -> list + LoadingFooter() - else -> list - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - fun getItems(ids: Set): Set { - val snapshot = listData.value ?: return emptySet() - val result = HashSet(ids.size) - snapshot.forEach { x -> - for (item in x.list) { - if (item.id in ids) { - result.add(item.manga) - } - } - } - return result - } - - fun retry() { - retryCounter.value += 1 - } - - fun download(items: Set) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule(items) - onDownloadStarted.call(Unit) - } - } - - @CheckResult - private fun searchImpl(q: String): Flow> = channelFlow { - val sources = sourcesRepository.getEnabledSources() - if (sources.isEmpty()) { - return@channelFlow - } - val semaphore = Semaphore(MAX_PARALLELISM) - for (source in sources) { - launch { - val item = runCatchingCancellable { - semaphore.withPermit { - mangaRepositoryFactory.create(source).getList(offset = 0, filter = MangaListFilter.Search(q)) - .toUi(ListMode.GRID, extraProvider) - } - }.fold( - onSuccess = { list -> - if (list.isEmpty()) { - null - } else { - MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list, null) - } - }, - onFailure = { error -> - error.printStackTraceDebug() - MultiSearchListModel(source, true, emptyList(), error) - }, - ) - if (item != null) { - send(item) - } - } - } - }.runningFold?>(null) { list, item -> list.orEmpty() + item } - .filterNotNull() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt deleted file mode 100644 index 607c525ee..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.koitharu.kotatsu.search.ui.multi.adapter - -import androidx.lifecycle.LifecycleOwner -import androidx.recyclerview.widget.RecyclerView.RecycledViewPool -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD -import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD -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.list.ui.size.ItemSizeResolver -import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel - -class MultiSearchAdapter( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - listener: MangaListListener, - itemClickListener: OnListItemClickListener, - sizeResolver: ItemSizeResolver, - selectionDecoration: MangaSelectionDecoration, -) : BaseListAdapter() { - - init { - val pool = RecycledViewPool() - addDelegate( - ListItemType.MANGA_NESTED_GROUP, - searchResultsAD( - sharedPool = pool, - lifecycleOwner = lifecycleOwner, - coil = coil, - sizeResolver = sizeResolver, - selectionDecoration = selectionDecoration, - listener = listener, - itemClickListener = itemClickListener, - ), - ) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt deleted file mode 100644 index 12d58adf9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionMenuProvider.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion - -import android.content.Context -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.activity.result.ActivityResultLauncher -import androidx.core.view.MenuProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.resolve -import org.koitharu.kotatsu.core.util.ext.tryLaunch -import com.google.android.material.R as materialR - -class SearchSuggestionMenuProvider( - private val context: Context, - private val voiceInputLauncher: ActivityResultLauncher, - private val viewModel: SearchSuggestionViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_search_suggestion, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_clear -> { - clearSearchHistory() - true - } - - R.id.action_voice_search -> { - voiceInputLauncher.tryLaunch(context.getString(R.string.search_manga), null) - } - - else -> false - } - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - menu.findItem(R.id.action_voice_search)?.isVisible = voiceInputLauncher.resolve(context, null) != null - } - - private fun clearSearchHistory() { - MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered) - .setTitle(R.string.clear_search_history) - .setIcon(R.drawable.ic_clear_all) - .setMessage(R.string.text_clear_search_history_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearSearchHistory() - }.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt deleted file mode 100644 index 8596fbd74..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.plus -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.widgets.ChipsView -import org.koitharu.kotatsu.core.util.ext.toEnumSet -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem -import javax.inject.Inject - -private const val DEBOUNCE_TIMEOUT = 500L -private const val MAX_MANGA_ITEMS = 6 -private const val MAX_QUERY_ITEMS = 16 -private const val MAX_HINTS_ITEMS = 3 -private const val MAX_TAGS_ITEMS = 8 -private const val MAX_SOURCES_ITEMS = 6 - -@HiltViewModel -class SearchSuggestionViewModel @Inject constructor( - private val repository: MangaSearchRepository, - private val settings: AppSettings, - private val sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - private val query = MutableStateFlow("") - private var suggestionJob: Job? = null - private var invalidateOnResume = false - - val isIncognitoModeEnabled = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_INCOGNITO_MODE, - valueProducer = { isIncognitoModeEnabled }, - ) - - val suggestion = MutableStateFlow>(emptyList()) - - init { - setupSuggestion() - } - - fun onQueryChanged(newQuery: String) { - query.value = newQuery - } - - fun saveQuery(query: String) { - if (!settings.isIncognitoModeEnabled) { - repository.saveSearchQuery(query) - } - invalidateOnResume = true - } - - fun clearSearchHistory() { - launchJob { - repository.clearSearchHistory() - setupSuggestion() - } - } - - fun onSourceToggle(source: MangaSource, isEnabled: Boolean) { - launchJob(Dispatchers.Default) { - sourcesRepository.setSourceEnabled(source, isEnabled) - } - } - - fun onResume() { - if (invalidateOnResume) { - invalidateOnResume = false - setupSuggestion() - } - } - - fun deleteQuery(query: String) { - launchJob { - repository.deleteSearchQuery(query) - setupSuggestion() - } - } - - private fun setupSuggestion() { - suggestionJob?.cancel() - suggestionJob = combine( - query.debounce(DEBOUNCE_TIMEOUT), - sourcesRepository.observeEnabledSources().map { it.toEnumSet() }, - ::Pair, - ).mapLatest { (searchQuery, enabledSources) -> - buildSearchSuggestion(searchQuery, enabledSources) - }.distinctUntilChanged() - .onEach { - suggestion.value = it - }.launchIn(viewModelScope + Dispatchers.Default) - } - - private suspend fun buildSearchSuggestion( - searchQuery: String, - enabledSources: Set, - ): List = coroutineScope { - val queriesDeferred = async { - repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) - } - val hintsDeferred = async { - repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) - } - val tagsDeferred = async { - repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) - } - val mangaDeferred = async { - repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) - } - val sources = repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS) - - val tags = tagsDeferred.await() - val mangaList = mangaDeferred.await() - val queries = queriesDeferred.await() - val hints = hintsDeferred.await() - - buildList(queries.size + sources.size + hints.size + 2) { - if (tags.isNotEmpty()) { - add(SearchSuggestionItem.Tags(mapTags(tags))) - } - if (mangaList.isNotEmpty()) { - add(SearchSuggestionItem.MangaList(mangaList)) - } - sources.mapTo(this) { SearchSuggestionItem.Source(it, it in enabledSources) } - queries.mapTo(this) { SearchSuggestionItem.RecentQuery(it) } - hints.mapTo(this) { SearchSuggestionItem.Hint(it) } - } - } - - private fun mapTags(tags: List): List = tags.map { tag -> - ChipsView.ChipModel( - tint = 0, - title = tag.title, - icon = 0, - data = tag, - isCheckable = false, - isChecked = false, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt deleted file mode 100644 index 3140500b2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAdapter.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener -import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem - -const val SEARCH_SUGGESTION_ITEM_TYPE_QUERY = 0 - -class SearchSuggestionAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: SearchSuggestionListener, -) : BaseListAdapter() { - - init { - delegatesManager - .addDelegate(SEARCH_SUGGESTION_ITEM_TYPE_QUERY, searchSuggestionQueryAD(listener)) - .addDelegate(searchSuggestionSourceAD(coil, lifecycleOwner, listener)) - .addDelegate(searchSuggestionTagsAD(listener)) - .addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener)) - .addDelegate(searchSuggestionQueryHintAD(listener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt deleted file mode 100644 index ede12ed52..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion.adapter - -import android.view.View -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding -import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener -import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem - -fun searchSuggestionQueryHintAD( - listener: SearchSuggestionListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemSearchSuggestionQueryHintBinding.inflate(inflater, parent, false) }, -) { - - val viewClickListener = View.OnClickListener { _ -> - listener.onQueryClick(item.query, true) - } - - binding.root.setOnClickListener(viewClickListener) - - bind { - binding.root.text = item.query - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt deleted file mode 100644 index 9029d7ef4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.getSummary -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.parser.favicon.faviconUri -import org.koitharu.kotatsu.core.ui.image.FaviconDrawable -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding -import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener -import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem - -fun searchSuggestionSourceAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: SearchSuggestionListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemSearchSuggestionSourceBinding.inflate(inflater, parent, false) }, -) { - - binding.switchLocal.setOnCheckedChangeListener { _, isChecked -> - listener.onSourceToggle(item.source, isChecked) - } - binding.root.setOnClickListener { - listener.onSourceClick(item.source) - } - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - binding.textViewSubtitle.text = item.source.getSummary(context) - binding.switchLocal.isChecked = item.isEnabled - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewCover.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - fallback(fallbackIcon) - placeholder(fallbackIcon) - error(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt deleted file mode 100644 index 633592471..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion.adapter - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.databinding.ItemSearchSuggestionTagsBinding -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener -import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem - -fun searchSuggestionTagsAD( - listener: SearchSuggestionListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemSearchSuggestionTagsBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.chipsGenres.onChipClickListener = ChipsView.OnChipClickListener { _, data -> - listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener) - } - - bind { - binding.chipsGenres.setChips(item.tags) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt deleted file mode 100644 index f7b83531c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion.model - -import org.koitharu.kotatsu.core.ui.widgets.ChipsView -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaSource - -sealed interface SearchSuggestionItem : ListModel { - - data class MangaList( - val items: List, - ) : SearchSuggestionItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaList - } - } - - data class RecentQuery( - val query: String, - ) : SearchSuggestionItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is RecentQuery && query == other.query - } - } - - data class Hint( - val query: String, - ) : SearchSuggestionItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Hint && query == other.query - } - } - - data class Source( - val source: MangaSource, - val isEnabled: Boolean, - ) : SearchSuggestionItem { - - val isNsfw: Boolean - get() = source.contentType == ContentType.HENTAI - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Source && other.source == source - } - - override fun getChangePayload(previousState: ListModel): Any? { - if (previousState !is Source) { - return super.getChangePayload(previousState) - } - return if (isEnabled != previousState.isEnabled) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - null - } - } - } - - data class Tags( - val tags: List, - ) : SearchSuggestionItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Tags - } - - override fun getChangePayload(previousState: ListModel): Any { - return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt deleted file mode 100644 index 02c1c0c2d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.content.Intent -import android.content.SharedPreferences -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.Settings -import android.view.View -import androidx.appcompat.app.AppCompatDelegate -import androidx.preference.ListPreference -import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle -import org.koitharu.kotatsu.core.util.LocaleComparator -import org.koitharu.kotatsu.core.util.ext.getLocalesConfig -import org.koitharu.kotatsu.core.util.ext.postDelayed -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat -import org.koitharu.kotatsu.core.util.ext.sortedWithSafe -import org.koitharu.kotatsu.core.util.ext.toList -import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.parsers.util.toTitleCase -import org.koitharu.kotatsu.settings.utils.ActivityListPreference -import org.koitharu.kotatsu.settings.utils.SliderPreference -import javax.inject.Inject - -@AndroidEntryPoint -class AppearanceSettingsFragment : - BasePreferenceFragment(R.string.appearance), - SharedPreferences.OnSharedPreferenceChangeListener { - - @Inject - lateinit var activityRecreationHandle: ActivityRecreationHandle - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_appearance) - findPreference(AppSettings.KEY_GRID_SIZE)?.run { - val pattern = context.getString(R.string.percent_string_pattern) - summary = pattern.format(value.toString()) - setOnPreferenceChangeListener { preference, newValue -> - preference.summary = pattern.format(newValue.toString()) - true - } - } - findPreference(AppSettings.KEY_LIST_MODE)?.run { - entryValues = ListMode.entries.names() - setDefaultValueCompat(ListMode.GRID.name) - } - findPreference(AppSettings.KEY_APP_LOCALE)?.run { - initLocalePicker(this) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activityIntent = Intent( - Settings.ACTION_APP_LOCALE_SETTINGS, - Uri.fromParts("package", context.packageName, null), - ) - } - summaryProvider = Preference.SummaryProvider { - val locale = AppCompatDelegate.getApplicationLocales().get(0) - locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic) - } - setDefaultValueCompat("") - } - bindNavSummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_THEME -> { - AppCompatDelegate.setDefaultNightMode(settings.theme) - } - - AppSettings.KEY_COLOR_THEME, - AppSettings.KEY_THEME_AMOLED, - -> { - postRestart() - } - - AppSettings.KEY_APP_LOCALE -> { - AppCompatDelegate.setApplicationLocales(settings.appLocales) - } - - AppSettings.KEY_NAV_MAIN -> { - bindNavSummary() - } - } - } - - private fun postRestart() { - viewLifecycleOwner.lifecycle.postDelayed(400) { - activityRecreationHandle.recreateAll() - } - } - - private fun initLocalePicker(preference: ListPreference) { - val locales = preference.context.getLocalesConfig() - .toList() - .sortedWithSafe(LocaleComparator()) - preference.entries = Array(locales.size + 1) { i -> - if (i == 0) { - getString(R.string.automatic) - } else { - val lc = locales[i - 1] - lc.getDisplayName(lc).toTitleCase(lc) - } - } - preference.entryValues = Array(locales.size + 1) { i -> - if (i == 0) { - "" - } else { - locales[i - 1].toLanguageTag() - } - } - } - - private fun bindNavSummary() { - val pref = findPreference(AppSettings.KEY_NAV_MAIN) ?: return - pref.summary = settings.mainNavItems.joinToString { - getString(it.title) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt deleted file mode 100644 index 5769cb73c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.preference.Preference -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.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -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.settings.storage.MangaDirectorySelectDialog -import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity -import org.koitharu.kotatsu.settings.utils.DozeHelper -import javax.inject.Inject - -@AndroidEntryPoint -class DownloadsSettingsFragment : - BasePreferenceFragment(R.string.downloads), - SharedPreferences.OnSharedPreferenceChangeListener { - - private val dozeHelper = DozeHelper(this) - - @Inject - lateinit var storageManager: LocalStorageManager - - @Inject - lateinit var downloadsScheduler: DownloadWorker.Scheduler - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_downloads) - dozeHelper.updatePreference() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName() - findPreference(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount() - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_LOCAL_STORAGE -> { - findPreference(key)?.bindStorageName() - } - - AppSettings.KEY_LOCAL_MANGA_DIRS -> { - findPreference(key)?.bindDirectoriesCount() - } - - AppSettings.KEY_DOWNLOADS_WIFI -> { - updateDownloadsConstraints() - } - } - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_LOCAL_STORAGE -> { - MangaDirectorySelectDialog.show(childFragmentManager) - true - } - - AppSettings.KEY_LOCAL_MANGA_DIRS -> { - startActivity(MangaDirectoriesActivity.newIntent(preference.context)) - true - } - - AppSettings.KEY_IGNORE_DOZE -> { - dozeHelper.startIgnoreDoseActivity() - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - private fun Preference.bindStorageName() { - viewLifecycleScope.launch { - val storage = storageManager.getDefaultWriteableDir() - summary = if (storage != null) { - storageManager.getDirectoryDisplayName(storage, isFullPath = true) - } else { - getString(R.string.not_available) - } - } - } - - private fun Preference.bindDirectoriesCount() { - viewLifecycleScope.launch { - val dirs = storageManager.getReadableDirs().size - summary = resources.getQuantityString(R.plurals.items, dirs, dirs) - } - } - - private fun updateDownloadsConstraints() { - val preference = findPreference(AppSettings.KEY_DOWNLOADS_WIFI) - viewLifecycleScope.launch { - try { - preference?.isEnabled = false - withContext(Dispatchers.Default) { - downloadsScheduler.updateConstraints() - } - } catch (e: Exception) { - e.printStackTraceDebug() - } finally { - preference?.isEnabled = true - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt deleted file mode 100644 index 72a5251d2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.preference.ListPreference -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.cache.ContentCache -import org.koitharu.kotatsu.core.network.DoHProvider -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat -import org.koitharu.kotatsu.parsers.util.names -import java.net.Proxy -import javax.inject.Inject - -@AndroidEntryPoint -class NetworkSettingsFragment : - BasePreferenceFragment(R.string.network), - SharedPreferences.OnSharedPreferenceChangeListener { - - @Inject - lateinit var contentCache: ContentCache - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_network) - findPreference(AppSettings.KEY_PREFETCH_CONTENT)?.isVisible = contentCache.isCachingEnabled - findPreference(AppSettings.KEY_DOH)?.run { - entryValues = DoHProvider.entries.names() - setDefaultValueCompat(DoHProvider.NONE.name) - } - bindProxySummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_SSL_BYPASS -> { - Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() - } - - AppSettings.KEY_PROXY_TYPE, - AppSettings.KEY_PROXY_ADDRESS, - AppSettings.KEY_PROXY_PORT -> { - bindProxySummary() - } - } - } - - private fun bindProxySummary() { - findPreference(AppSettings.KEY_PROXY)?.run { - val type = settings.proxyType - val address = settings.proxyAddress - val port = settings.proxyPort - summary = if (type == Proxy.Type.DIRECT || address.isNullOrEmpty() || port == 0) { - context.getString(R.string.disabled) - } else { - "$address:$port" - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt deleted file mode 100644 index 4125d46e2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import android.view.inputmethod.EditorInfo -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceCategory -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.settings.utils.EditTextBindListener -import org.koitharu.kotatsu.settings.utils.PasswordSummaryProvider -import org.koitharu.kotatsu.settings.utils.validation.DomainValidator -import org.koitharu.kotatsu.settings.utils.validation.PortNumberValidator -import java.net.Proxy - -@AndroidEntryPoint -class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), - SharedPreferences.OnSharedPreferenceChangeListener { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_proxy) - findPreference(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener( - EditTextBindListener( - inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, - hint = null, - validator = DomainValidator(), - ), - ) - findPreference(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener( - EditTextBindListener( - inputType = EditorInfo.TYPE_CLASS_NUMBER, - hint = null, - validator = PortNumberValidator(), - ), - ) - findPreference(AppSettings.KEY_PROXY_PASSWORD)?.let { pref -> - pref.setOnBindEditTextListener( - EditTextBindListener( - inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD, - hint = null, - validator = null, - ), - ) - pref.summaryProvider = PasswordSummaryProvider() - } - updateDependencies() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_PROXY_TYPE -> updateDependencies() - } - } - - private fun updateDependencies() { - val isProxyEnabled = settings.proxyType != Proxy.Type.DIRECT - findPreference(AppSettings.KEY_PROXY_ADDRESS)?.isEnabled = isProxyEnabled - findPreference(AppSettings.KEY_PROXY_PORT)?.isEnabled = isProxyEnabled - findPreference(AppSettings.KEY_PROXY_AUTH)?.isEnabled = isProxyEnabled - findPreference(AppSettings.KEY_PROXY_LOGIN)?.isEnabled = isProxyEnabled - findPreference(AppSettings.KEY_PROXY_PASSWORD)?.isEnabled = isProxyEnabled - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt deleted file mode 100644 index f095ec6c7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import android.view.View -import androidx.annotation.StringRes -import androidx.fragment.app.viewModels -import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.observe - -@AndroidEntryPoint -class RootSettingsFragment : BasePreferenceFragment(0) { - - private val viewModel: RootSettingsViewModel by viewModels() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_root) - bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language) - bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages) - bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content) - bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion) - bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only) - bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) - bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) - findPreference("about")?.summary = getString(R.string.app_version, BuildConfig.VERSION_NAME) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> - val total = viewModel.totalSourcesCount - viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { - pref.summary = if (it >= 0) { - getString(R.string.enabled_d_of_d, it, total) - } else { - resources.getQuantityString(R.plurals.items, total, total) - } - } - } - } - - private fun bindPreferenceSummary(key: String, @StringRes vararg items: Int) { - findPreference(key)?.summary = items.joinToString { getString(it) } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt deleted file mode 100644 index b997b8236..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsViewModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.settings - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import javax.inject.Inject - -@HiltViewModel -class RootSettingsViewModel @Inject constructor( - sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - val totalSourcesCount = sourcesRepository.allMangaSources.size - - val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt deleted file mode 100644 index fe549eca7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.accounts.AccountManager -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.SharedPreferences -import android.net.Uri -import android.os.Bundle -import android.view.View -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.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService -import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity -import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository -import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository -import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import javax.inject.Inject - -@AndroidEntryPoint -class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), - SharedPreferences.OnSharedPreferenceChangeListener { - - @Inject - lateinit var shikimoriRepository: ShikimoriRepository - - @Inject - lateinit var aniListRepository: AniListRepository - - @Inject - lateinit var malRepository: MALRepository - - @Inject - lateinit var syncController: SyncController - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_services) - bindSuggestionsSummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onResume() { - super.onResume() - bindScrobblerSummary(AppSettings.KEY_SHIKIMORI, shikimoriRepository) - bindScrobblerSummary(AppSettings.KEY_ANILIST, aniListRepository) - bindScrobblerSummary(AppSettings.KEY_MAL, malRepository) - bindSyncSummary() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary() - } - } - - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_SHIKIMORI -> { - if (!shikimoriRepository.isAuthorized) { - launchScrobblerAuth(shikimoriRepository) - } else { - startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.SHIKIMORI)) - } - true - } - - AppSettings.KEY_MAL -> { - if (!malRepository.isAuthorized) { - launchScrobblerAuth(malRepository) - } else { - startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.MAL)) - } - true - } - - AppSettings.KEY_ANILIST -> { - if (!aniListRepository.isAuthorized) { - launchScrobblerAuth(aniListRepository) - } else { - startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.ANILIST)) - } - true - } - - AppSettings.KEY_SYNC -> { - val am = AccountManager.get(requireContext()) - val accountType = getString(R.string.account_type_sync) - val account = am.getAccountsByType(accountType).firstOrNull() - if (account == null) { - am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) - } else { - startActivitySafe(SyncSettingsIntent(account)) - } - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - private fun bindScrobblerSummary( - key: String, - repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository - ) { - val pref = findPreference(key) ?: return - if (!repository.isAuthorized) { - pref.setSummary(R.string.disabled) - return - } - val username = repository.cachedUser?.nickname - if (username != null) { - pref.summary = getString(R.string.logged_in_as, username) - } else { - pref.setSummary(R.string.loading_) - viewLifecycleScope.launch { - pref.summary = withContext(Dispatchers.Default) { - runCatching { - val user = repository.loadUser() - getString(R.string.logged_in_as, user.nickname) - }.getOrElse { - it.printStackTraceDebug() - it.getDisplayMessage(resources) - } - } - } - } - } - - private fun launchScrobblerAuth(repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository) { - runCatching { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(repository.oauthUrl) - startActivity(intent) - }.onFailure { - Snackbar.make(listView, it.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() - } - } - - private fun bindSyncSummary() { - viewLifecycleScope.launch { - val account = withContext(Dispatchers.Default) { - val type = getString(R.string.account_type_sync) - AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull() - } - findPreference(AppSettings.KEY_SYNC)?.run { - summary = when { - account == null -> getString(R.string.sync_title) - syncController.isEnabled(account) -> account.name - else -> getString(R.string.disabled) - } - } - findPreference(AppSettings.KEY_SYNC_SETTINGS)?.isEnabled = account != null - } - } - - private fun bindSuggestionsSummary() { - findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary( - if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SyncSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SyncSettingsFragment.kt deleted file mode 100644 index 86ae24845..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SyncSettingsFragment.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.FragmentResultListener -import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.sync.data.SyncSettings -import org.koitharu.kotatsu.sync.ui.SyncHostDialogFragment -import javax.inject.Inject - -@AndroidEntryPoint -class SyncSettingsFragment : BasePreferenceFragment(R.string.sync_settings), FragmentResultListener { - - @Inject - lateinit var syncSettings: SyncSettings - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_sync) - bindHostSummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - childFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, viewLifecycleOwner, this) - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - SyncSettings.KEY_HOST -> { - SyncHostDialogFragment.show(childFragmentManager, null) - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - override fun onFragmentResult(requestKey: String, result: Bundle) { - bindHostSummary() - } - - private fun bindHostSummary() { - val preference = findPreference(SyncSettings.KEY_HOST) ?: return - preference.summary = syncSettings.host - } -} 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 deleted file mode 100644 index fb2abd9cc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.koitharu.kotatsu.settings.about - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.core.net.toUri -import androidx.fragment.app.viewModels -import androidx.preference.Preference -import androidx.preference.SwitchPreferenceCompat -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.BuildConfig -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.logs.FileLogger -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.settings.SettingsActivity -import javax.inject.Inject - -@AndroidEntryPoint -class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { - - private val viewModel by viewModels() - - @Inject - lateinit var loggers: Set<@JvmSuppressWildcards FileLogger> - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_about) - findPreference(AppSettings.KEY_APP_VERSION)?.run { - title = getString(R.string.app_version, BuildConfig.VERSION_NAME) - isEnabled = viewModel.isUpdateSupported - } - findPreference(AppSettings.KEY_UPDATES_UNSTABLE)?.run { - isEnabled = VersionId(BuildConfig.VERSION_NAME).isStable - if (!isEnabled) isChecked = true - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.isLoading.observe(viewLifecycleOwner) { - findPreference(AppSettings.KEY_APP_UPDATE)?.isEnabled = !it - } - viewModel.onUpdateAvailable.observeEvent(viewLifecycleOwner, ::onUpdateAvailable) - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_APP_VERSION -> { - viewModel.checkForUpdates() - true - } - - AppSettings.KEY_APP_TRANSLATION -> { - openLink(getString(R.string.url_weblate), preference.title) - true - } - - AppSettings.KEY_LOGS_SHARE -> { - ShareHelper(preference.context).shareLogs(loggers) - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - private fun onUpdateAvailable(version: AppVersion?) { - if (version == null) { - Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show() - return - } - (activity as SettingsActivity).appUpdateDialog.show(version) - } - - private fun openLink(url: String, title: CharSequence?) { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() - startActivitySafe( - if (title != null) { - Intent.createChooser(intent, title) - } else { - intent - }, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt deleted file mode 100644 index 638ce1e8e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.settings.about - -import dagger.hilt.android.lifecycle.HiltViewModel -import org.koitharu.kotatsu.core.github.AppUpdateRepository -import org.koitharu.kotatsu.core.github.AppVersion -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import javax.inject.Inject - -@HiltViewModel -class AboutSettingsViewModel @Inject constructor( - private val appUpdateRepository: AppUpdateRepository, -) : BaseViewModel() { - - val isUpdateSupported = appUpdateRepository.isUpdateSupported() - val onUpdateAvailable = MutableEventFlow() - - fun checkForUpdates() { - launchLoadingJob { - val update = appUpdateRepository.fetchUpdate() - onUpdateAvailable.call(update) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt deleted file mode 100644 index cc0587718..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateDialog.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.koitharu.kotatsu.settings.about - -import android.Manifest -import android.app.DownloadManager -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Environment -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.net.toUri -import androidx.core.text.buildSpannedString -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.noties.markwon.Markwon -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.github.AppVersion -import org.koitharu.kotatsu.core.util.FileSize -import com.google.android.material.R as materialR - -class AppUpdateDialog(private val activity: AppCompatActivity) { - - private lateinit var latestVersion: AppVersion - - private val permissionRequest = activity.registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { - if (it) { - downloadUpdateImpl() - } else { - openInBrowser() - } - } - - fun show(version: AppVersion) { - latestVersion = version - val message = buildSpannedString { - append(activity.getString(R.string.new_version_s, version.name)) - appendLine() - append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize))) - appendLine() - appendLine() - append(Markwon.create(activity).toMarkdown(version.description)) - } - MaterialAlertDialogBuilder( - activity, - materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered, - ) - .setTitle(R.string.app_update_available) - .setMessage(message) - .setIcon(R.drawable.ic_app_update) - .setNeutralButton(R.string.open_in_browser) { _, _ -> - val intent = Intent(Intent.ACTION_VIEW, version.url.toUri()) - activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser))) - }.setPositiveButton(R.string.update) { _, _ -> - downloadUpdate() - }.setNegativeButton(android.R.string.cancel, null) - .setCancelable(false) - .create() - .show() - } - - private fun downloadUpdate() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { - downloadUpdateImpl() - } - } - - private fun downloadUpdateImpl() { - val version = latestVersion - val url = version.apkUrl.toUri() - val dm = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(url) - .setTitle("${activity.getString(R.string.app_name)} v${version.name}") - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setMimeType("application/vnd.android.package-archive") - dm.enqueue(request) - Toast.makeText(activity, R.string.download_started, Toast.LENGTH_SHORT).show() - } - - private fun openInBrowser() { - val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri()) - activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser))) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt deleted file mode 100644 index bb2b8e9c9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/UpdateDownloadReceiver.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.settings.about - -import android.app.DownloadManager -import android.content.ActivityNotFoundException -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - - -class UpdateDownloadReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - DownloadManager.ACTION_DOWNLOAD_COMPLETE -> { - val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) - if (downloadId == 0L) { - return - } - val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - - @Suppress("DEPRECATION") - val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) - installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) - installIntent.setDataAndType( - dm.getUriForDownloadedFile(downloadId), - dm.getMimeTypeForDownloadedFile(downloadId), - ) - installIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - try { - context.startActivity(installIntent) - } catch (e: ActivityNotFoundException) { - e.printStackTraceDebug() - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt deleted file mode 100644 index 44ba6a831..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.setChecked -import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED -import org.koitharu.kotatsu.list.ui.adapter.ListItemType - -class BackupEntriesAdapter( - clickListener: OnListItemClickListener, -) : BaseListAdapter() { - - init { - addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener)) - } -} - -private fun backupEntryAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener { v -> - clickListener.onItemClick(item, v) - } - - bind { payloads -> - with(binding.root) { - setText(item.titleResId) - setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads) - isEnabled = item.isEnabled - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt deleted file mode 100644 index 632807dec..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import androidx.annotation.StringRes -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class BackupEntryModel( - val name: BackupEntry.Name, - val isChecked: Boolean, - val isEnabled: Boolean, -) : ListModel { - - @get:StringRes - val titleResId: Int - get() = when (name) { - BackupEntry.Name.INDEX -> 0 // should not appear here - BackupEntry.Name.HISTORY -> R.string.history - BackupEntry.Name.CATEGORIES -> R.string.favourites_categories - BackupEntry.Name.FAVOURITES -> R.string.favourites - BackupEntry.Name.SETTINGS -> R.string.settings - BackupEntry.Name.BOOKMARKS -> R.string.bookmarks - BackupEntry.Name.SOURCES -> R.string.remote_sources - } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is BackupEntryModel && other.name == name - } - - override fun getChangePayload(previousState: ListModel): Any? { - if (previousState !is BackupEntryModel) { - return null - } - return if (previousState.isEnabled != isEnabled) { - ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED - } else if (previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt deleted file mode 100644 index 654e1656b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.Context -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipOutput -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class BackupViewModel @Inject constructor( - private val repository: BackupRepository, - @ApplicationContext context: Context, -) : BaseViewModel() { - - val progress = MutableStateFlow(-1f) - val onBackupDone = MutableEventFlow() - - init { - launchLoadingJob { - val file = BackupZipOutput(context).use { backup -> - val step = 1f / 6f - backup.put(repository.createIndex()) - - progress.value = 0f - backup.put(repository.dumpHistory()) - - progress.value += step - backup.put(repository.dumpCategories()) - - progress.value += step - backup.put(repository.dumpFavourites()) - - progress.value += step - backup.put(repository.dumpBookmarks()) - - progress.value += step - backup.put(repository.dumpSources()) - - progress.value += step - backup.put(repository.dumpSettings()) - - backup.finish() - progress.value = 1f - backup.file - } - onBackupDone.call(file) - } - } -} 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 deleted file mode 100644 index 3e5dfd316..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile -import androidx.preference.Preference -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.DIR_BACKUPS -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.tryLaunch -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import java.io.File -import java.text.SimpleDateFormat -import javax.inject.Inject - -@AndroidEntryPoint -class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups), - ActivityResultCallback { - - @Inject - lateinit var scheduler: PeriodicalBackupWorker.Scheduler - - private val outputSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - this, - ) - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_backup_periodic) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - bindOutputSummary() - bindLastBackupInfo() - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) - else -> super.onPreferenceTreeClick(preference) - } - } - - override fun onActivityResult(result: Uri?) { - if (result != null) { - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context?.contentResolver?.takePersistableUriPermission(result, takeFlags) - settings.periodicalBackupOutput = result - bindOutputSummary() - } - } - - private fun bindOutputSummary() { - val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return - viewLifecycleScope.launch { - preference.summary = withContext(Dispatchers.Default) { - val value = settings.periodicalBackupOutput - value?.toUserFriendlyString(preference.context) ?: preference.context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - }.path - } - } - } - - private fun bindLastBackupInfo() { - val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return - viewLifecycleScope.launch { - val lastDate = withContext(Dispatchers.Default) { - scheduler.getLastSuccessfulBackup() - } - preference.summary = lastDate?.let { - val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT) - preference.context.getString(R.string.last_successful_backup, format.format(it)) - } - 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) - } - return resolveFile(context)?.path ?: toString() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt deleted file mode 100644 index 224df7b7b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ /dev/null @@ -1,112 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.Context -import android.os.Build -import androidx.documentfile.provider.DocumentFile -import androidx.hilt.work.HiltWorker -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.await -import androidx.work.workDataOf -import dagger.Reusable -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipOutput -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName -import org.koitharu.kotatsu.core.util.ext.deleteAwait -import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler -import java.util.Date -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@HiltWorker -class PeriodicalBackupWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val repository: BackupRepository, - private val settings: AppSettings, -) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result { - val resultData = workDataOf(DATA_TIMESTAMP to Date().time) - val file = BackupZipOutput(applicationContext).use { backup -> - backup.put(repository.createIndex()) - backup.put(repository.dumpHistory()) - backup.put(repository.dumpCategories()) - backup.put(repository.dumpFavourites()) - backup.put(repository.dumpBookmarks()) - backup.put(repository.dumpSources()) - backup.put(repository.dumpSettings()) - backup.finish() - backup.file - } - val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData) - val target = DocumentFile.fromTreeUri(applicationContext, dirUri) - ?.createFile("application/zip", file.nameWithoutExtension) - ?.uri ?: return Result.failure() - applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output -> - file.inputStream().copyTo(output) - } ?: return Result.failure() - file.deleteAwait() - return Result.success(resultData) - } - - @Reusable - class Scheduler @Inject constructor( - private val workManager: WorkManager, - private val settings: AppSettings, - ) : PeriodicWorkScheduler { - - override suspend fun schedule() { - val constraints = Constraints.Builder() - .setRequiresStorageNotLow(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - constraints.setRequiresDeviceIdle(true) - } - val request = PeriodicWorkRequestBuilder( - settings.periodicalBackupFrequency, - TimeUnit.DAYS, - ).setConstraints(constraints.build()) - .keepResultsForAtLeast(20, TimeUnit.DAYS) - .addTag(TAG) - .build() - workManager - .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) - .await() - } - - override suspend fun unschedule() { - workManager - .cancelUniqueWork(TAG) - .await() - } - - override suspend fun isScheduled(): Boolean { - return workManager - .awaitUniqueWorkInfoByName(TAG) - .any { !it.state.isFinished } - } - - suspend fun getLastSuccessfulBackup(): Date? { - return workManager - .awaitUniqueWorkInfoByName(TAG) - .lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED } - ?.outputData - ?.getLong(DATA_TIMESTAMP, 0) - ?.let { if (it != 0L) Date(it) else null } - } - } - - private companion object { - - const val TAG = "backups" - const val DATA_TIMESTAMP = "ts" - } -} 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 deleted file mode 100644 index d1515e841..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ /dev/null @@ -1,162 +0,0 @@ -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 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.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, - View.OnClickListener { - - private val viewModel: RestoreViewModel by viewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = DialogRestoreBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = BackupEntriesAdapter(this) - binding.recyclerView.adapter = adapter - 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, - viewModel.availableEntries, - viewModel.backupDate, - ::Triple, - ).observe(viewLifecycleOwner, this::onLoadingChanged) - } - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setCancelable(false) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_cancel -> dismiss() - R.id.button_restore -> viewModel.restore() - } - } - - override fun onItemClick(item: BackupEntryModel, view: View) { - viewModel.onItemClick(item) - } - - private fun onLoadingChanged(value: Triple, Date?>) { - val (isLoading, entries, backupDate) = value - val hasEntries = entries.isNotEmpty() - with(requireViewBinding()) { - progressBar.isVisible = isLoading - recyclerView.isGone = isLoading - textViewSubtitle.textAndVisible = - when { - !isLoading -> backupDate?.formatBackupDate() - hasEntries -> getString(R.string.processing_) - else -> getString(R.string.loading_) - } - buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked } - } - } - - private fun Date.formatBackupDate(): String { - return getString( - R.string.backup_date_, - SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this), - ) - } - - private fun onError(e: Throwable) { - MaterialAlertDialogBuilder(context ?: return) - .setNegativeButton(R.string.close, null) - .setTitle(R.string.error) - .setMessage(e.getDisplayMessage(resources)) - .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/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt deleted file mode 100644 index d1497f696..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ /dev/null @@ -1,157 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.Context -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -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.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.toUriOrNull -import org.koitharu.kotatsu.parsers.util.SuspendLazy -import java.io.File -import java.io.FileNotFoundException -import java.util.Date -import java.util.EnumMap -import java.util.EnumSet -import javax.inject.Inject - -@HiltViewModel -class RestoreViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val repository: BackupRepository, - @ApplicationContext context: Context, -) : BaseViewModel() { - - private val backupInput = SuspendLazy { - val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE) - ?.toUriOrNull() ?: throw FileNotFoundException() - val contentResolver = context.contentResolver - runInterruptible(Dispatchers.IO) { - val tempFile = File.createTempFile("backup_", ".tmp") - (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } - BackupZipInput(tempFile) - } - } - - val progress = MutableStateFlow(-1f) - val onRestoreDone = MutableEventFlow() - - val availableEntries = MutableStateFlow>(emptyList()) - val backupDate = MutableStateFlow(null) - - init { - launchLoadingJob(Dispatchers.Default) { - val backup = backupInput.get() - val entries = backup.entries() - availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> - if (entry == BackupEntry.Name.INDEX || entry !in entries) { - return@mapNotNull null - } - BackupEntryModel( - name = entry, - isChecked = true, - isEnabled = true, - ) - } - backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX)) - } - } - - override fun onCleared() { - super.onCleared() - backupInput.peek()?.cleanupAsync() - } - - fun onItemClick(item: BackupEntryModel) { - val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } - map[item.name] = item.copy(isChecked = !item.isChecked) - map.validate() - availableEntries.value = map.values.sortedBy { it.name.ordinal } - } - - fun restore() { - launchLoadingJob { - val backup = backupInput.get() - val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { - if (it.isChecked) it.name else null - } - val result = CompositeResult() - val step = 1f / 6f - - progress.value = 0f - if (BackupEntry.Name.HISTORY in checkedItems) { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { - result += repository.restoreHistory(it) - } - } - - 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) - } - } - - /** - * Check for inconsistent user selection - * Favorites cannot be restored without categories - */ - private fun MutableMap.validate() { - val favorites = this[BackupEntry.Name.FAVOURITES] ?: return - val categories = this[BackupEntry.Name.CATEGORIES] - if (categories?.isChecked == true) { - if (!favorites.isEnabled) { - this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true) - } - } else { - if (favorites.isEnabled) { - this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt deleted file mode 100644 index ec9bca2d3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.koitharu.kotatsu.settings.nav - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.NavItem -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.settings.nav.adapter.navAddAD -import org.koitharu.kotatsu.settings.nav.adapter.navAvailableAD -import org.koitharu.kotatsu.settings.nav.adapter.navConfigAD - -@AndroidEntryPoint -class NavConfigFragment : BaseFragment(), RecyclerViewOwner, - OnListItemClickListener, View.OnClickListener { - - private var reorderHelper: ItemTouchHelper? = null - private val viewModel by viewModels() - - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ): FragmentSettingsSourcesBinding { - return FragmentSettingsSourcesBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated( - binding: FragmentSettingsSourcesBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - val navConfigAdapter = BaseListAdapter() - .addDelegate(ListItemType.NAV_ITEM, navConfigAD(this)) - .addDelegate(ListItemType.FOOTER_LOADING, navAddAD(this)) - with(binding.recyclerView) { - setHasFixedSize(true) - adapter = navConfigAdapter - reorderHelper = ItemTouchHelper(ReorderCallback()).also { - it.attachToRecyclerView(this) - } - } - viewModel.content.observe(viewLifecycleOwner, navConfigAdapter) - } - - override fun onResume() { - super.onResume() - activity?.setTitle(R.string.main_screen_sections) - } - - override fun onDestroyView() { - reorderHelper = null - super.onDestroyView() - } - - override fun onWindowInsetsChanged(insets: Insets) { - requireViewBinding().recyclerView.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - } - - override fun onClick(v: View) { - var dialog: DialogInterface? = null - val listener = OnListItemClickListener { item, _ -> - viewModel.addItem(item) - dialog?.dismiss() - } - dialog = RecyclerViewAlertDialog.Builder(v.context) - .setTitle(R.string.add) - .addAdapterDelegate(navAvailableAD(listener)) - .setCancelable(true) - .setItems(viewModel.availableItems) - .setNegativeButton(android.R.string.cancel, null) - .create() - .apply { show() } - } - - override fun onItemClick(item: NavItem, view: View) { - viewModel.removeItem(item) - } - - override fun onItemLongClick(item: NavItem, view: View): Boolean { - val holder = viewBinding?.recyclerView?.findContainingViewHolder(view) ?: return false - reorderHelper?.startDrag(holder) - return true - } - - private inner class ReorderCallback : ItemTouchHelper.SimpleCallback( - ItemTouchHelper.DOWN or ItemTouchHelper.UP, - 0, - ) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean = target.itemViewType == ListItemType.NAV_ITEM.ordinal - - override fun onMoved( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - fromPos: Int, - target: RecyclerView.ViewHolder, - toPos: Int, - x: Int, - y: Int, - ) { - super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) - viewModel.reorder(fromPos, toPos) - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - - override fun isLongPressDragEnabled() = false - } -} 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 deleted file mode 100644 index c9a88a6ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.koitharu.kotatsu.settings.nav - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.NavItem -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.parsers.util.move -import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel -import javax.inject.Inject - -@HiltViewModel -class NavConfigViewModel @Inject constructor( - private val settings: AppSettings, - private val activityRecreationHandle: ActivityRecreationHandle, -) : BaseViewModel() { - - private val items = MutableStateFlow(settings.mainNavItems) - - val content: StateFlow> = items.map { snapshot -> - if (snapshot.size < NavItem.entries.size) { - snapshot + NavItemAddModel(snapshot.size < 5) - } else { - snapshot - } - }.stateIn( - viewModelScope + Dispatchers.Default, - SharingStarted.WhileSubscribed(5000), - emptyList() - ) - - private var commitJob: Job? = null - - val availableItems - get() = items.value.let { snapshot -> - NavItem.entries.filterNot { x -> x in snapshot } - } - - fun reorder(fromPos: Int, toPos: Int) { - items.value = items.value.toMutableList().apply { - move(fromPos, toPos) - commit(this) - } - } - - fun addItem(item: NavItem) { - items.value = items.value.plus(item).also { - commit(it) - } - } - - fun removeItem(item: NavItem) { - val newList = items.value.toMutableList() - newList.remove(item) - if (newList.isEmpty()) { - newList.add(NavItem.EXPLORE) - } - items.value = newList - commit(newList) - } - - private fun commit(value: List) { - val prevJob = commitJob - commitJob = launchJob { - prevJob?.cancelAndJoin() - delay(500) - settings.mainNavItems = value - activityRecreationHandle.recreate(MainActivity::class.java) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/adapter/NavConfigAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/adapter/NavConfigAD.kt deleted file mode 100644 index 968522805..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/adapter/NavConfigAD.kt +++ /dev/null @@ -1,73 +0,0 @@ -package org.koitharu.kotatsu.settings.nav.adapter - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.View -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.NavItem -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.ItemNavAvailableBinding -import org.koitharu.kotatsu.databinding.ItemNavConfigBinding -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel - -@SuppressLint("ClickableViewAccessibility") -fun navConfigAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemNavConfigBinding.inflate(layoutInflater, parent, false) }, -) { - - val eventListener = object : View.OnClickListener, View.OnTouchListener { - override fun onClick(v: View) = clickListener.onItemClick(item, v) - - override fun onTouch(v: View?, event: MotionEvent): Boolean = - event.actionMasked == MotionEvent.ACTION_DOWN && - clickListener.onItemLongClick(item, itemView) - } - binding.imageViewRemove.setOnClickListener(eventListener) - binding.imageViewReorder.setOnTouchListener(eventListener) - - bind { - with(binding.textViewTitle) { - setText(item.title) - setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0) - } - } -} - -fun navAvailableAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener { v -> - clickListener.onItemClick(item, v) - } - - bind { - with(binding.root) { - setText(item.title) - setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0) - } - } -} - -fun navAddAD( - clickListener: View.OnClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener(clickListener) - binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_add, 0, 0, 0) - - bind { - with(binding.root) { - setText(if (item.canAdd) R.string.add else R.string.items_limit_exceeded) - isEnabled = item.canAdd - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/model/NavItemAddModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/model/NavItemAddModel.kt deleted file mode 100644 index 7f5656b97..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/model/NavItemAddModel.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.settings.nav.model - -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class NavItemAddModel( - val canAdd: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean = other is NavItemAddModel -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt deleted file mode 100644 index 421cd2fee..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.koitharu.kotatsu.settings.newsources - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.util.SuspendLazy -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -import javax.inject.Inject - -@HiltViewModel -class NewSourcesViewModel @Inject constructor( - private val repository: MangaSourcesRepository, - private val settings: AppSettings, -) : BaseViewModel() { - - private val newSources = SuspendLazy { - repository.assimilateNewSources() - } - val content: StateFlow> = repository.observeAll() - .map { sources -> - val new = newSources.get() - val skipNsfw = settings.isNsfwContentDisabled - sources.mapNotNull { (source, enabled) -> - if (source in new) { - SourceConfigItem.SourceItem( - source = source, - isEnabled = enabled, - isDraggable = false, - isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI, - ) - } else { - null - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) - - fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { - launchJob(Dispatchers.Default) { - repository.setSourceEnabled(item.source, isEnabled) - } - } -} - diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt deleted file mode 100644 index 30fc3e448..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/newsources/SourcesSelectAdapter.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.settings.newsources - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener -import org.koitharu.kotatsu.settings.sources.adapter.sourceConfigItemCheckableDelegate -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem - -class SourcesSelectAdapter( - listener: SourceConfigListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(sourceConfigItemCheckableDelegate(listener, coil, lifecycleOwner)) - } -} 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 deleted file mode 100644 index 3e0ddf42d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -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.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 - -@AndroidEntryPoint -class SourceSettingsFragment : BasePreferenceFragment(0) { - - private val viewModel: SourceSettingsViewModel by viewModels() - private val exceptionResolver = ExceptionResolver(this) - - override fun onResume() { - super.onResume() - setTitle(viewModel.source.title) - viewModel.onResume() - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.sharedPreferencesName = viewModel.source.name - addPreferencesFromResource(R.xml.pref_source) - addPreferencesFromRepository(viewModel.repository) - - findPreference(KEY_AUTH)?.run { - val authProvider = viewModel.repository.getAuthProvider() - isVisible = authProvider != null - isEnabled = authProvider?.isAuthorized == false - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.username.observe(viewLifecycleOwner) { username -> - findPreference(KEY_AUTH)?.summary = username?.let { - getString(R.string.logged_in_as, it) - } - } - viewModel.onError.observeEvent( - viewLifecycleOwner, - SnackbarErrorObserver( - listView, - this, - exceptionResolver, - ) { viewModel.onResume() }, - ) - viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - findPreference(KEY_AUTH)?.isEnabled = !isLoading - } - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - KEY_AUTH -> { - startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source)) - true - } - AppSettings.KEY_COOKIES_CLEAR -> { - viewModel.clearCookies() - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - companion object { - - private const val KEY_AUTH = "auth" - - const val EXTRA_SOURCE = "source" - - fun newInstance(source: MangaSource) = SourceSettingsFragment().withArgs(1) { - putSerializable(EXTRA_SOURCE, source) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt deleted file mode 100644 index 687a6d92e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsViewModel.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import okhttp3.HttpUrl -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -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.require -import org.koitharu.kotatsu.parsers.exception.AuthRequiredException -import org.koitharu.kotatsu.parsers.model.MangaSource -import javax.inject.Inject - -@HiltViewModel -class SourceSettingsViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - mangaRepositoryFactory: MangaRepository.Factory, - private val cookieJar: MutableCookieJar, -) : BaseViewModel() { - - val source = savedStateHandle.require(SourceSettingsFragment.EXTRA_SOURCE) - val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository - - val onActionDone = MutableEventFlow() - val username = MutableStateFlow(null) - private var usernameLoadJob: Job? = null - - init { - loadUsername() - } - - fun onResume() { - if (usernameLoadJob?.isActive != true) { - loadUsername() - } - } - - fun clearCookies() { - launchLoadingJob(Dispatchers.Default) { - val url = HttpUrl.Builder() - .scheme("https") - .host(repository.domain) - .build() - cookieJar.removeCookies(url, null) - onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) - loadUsername() - } - } - - private fun loadUsername() { - launchLoadingJob(Dispatchers.Default) { - try { - username.value = null - username.value = repository.getAuthProvider()?.getUsername() - } catch (_: AuthRequiredException) { - } - } - } -} 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 deleted file mode 100644 index c4f4e7a83..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.preference.ListPreference -import androidx.preference.Preference -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.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) { - - private val viewModel by viewModels() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_sources) - findPreference(AppSettings.KEY_SOURCES_ORDER)?.run { - entryValues = SourcesSortOrder.entries.names() - entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray() - setDefaultValueCompat(SourcesSortOrder.MANUAL.name) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> - viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { - pref.summary = if (it >= 0) { - resources.getQuantityString(R.plurals.items, it, it) - } else { - null - } - } - } - findPreference(AppSettings.KEY_SOURCES_CATALOG)?.let { pref -> - viewModel.availableSourcesCount.observe(viewLifecycleOwner) { - pref.summary = if (it >= 0) { - getString(R.string.available_d, it) - } else { - null - } - } - } - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { - AppSettings.KEY_SOURCES_CATALOG -> { - startActivity(Intent(preference.context, SourcesCatalogActivity::class.java)) - true - } - - else -> super.onPreferenceTreeClick(preference) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt deleted file mode 100644 index 2d8f52c3c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import javax.inject.Inject - -@HiltViewModel -class SourcesSettingsViewModel @Inject constructor( - sourcesRepository: MangaSourcesRepository, -) : BaseViewModel() { - - val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) - - val availableSourcesCount = sourcesRepository.observeAvailableSourcesCount() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt deleted file mode 100644 index a27037fa4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.ReorderableListAdapter -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem - -class SourceConfigAdapter( - listener: SourceConfigListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) : ReorderableListAdapter() { - - init { - with(delegatesManager) { - addDelegate(sourceConfigItemDelegate2(listener, coil, lifecycleOwner)) - addDelegate(sourceConfigEmptySearchDelegate()) - addDelegate(sourceConfigTipDelegate(listener)) - } - } -} 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 deleted file mode 100644 index e8c41887d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.adapter - -import android.view.View -import androidx.appcompat.widget.PopupMenu -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.getSummary -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.parser.favicon.faviconUri -import org.koitharu.kotatsu.core.ui.image.FaviconDrawable -import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener -import org.koitharu.kotatsu.core.util.ext.crossfade -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding -import org.koitharu.kotatsu.databinding.ItemSourceConfigCheckableBinding -import org.koitharu.kotatsu.databinding.ItemTipBinding -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem - -fun sourceConfigItemCheckableDelegate( - listener: SourceConfigListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemSourceConfigCheckableBinding.inflate( - layoutInflater, - parent, - false, - ) - }, -) { - - binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> - listener.onItemEnabledChanged(item, isChecked) - } - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - binding.switchToggle.isChecked = item.isEnabled - binding.switchToggle.isEnabled = item.isAvailable - binding.textViewDescription.text = item.source.getSummary(context) - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - crossfade(context) - error(fallbackIcon) - placeholder(fallbackIcon) - fallback(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} - -fun sourceConfigItemDelegate2( - listener: SourceConfigListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemSourceConfigBinding.inflate( - layoutInflater, - parent, - false, - ) - }, -) { - - val eventListener = View.OnClickListener { v -> - when (v.id) { - R.id.imageView_add -> listener.onItemEnabledChanged(item, true) - R.id.imageView_remove -> listener.onItemEnabledChanged(item, false) - R.id.imageView_menu -> showSourceMenu(v, item, listener) - } - } - binding.imageViewRemove.setOnClickListener(eventListener) - binding.imageViewAdd.setOnClickListener(eventListener) - binding.imageViewMenu.setOnClickListener(eventListener) - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable - binding.imageViewRemove.isVisible = item.isEnabled - binding.imageViewMenu.isVisible = item.isEnabled - binding.textViewDescription.text = item.source.getSummary(context) - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - crossfade(context) - error(fallbackIcon) - placeholder(fallbackIcon) - fallback(fallbackIcon) - source(item.source) - enqueueWith(coil) - } - } -} - -fun sourceConfigTipDelegate( - listener: OnTipCloseListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.buttonClose.setOnClickListener { - listener.onCloseTip(item) - } - - bind { - binding.imageViewIcon.setImageResource(item.iconResId) - binding.textView.setText(item.textResId) - } -} - -fun sourceConfigEmptySearchDelegate() = - adapterDelegate( - R.layout.item_sources_empty, - ) { } - -private fun showSourceMenu( - anchor: View, - item: SourceConfigItem.SourceItem, - listener: SourceConfigListener, -) { - val menu = PopupMenu(anchor.context, anchor) - menu.inflate(R.menu.popup_source_config) - menu.menu.findItem(R.id.action_shortcut) - ?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context) - menu.menu.findItem(R.id.action_lift)?.isVisible = item.isDraggable - menu.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_settings -> listener.onItemSettingsClick(item) - R.id.action_lift -> listener.onItemLiftClick(item) - R.id.action_shortcut -> listener.onItemShortcutClick(item) - } - true - } - menu.show() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItem.kt deleted file mode 100644 index 34c84e2c4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItem.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.MangaSource - -sealed interface SourceCatalogItem : ListModel { - - data class Source( - val source: MangaSource, - val showSummary: Boolean, - ) : SourceCatalogItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Source && other.source == source - } - } - - data class Hint( - @DrawableRes val icon: Int, - @StringRes val title: Int, - @StringRes val text: Int, - ) : SourceCatalogItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Hint && other.title == title - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt deleted file mode 100644 index 4b267125a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import androidx.core.view.ViewCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors -import org.koitharu.kotatsu.core.model.getSummary -import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.parser.favicon.faviconUri -import org.koitharu.kotatsu.core.ui.image.FaviconDrawable -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate -import org.koitharu.kotatsu.core.util.ext.crossfade -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.core.util.ext.source -import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding -import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding -import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding - -fun sourceCatalogItemSourceAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: OnListItemClickListener -) = adapterDelegateViewBinding( - { layoutInflater, parent -> - ItemSourceCatalogBinding.inflate(layoutInflater, parent, false) - }, -) { - - binding.imageViewAdd.setOnClickListener { v -> - listener.onItemClick(item, v) - } - - bind { - binding.textViewTitle.text = item.source.getTitle(context) - if (item.showSummary) { - binding.textViewDescription.text = item.source.getSummary(context) - binding.textViewDescription.isVisible = true - } else { - binding.textViewDescription.isVisible = false - } - val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) - binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { - crossfade(context) - error(fallbackIcon) - placeholder(fallbackIcon) - fallback(fallbackIcon) - source(item.source) - ignoreCaptchaErrors() - enqueueWith(coil) - } - } -} - -fun sourceCatalogItemHintAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, -) { - - binding.buttonRetry.isVisible = false - - bind { - binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) - binding.textPrimary.setText(item.title) - binding.textSecondary.setTextAndVisible(item.text) - } -} - -fun sourceCatalogPageAD( - listener: OnListItemClickListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemCatalogPageBinding.inflate(inflater, parent, false) }, -) { - - val sourcesAdapter = SourcesCatalogAdapter(listener, coil, lifecycleOwner) - with(binding.recyclerView) { - setHasFixedSize(true) - adapter = sourcesAdapter - } - val insetsDelegate = WindowInsetsDelegate() - ViewCompat.setOnApplyWindowInsetsListener(itemView, insetsDelegate) - itemView.addOnLayoutChangeListener(insetsDelegate) - insetsDelegate.addInsetsListener { insets -> - binding.recyclerView.updatePadding( - bottom = insets.bottom + binding.recyclerView.paddingTop, - ) - } - - bind { - sourcesAdapter.items = item.items - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt deleted file mode 100644 index b129022db..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.ContentType - -data class SourceCatalogPage( - val type: ContentType, - val items: List, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is SourceCatalogPage && other.type == type - } - - override fun getChangePayload(previousState: ListModel): Any? { - return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED - } -} 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 deleted file mode 100644 index a8b30a06a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import coil.ImageLoader -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayoutMediator -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.getDisplayName -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.toLocale -import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment -import javax.inject.Inject - -@AndroidEntryPoint -class SourcesCatalogActivity : BaseActivity(), - OnListItemClickListener, - AppBarOwner, MenuItem.OnActionExpandListener { - - @Inject - lateinit var coil: ImageLoader - - private var newSourcesSnackbar: Snackbar? = null - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - private val viewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this) - viewBinding.pager.adapter = pagerAdapter - val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter) - tabMediator.attach() - viewModel.content.observe(this, pagerAdapter) - viewModel.hasNewSources.observe(this, ::onHasNewSourcesChanged) - viewModel.onActionDone.observeEvent( - this, - ReversibleActionObserver(viewBinding.pager), - ) - viewModel.locale.observe(this) { - supportActionBar?.subtitle = it?.toLocale().getDisplayName(this) - } - addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - override fun onItemClick(item: SourceCatalogItem.Source, view: View) { - viewModel.addSource(item.source) - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - viewBinding.tabs.isVisible = false - viewBinding.pager.isUserInputEnabled = false - val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty() - viewModel.performSearch(sq) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - viewBinding.tabs.isVisible = true - viewBinding.pager.isUserInputEnabled = true - viewModel.performSearch(null) - return true - } - - private fun onHasNewSourcesChanged(hasNewSources: Boolean) { - if (hasNewSources) { - if (newSourcesSnackbar?.isShownOrQueued == true) { - return - } - val snackbar = Snackbar.make(viewBinding.pager, R.string.new_sources_text, Snackbar.LENGTH_INDEFINITE) - snackbar.setAction(R.string.explore) { - NewSourcesDialogFragment.show(supportFragmentManager) - } - snackbar.addCallback( - object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (event == DISMISS_EVENT_SWIPE) { - viewModel.skipNewSources() - } - } - }, - ) - snackbar.show() - newSourcesSnackbar = snackbar - } else { - newSourcesSnackbar?.dismiss() - newSourcesSnackbar = null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt deleted file mode 100644 index 0117be267..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogAdapter.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.list.ui.adapter.ListItemType - -class SourcesCatalogAdapter( - listener: OnListItemClickListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - addDelegate(ListItemType.CHAPTER, sourceCatalogItemSourceAD(coil, lifecycleOwner, listener)) - addDelegate(ListItemType.HINT_EMPTY, sourceCatalogItemHintAD(coil, lifecycleOwner)) - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - return (items.getOrNull(position) as? SourceCatalogItem.Source)?.source?.title?.take(1) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt deleted file mode 100644 index 40c42c3e2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogListProducer.kt +++ /dev/null @@ -1,105 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import androidx.room.InvalidationTracker -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.ViewModelLifecycle -import dagger.hilt.android.lifecycle.RetainedLifecycle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.TABLE_SOURCES -import org.koitharu.kotatsu.core.db.removeObserverAsync -import org.koitharu.kotatsu.core.util.ext.lifecycleScope -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.ContentType - -class SourcesCatalogListProducer @AssistedInject constructor( - @Assisted private val locale: String?, - @Assisted private val contentType: ContentType, - @Assisted lifecycle: ViewModelLifecycle, - private val repository: MangaSourcesRepository, - private val database: MangaDatabase, -) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener { - - private val scope = lifecycle.lifecycleScope - private var query: String? = null - val list = MutableStateFlow(emptyList()) - - private var job = scope.launch(Dispatchers.Default) { - list.value = buildList() - } - - init { - scope.launch(Dispatchers.Default) { - database.invalidationTracker.addObserver(this@SourcesCatalogListProducer) - } - lifecycle.addOnClearedListener(this) - } - - override fun onCleared() { - database.invalidationTracker.removeObserverAsync(this) - } - - override fun onInvalidated(tables: Set) { - val prevJob = job - job = scope.launch(Dispatchers.Default) { - prevJob.cancelAndJoin() - list.update { buildList() } - } - } - - fun setQuery(value: String?) { - this.query = value - onInvalidated(emptySet()) - } - - private suspend fun buildList(): List { - val sources = repository.getDisabledSources().toMutableList() - when (val q = query) { - null -> sources.retainAll { it.contentType == contentType && it.locale == locale } - "" -> return emptyList() - else -> sources.retainAll { it.title.contains(q, ignoreCase = true) } - } - return if (sources.isEmpty()) { - listOf( - if (query == null) { - SourceCatalogItem.Hint( - icon = R.drawable.ic_empty_feed, - title = R.string.no_manga_sources, - text = R.string.no_manga_sources_catalog_text, - ) - } else { - SourceCatalogItem.Hint( - icon = R.drawable.ic_empty_feed, - title = R.string.nothing_found, - text = R.string.no_manga_sources_found, - ) - }, - ) - } else { - sources.sortBy { it.title } - sources.map { - SourceCatalogItem.Source( - source = it, - showSummary = query != null, - ) - } - } - } - - @AssistedFactory - interface Factory { - - fun create( - locale: String?, - contentType: ContentType, - lifecycle: ViewModelLifecycle, - ): SourcesCatalogListProducer - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt deleted file mode 100644 index 67154760e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import android.app.Activity -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView -import androidx.core.view.MenuProvider -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.LocaleComparator -import org.koitharu.kotatsu.core.util.ext.getDisplayName -import org.koitharu.kotatsu.core.util.ext.toLocale -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner - -class SourcesCatalogMenuProvider( - private val activity: Activity, - private val viewModel: SourcesCatalogViewModel, - private val expandListener: MenuItem.OnActionExpandListener, -) : MenuProvider, - MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_sources_catalog, menu) - val searchMenuItem = menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_locales -> { - showLocalesMenu() - true - } - - else -> false - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - return expandListener.onMenuItemActionExpand(item) - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - (item.actionView as SearchView).setQuery("", false) - return expandListener.onMenuItemActionCollapse(item) - } - - override fun onQueryTextSubmit(query: String?): Boolean = false - - override fun onQueryTextChange(newText: String?): Boolean { - viewModel.performSearch(newText?.trim().orEmpty()) - return true - } - - private fun showLocalesMenu() { - val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { - it to it?.toLocale() - } - locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) - - val anchor: View = (activity as AppBarOwner).appBar.let { - it.findViewById(R.id.toolbar) ?: it - } - val menu = PopupMenu(activity, anchor) - for ((i, lc) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(activity)) - } - menu.setOnMenuItemClickListener { - viewModel.setLocale(locales.getOrNull(it.order)?.first) - true - } - menu.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt deleted file mode 100644 index 32f76fe15..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import org.koitharu.kotatsu.core.model.titleResId -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener - -class SourcesCatalogPagerAdapter( - listener: OnListItemClickListener, - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) : BaseListAdapter(), TabLayoutMediator.TabConfigurationStrategy { - - init { - delegatesManager.addDelegate(sourceCatalogPageAD(listener, coil, lifecycleOwner)) - } - - override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val item = items.getOrNull(position) ?: return - tab.setText(item.type.titleResId) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt deleted file mode 100644 index 144f5e45d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ /dev/null @@ -1,96 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.catalog - -import androidx.annotation.MainThread -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl -import dagger.hilt.android.lifecycle.HiltViewModel -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.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.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.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mapToSet -import java.util.EnumMap -import java.util.EnumSet -import java.util.Locale -import javax.inject.Inject - -@HiltViewModel -class SourcesCatalogViewModel @Inject constructor( - private val repository: MangaSourcesRepository, - private val listProducerFactory: SourcesCatalogListProducer.Factory, - private val settings: AppSettings, -) : BaseViewModel() { - - private val lifecycle = RetainedLifecycleImpl() - private var searchQuery: String? = null - val onActionDone = MutableEventFlow() - val locales = repository.allMangaSources.mapToSet { it.locale } - val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) - - val hasNewSources = repository.observeNewSources() - .map { it.isNotEmpty() } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - private val listProducers = locale.map { lc -> - createListProducers(lc) - }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) - - val content: StateFlow> = listProducers.flatMapLatest { - val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } } - combine>(flows, Array::toList) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - override fun onCleared() { - super.onCleared() - lifecycle.dispatchOnCleared() - } - - fun performSearch(query: String?) { - searchQuery = query - listProducers.value.forEach { (_, v) -> v.setQuery(query) } - } - - fun setLocale(value: String?) { - locale.value = value - } - - fun addSource(source: MangaSource) { - launchJob(Dispatchers.Default) { - val rollback = repository.setSourceEnabled(source, true) - onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) - } - } - - fun skipNewSources() { - launchJob { - repository.assimilateNewSources() - } - } - - @MainThread - private fun createListProducers(lc: String?): Map { - val types = EnumSet.allOf(ContentType::class.java) - if (settings.isNsfwContentDisabled) { - types.remove(ContentType.HENTAI) - } - return types.associateWithTo(EnumMap(ContentType::class.java)) { type -> - listProducerFactory.create(lc, type, lifecycle).also { - it.setQuery(searchQuery) - } - } - } -} 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 deleted file mode 100644 index c519807b4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt +++ /dev/null @@ -1,108 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.manage - -import androidx.room.InvalidationTracker -import dagger.hilt.android.ViewModelLifecycle -import dagger.hilt.android.scopes.ViewModelScoped -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.TABLE_SOURCES -import org.koitharu.kotatsu.core.model.isNsfw -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.lifecycleScope -import org.koitharu.kotatsu.core.util.ext.toEnumSet -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.explore.data.SourcesSortOrder -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -import javax.inject.Inject - -@ViewModelScoped -class SourcesListProducer @Inject constructor( - lifecycle: ViewModelLifecycle, - private val repository: MangaSourcesRepository, - private val settings: AppSettings, -) : InvalidationTracker.Observer(TABLE_SOURCES) { - - private val scope = lifecycle.lifecycleScope - private var query: String = "" - val list = MutableStateFlow(emptyList()) - - private var job = scope.launch(Dispatchers.Default) { - list.value = buildList() - } - - init { - settings.observe() - .filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW } - .flowOn(Dispatchers.Default) - .onEach { onInvalidated(emptySet()) } - .launchIn(scope) - } - - override fun onInvalidated(tables: Set) { - val prevJob = job - job = scope.launch(Dispatchers.Default) { - prevJob.cancelAndJoin() - list.update { buildList() } - } - } - - fun setQuery(value: String) { - this.query = value - onInvalidated(emptySet()) - } - - private suspend fun buildList(): List { - val enabledSources = repository.getEnabledSources() - val isNsfwDisabled = settings.isNsfwContentDisabled - val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL - val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) - val enabledSet = enabledSources.toEnumSet() - if (query.isNotEmpty()) { - return enabledSources.mapNotNull { - if (!it.title.contains(query, ignoreCase = true)) { - return@mapNotNull null - } - SourceConfigItem.SourceItem( - source = it, - isEnabled = it in enabledSet, - isDraggable = false, - isAvailable = !isNsfwDisabled || !it.isNsfw(), - ) - }.ifEmpty { - listOf(SourceConfigItem.EmptySearchResult) - } - } - val result = ArrayList(enabledSources.size + 1) - if (enabledSources.isNotEmpty()) { - if (withTip) { - result += SourceConfigItem.Tip( - TIP_REORDER, - R.drawable.ic_tap_reorder, - R.string.sources_reorder_tip, - ) - } - enabledSources.mapTo(result) { - SourceConfigItem.SourceItem( - source = it, - isEnabled = true, - isDraggable = isReorderAvailable, - isAvailable = false, - ) - } - } - return result - } - - companion object { - - const val TIP_REORDER = "src_reorder" - } -} 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 deleted file mode 100644 index f5a7e051a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ /dev/null @@ -1,259 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.manage - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.ViewGroup -import androidx.appcompat.widget.SearchView -import androidx.core.graphics.Insets -import androidx.core.view.MenuProvider -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.addMenuProvider -import org.koitharu.kotatsu.core.util.ext.getItem -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -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 - -@AndroidEntryPoint -class SourcesManageFragment : - BaseFragment(), - SourceConfigListener, - RecyclerViewOwner { - - @Inject - lateinit var coil: ImageLoader - - @Inject - lateinit var settings: AppSettings - - @Inject - lateinit var shortcutManager: AppShortcutManager - - private var reorderHelper: ItemTouchHelper? = null - private var sourcesAdapter: SourceConfigAdapter? = null - private val viewModel by viewModels() - - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentSettingsSourcesBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated( - binding: FragmentSettingsSourcesBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) - with(binding.recyclerView) { - setHasFixedSize(true) - adapter = sourcesAdapter - reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also { - it.attachToRecyclerView(this) - } - } - viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter)) - viewModel.onActionDone.observeEvent( - viewLifecycleOwner, - ReversibleActionObserver(binding.recyclerView), - ) - addMenuProvider(SourcesMenuProvider()) - } - - override fun onResume() { - super.onResume() - activity?.setTitle(R.string.manage_sources) - } - - override fun onDestroyView() { - sourcesAdapter = null - reorderHelper = null - super.onDestroyView() - } - - override fun onWindowInsetsChanged(insets: Insets) { - requireViewBinding().recyclerView.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - } - - override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { - val fragment = SourceSettingsFragment.newInstance(item.source) - (activity as? SettingsActivity)?.openFragment(fragment, false) - } - - override fun onItemLiftClick(item: SourceConfigItem.SourceItem) { - viewModel.bringToTop(item.source) - } - - override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) { - viewLifecycleScope.launch { - shortcutManager.requestPinShortcut(item.source) - } - } - - override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { - viewModel.setEnabled(item.source, isEnabled) - } - - override fun onCloseTip(tip: SourceConfigItem.Tip) { - viewModel.onTipClosed(tip) - } - - private inner class SourcesMenuProvider : - MenuProvider, - MenuItem.OnActionExpandListener, - SearchView.OnQueryTextListener { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_sources, menu) - val searchMenuItem = menu.findItem(R.id.action_search) - searchMenuItem.setOnActionExpandListener(this) - val searchView = searchMenuItem.actionView as SearchView - searchView.setOnQueryTextListener(this) - searchView.setIconifiedByDefault(false) - searchView.queryHint = searchMenuItem.title - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.action_catalog -> { - startActivity(Intent(context, SourcesCatalogActivity::class.java)) - true - } - - R.id.action_disable_all -> { - viewModel.disableAll() - true - } - - R.id.action_no_nsfw -> { - settings.isNsfwContentDisabled = !menuItem.isChecked - true - } - - else -> false - } - - override fun onPrepareMenu(menu: Menu) { - super.onPrepareMenu(menu) - menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - (activity as? AppBarOwner)?.appBar?.setExpanded(false, true) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - (item.actionView as SearchView).setQuery("", false) - return true - } - - override fun onQueryTextSubmit(query: String?): Boolean = false - - override fun onQueryTextChange(newText: String?): Boolean { - viewModel.performSearch(newText) - return true - } - } - - private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback( - ItemTouchHelper.DOWN or ItemTouchHelper.UP, - ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, - ) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean = viewHolder.itemViewType == target.itemViewType - - override fun onMoved( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - fromPos: Int, - target: RecyclerView.ViewHolder, - toPos: Int, - x: Int, - y: Int, - ) { - super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) - sourcesAdapter?.reorderItems(fromPos, toPos) - } - - override fun canDropOver( - recyclerView: RecyclerView, - current: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder( - current.bindingAdapterPosition, - target.bindingAdapterPosition, - ) - - override fun getDragDirs( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - ): Int { - val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java) - return if (item != null && item.isDraggable) { - super.getDragDirs(recyclerView, viewHolder) - } else { - 0 - } - } - - override fun getSwipeDirs( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - ): Int { - val item = viewHolder.getItem(SourceConfigItem.Tip::class.java) - return if (item != null) { - super.getSwipeDirs(recyclerView, viewHolder) - } else { - 0 - } - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val item = viewHolder.getItem(SourceConfigItem.Tip::class.java) - if (item != null) { - viewModel.onTipClosed(item) - } - } - - override fun isLongPressDragEnabled() = true - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt deleted file mode 100644 index daa7108ab..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt +++ /dev/null @@ -1,130 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.manage - -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.removeObserverAsync -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.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.move -import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem -import javax.inject.Inject - -@HiltViewModel -class SourcesManageViewModel @Inject constructor( - private val database: MangaDatabase, - private val settings: AppSettings, - private val repository: MangaSourcesRepository, - private val listProducer: SourcesListProducer, -) : BaseViewModel() { - - val content = listProducer.list - val onActionDone = MutableEventFlow() - private var commitJob: Job? = null - - init { - launchJob(Dispatchers.Default) { - database.invalidationTracker.addObserver(listProducer) - } - } - - override fun onCleared() { - super.onCleared() - database.invalidationTracker.removeObserverAsync(listProducer) - } - - fun saveSourcesOrder(snapshot: List) { - val prevJob = commitJob - commitJob = launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val newSourcesList = snapshot.mapNotNull { x -> - if (x is SourceConfigItem.SourceItem && x.isDraggable) { - x.source - } else { - null - } - } - repository.setPositions(newSourcesList) - } - } - - fun canReorder(oldPos: Int, newPos: Int): Boolean { - val snapshot = content.value - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false - return (snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled == true - } - - fun setEnabled(source: MangaSource, isEnabled: Boolean) { - launchJob(Dispatchers.Default) { - val rollback = repository.setSourceEnabled(source, isEnabled) - if (!isEnabled) { - onActionDone.call(ReversibleAction(R.string.source_disabled, rollback)) - } - } - } - - fun bringToTop(source: MangaSource) { - val snapshot = content.value - launchJob(Dispatchers.Default) { - var oldPos = -1 - var newPos = -1 - for ((i, x) in snapshot.withIndex()) { - if (x !is SourceConfigItem.SourceItem) { - continue - } - if (newPos == -1) { - newPos = i - } - if (x.source == source) { - oldPos = i - break - } - } - @Suppress("KotlinConstantConditions") - if (oldPos != -1 && newPos != -1) { - reorderSources(oldPos, newPos) - val revert = ReversibleAction(R.string.moved_to_top) { - reorderSources(newPos, oldPos) - } - commitJob?.join() - onActionDone.call(revert) - } - } - } - - fun disableAll() { - launchJob(Dispatchers.Default) { - repository.disableAllSources() - } - } - - fun performSearch(query: String?) { - listProducer.setQuery(query?.trim().orEmpty()) - } - - fun onTipClosed(item: SourceConfigItem.Tip) { - launchJob(Dispatchers.Default) { - settings.closeTip(item.key) - } - } - - private fun reorderSources(oldPos: Int, newPos: Int) { - val snapshot = content.value.toMutableList() - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return - } - if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return - } - snapshot.move(oldPos, newPos) - saveSourcesOrder(snapshot) - } -} 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 deleted file mode 100644 index a68b6581b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.koitharu.kotatsu.settings.sources.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaSource - -sealed interface SourceConfigItem : ListModel { - - data class SourceItem( - val source: MangaSource, - val isEnabled: Boolean, - val isDraggable: Boolean, - val isAvailable: Boolean, - ) : SourceConfigItem { - - val isNsfw: Boolean - get() = source.contentType == ContentType.HENTAI - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is SourceItem && other.source == source - } - } - - data class Tip( - val key: String, - @DrawableRes val iconResId: Int, - @StringRes val textResId: Int, - ) : SourceConfigItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Tip && other.key == key - } - } - - data object EmptySearchResult : SourceConfigItem { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is EmptySearchResult - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt deleted file mode 100644 index aca367204..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryAD.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemStorageBinding - -fun directoryAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemStorageBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.root.setOnClickListener { v -> clickListener.onItemClick(item, v) } - - bind { - binding.textViewTitle.text = item.title ?: getString(item.titleRes) - binding.textViewSubtitle.textAndVisible = item.file?.absolutePath - binding.imageViewIndicator.isChecked = item.isChecked - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt deleted file mode 100644 index ce30fdc25..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryDiffCallback.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import androidx.recyclerview.widget.DiffUtil.ItemCallback - -class DirectoryDiffCallback : ItemCallback() { - - override fun areItemsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean { - return oldItem.file == newItem.file - } - - override fun areContentsTheSame(oldItem: DirectoryModel, newItem: DirectoryModel): Boolean { - return oldItem == newItem - } - - override fun getChangePayload(oldItem: DirectoryModel, newItem: DirectoryModel): Any? { - return if (oldItem.isChecked != newItem.isChecked) { - Unit - } else { - super.getChangePayload(oldItem, newItem) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt deleted file mode 100644 index a606c7abf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import androidx.annotation.StringRes -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import java.io.File - -data class DirectoryModel( - val title: String?, - @StringRes val titleRes: Int, - val file: File?, - val isRemovable: Boolean, - val isChecked: Boolean, - val isAvailable: Boolean, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is DirectoryModel && other.file == file && other.title == title && other.titleRes == titleRes - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is DirectoryModel && previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} 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 deleted file mode 100644 index ab9d565d3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import android.Manifest -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -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.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.databinding.DialogDirectorySelectBinding - -@AndroidEntryPoint -class MangaDirectorySelectDialog : AlertDialogFragment(), - OnListItemClickListener { - - private val viewModel: MangaDirectorySelectViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { - if (it != null) viewModel.onCustomDirectoryPicked(it) - } - private val permissionRequestLauncher = registerForActivityResult( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - RequestStorageManagerPermissionContract() - } else { - ActivityResultContracts.RequestPermission() - }, - ) { - if (it) { - viewModel.refresh() - pickFileTreeLauncher.launch(null) - } - } - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogDirectorySelectBinding { - return DialogDirectorySelectBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: DialogDirectorySelectBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryAD(this)) - binding.root.adapter = adapter - viewModel.items.observe(viewLifecycleOwner) { adapter.items = it } - viewModel.onDismissDialog.observeEvent(viewLifecycleOwner) { dismiss() } - viewModel.onPickDirectory.observeEvent(viewLifecycleOwner) { pickCustomDirectory() } - viewModel.onError.observeEvent(viewLifecycleOwner, ToastErrorObserver(binding.root, this)) - } - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setCancelable(true) - .setTitle(R.string.manga_save_location) - .setNegativeButton(android.R.string.cancel, null) - } - - override fun onItemClick(item: DirectoryModel, view: View) { - viewModel.onItemClick(item) - } - - private fun pickCustomDirectory() { - permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - - companion object { - - private const val TAG = "MangaDirectorySelectDialog" - - fun show(fm: FragmentManager) = MangaDirectorySelectDialog() - .showDistinct(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt deleted file mode 100644 index 3df94e208..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import android.net.Uri -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.R -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.local.data.LocalStorageManager -import javax.inject.Inject - -@HiltViewModel -class MangaDirectorySelectViewModel @Inject constructor( - private val storageManager: LocalStorageManager, - private val settings: AppSettings, -) : BaseViewModel() { - - val items = MutableStateFlow(emptyList()) - val onDismissDialog = MutableEventFlow() - val onPickDirectory = MutableEventFlow() - - init { - refresh() - } - - fun onItemClick(item: DirectoryModel) { - if (item.file != null) { - settings.mangaStorageDir = item.file - onDismissDialog.call(Unit) - } else { - onPickDirectory.call(Unit) - } - } - - fun onCustomDirectoryPicked(uri: Uri) { - launchJob(Dispatchers.Default) { - storageManager.takePermissions(uri) - val dir = requireNotNull(storageManager.resolveUri(uri)) { - "Cannot resolve file name of \"$uri\"" - } - if (!dir.canWrite()) { - throw AccessDeniedException(dir) - } - if (dir !in storageManager.getApplicationStorageDirs()) { - settings.mangaStorageDir = dir - storageManager.setDirIsNoMedia(dir) - } - onDismissDialog.call(Unit) - } - } - - fun refresh() { - launchJob(Dispatchers.Default) { - val defaultValue = storageManager.getDefaultWriteableDir() - val available = storageManager.getWriteableDirs() - items.value = buildList(available.size + 1) { - available.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == defaultValue, - isAvailable = true, - isRemovable = false, - ) - } - this += DirectoryModel( - title = null, - titleRes = R.string.pick_custom_directory, - file = null, - isChecked = false, - isAvailable = true, - isRemovable = false, - ) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt deleted file mode 100644 index 85fbab3d1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/RequestStorageManagerPermissionContract.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.koitharu.kotatsu.settings.storage - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.activity.result.contract.ActivityResultContract -import androidx.annotation.RequiresApi -import androidx.core.net.toUri - - -@RequiresApi(Build.VERSION_CODES.R) -class RequestStorageManagerPermissionContract : ActivityResultContract() { - - override fun createIntent(context: Context, input: String): Intent { - val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - intent.addCategory("android.intent.category.DEFAULT") - intent.data = "package:${context.packageName}".toUri() - return intent - } - - override fun parseResult(resultCode: Int, intent: Intent?): Boolean { - return Environment.isExternalStorageManager() - } - - override fun getSynchronousResult(context: Context, input: String): SynchronousResult? { - return if (Environment.isExternalStorageManager()) { - SynchronousResult(true) - } else { - null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt deleted file mode 100644 index d1d823a14..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.koitharu.kotatsu.settings.storage.directories - -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.drawableStart -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemStorageConfigBinding -import org.koitharu.kotatsu.settings.storage.DirectoryModel - -fun directoryConfigAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemStorageConfigBinding.inflate(layoutInflater, parent, false) }, -) { - - binding.imageViewRemove.setOnClickListener { v -> clickListener.onItemClick(item, v) } - - bind { - binding.textViewTitle.text = item.title ?: getString(item.titleRes) - binding.textViewSubtitle.textAndVisible = item.file?.absolutePath - binding.imageViewRemove.isVisible = item.isRemovable - binding.imageViewRemove.isEnabled = !item.isChecked - binding.textViewTitle.drawableStart = if (!item.isAvailable) { - ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply { - setTint(ContextCompat.getColor(context, R.color.warning)) - } - } else if (item.isChecked) { - ContextCompat.getDrawable(context, R.drawable.ic_download) - } else { - null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt deleted file mode 100644 index d4f3283f9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesActivity.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.koitharu.kotatsu.settings.storage.directories - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.ui.BaseActivity -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.ActivityMangaDirectoriesBinding -import org.koitharu.kotatsu.settings.storage.DirectoryDiffCallback -import org.koitharu.kotatsu.settings.storage.DirectoryModel -import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract - -@AndroidEntryPoint -class MangaDirectoriesActivity : BaseActivity(), - OnListItemClickListener, View.OnClickListener { - - private val viewModel: MangaDirectoriesViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { - if (it != null) viewModel.onCustomDirectoryPicked(it) - } - private val permissionRequestLauncher = registerForActivityResult( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - RequestStorageManagerPermissionContract() - } else { - ActivityResultContracts.RequestPermission() - }, - ) { - if (it) { - viewModel.updateList() - pickFileTreeLauncher.launch(null) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this)) - viewBinding.recyclerView.adapter = adapter - viewBinding.fabAdd.setOnClickListener(this) - viewModel.items.observe(this) { adapter.items = it } - viewModel.isLoading.observe(this) { viewBinding.progressBar.isVisible = it } - viewModel.onError.observeEvent( - this, - SnackbarErrorObserver(viewBinding.root, null, exceptionResolver) { - if (it) viewModel.updateList() - }, - ) - } - - override fun onItemClick(item: DirectoryModel, view: View) { - viewModel.onRemoveClick(item.file ?: return) - } - - override fun onClick(v: View?) { - permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.fabAdd.updateLayoutParams { - rightMargin = topMargin + insets.right - leftMargin = topMargin + insets.left - bottomMargin = topMargin + insets.bottom - } - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt deleted file mode 100644 index b53cc05de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.koitharu.kotatsu.settings.storage.directories - -import android.net.Uri -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.settings.storage.DirectoryModel -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class MangaDirectoriesViewModel @Inject constructor( - private val storageManager: LocalStorageManager, - private val settings: AppSettings, -) : BaseViewModel() { - - val items = MutableStateFlow(emptyList()) - private var loadingJob: Job? = null - - init { - loadList() - } - - fun updateList() { - loadList() - } - - fun onCustomDirectoryPicked(uri: Uri) { - launchLoadingJob(Dispatchers.Default) { - loadingJob?.cancelAndJoin() - storageManager.takePermissions(uri) - val dir = requireNotNull(storageManager.resolveUri(uri)) { - "Cannot resolve file name of \"$uri\"" - } - if (!dir.canWrite()) { - throw AccessDeniedException(dir) - } - if (dir !in storageManager.getApplicationStorageDirs()) { - settings.userSpecifiedMangaDirectories += dir - loadList() - } - } - } - - fun onRemoveClick(directory: File) { - settings.userSpecifiedMangaDirectories -= directory - if (settings.mangaStorageDir == directory) { - settings.mangaStorageDir = null - } - loadList() - } - - private fun loadList() { - val prevJob = loadingJob - loadingJob = launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val downloadDir = storageManager.getDefaultWriteableDir() - val applicationDirs = storageManager.getApplicationStorageDirs() - val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs - items.value = buildList(applicationDirs.size + customDirs.size) { - applicationDirs.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), - isRemovable = false, - ) - } - customDirs.mapTo(this) { dir -> - DirectoryModel( - title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), - titleRes = 0, - file = dir, - isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), - isRemovable = true, - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt deleted file mode 100644 index cd6713272..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.settings.tracker - -import androidx.room.InvalidationTracker -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import okio.Closeable -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES -import org.koitharu.kotatsu.core.db.removeObserverAsync -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -@HiltViewModel -class TrackerSettingsViewModel @Inject constructor( - private val repository: TrackingRepository, - private val database: MangaDatabase, -) : BaseViewModel() { - - val categoriesCount = MutableStateFlow(null) - - init { - updateCategoriesCount() - val databaseObserver = DatabaseObserver(this) - addCloseable(databaseObserver) - launchJob(Dispatchers.Default) { - database.invalidationTracker.addObserver(databaseObserver) - } - } - - private fun updateCategoriesCount() { - launchJob(Dispatchers.Default) { - categoriesCount.value = repository.getCategoriesCount() - } - } - - private class DatabaseObserver(private var vm: TrackerSettingsViewModel?) : - InvalidationTracker.Observer(arrayOf(TABLE_FAVOURITE_CATEGORIES)), - Closeable { - - override fun onInvalidated(tables: Set) { - vm?.updateCategoriesCount() - } - - override fun close() { - (vm ?: return).database.invalidationTracker.removeObserverAsync(this) - vm = null - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigAdapter.kt deleted file mode 100644 index 6d7d43cd0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigAdapter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.settings.tracker.categories - -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener - -class TrackerCategoriesConfigAdapter( - listener: OnListItemClickListener, -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(trackerCategoryAD(listener)) - } -} 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 deleted file mode 100644 index c9ad94181..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.koitharu.kotatsu.settings.tracker.categories - -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 -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.databinding.SheetBaseBinding - -@AndroidEntryPoint -class TrackerCategoriesConfigSheet : - BaseAdaptiveSheet(), - OnListItemClickListener { - - private val viewModel by viewModels() - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding { - return SheetBaseBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetBaseBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.headerBar.setTitle(R.string.favourites_categories) - val adapter = TrackerCategoriesConfigAdapter(this) - binding.recyclerView.adapter = adapter - - viewModel.content.observe(viewLifecycleOwner) { adapter.items = it } - } - - 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/tracker/categories/TrackerCategoriesConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt deleted file mode 100644 index 51daf79b1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.koitharu.kotatsu.settings.tracker.categories - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import javax.inject.Inject - -@HiltViewModel -class TrackerCategoriesConfigViewModel @Inject constructor( - private val favouritesRepository: FavouritesRepository, -) : BaseViewModel() { - - val content = favouritesRepository.observeCategories() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - - private var updateJob: Job? = null - - fun toggleItem(category: FavouriteCategory) { - val prevJob = updateJob - updateJob = launchJob(Dispatchers.Default) { - prevJob?.join() - favouritesRepository.updateCategoryTracking(category.id, !category.isTrackingEnabled) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoryAD.kt deleted file mode 100644 index 7db2f78b1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoryAD.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.koitharu.kotatsu.settings.tracker.categories - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding - -fun trackerCategoryAD( - listener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, -) { - val eventListener = AdapterDelegateClickListenerAdapter(this, listener) - itemView.setOnClickListener(eventListener) - - bind { - binding.root.text = item.title - binding.root.isChecked = item.isTrackingEnabled - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt deleted file mode 100644 index 6ed2e4748..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -data class StorageUsage( - val savedManga: Item, - val pagesCache: Item, - val otherCache: Item, - val available: Item, -) { - data class Item( - val bytes: Long, - val percent: Float, - ) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt deleted file mode 100644 index 20f114981..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -import android.content.Context -import android.content.res.ColorStateList -import android.util.AttributeSet -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt -import androidx.annotation.StringRes -import androidx.core.graphics.ColorUtils -import androidx.core.widget.TextViewCompat -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import com.google.android.material.color.MaterialColors -import kotlinx.coroutines.flow.FlowCollector -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding -import com.google.android.material.R as materialR - -class StorageUsagePreference @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, -) : Preference(context, attrs), FlowCollector { - - private val labelPattern = context.getString(R.string.memory_usage_pattern) - private var usage: StorageUsage? = null - - init { - layoutResource = R.layout.preference_memory_usage - isSelectable = false - isPersistent = false - } - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) - val storageSegment = SegmentedBarView.Segment( - usage?.savedManga?.percent ?: 0f, - segmentColor(materialR.attr.colorPrimary), - ) - val pagesSegment = SegmentedBarView.Segment( - usage?.pagesCache?.percent ?: 0f, - segmentColor(materialR.attr.colorSecondary), - ) - val otherSegment = SegmentedBarView.Segment( - usage?.otherCache?.percent ?: 0f, - segmentColor(materialR.attr.colorTertiary), - ) - - with(binding) { - bar.animateSegments(listOf(storageSegment, pagesSegment, otherSegment).filter { it.percent > 0f }) - labelStorage.text = formatLabel(usage?.savedManga, R.string.saved_manga) - labelPagesCache.text = formatLabel(usage?.pagesCache, R.string.pages_cache) - labelOtherCache.text = formatLabel(usage?.otherCache, R.string.other_cache) - labelAvailable.text = formatLabel(usage?.available, R.string.available, R.string.available) - - TextViewCompat.setCompoundDrawableTintList(labelStorage, ColorStateList.valueOf(storageSegment.color)) - TextViewCompat.setCompoundDrawableTintList(labelPagesCache, ColorStateList.valueOf(pagesSegment.color)) - TextViewCompat.setCompoundDrawableTintList(labelOtherCache, ColorStateList.valueOf(otherSegment.color)) - } - } - - override suspend fun emit(value: StorageUsage?) { - usage = value - notifyChanged() - } - - private fun formatLabel( - item: StorageUsage.Item?, - @StringRes labelResId: Int, - @StringRes emptyResId: Int = R.string.computing_, - ): String { - return if (item != null) { - labelPattern.format( - FileSize.BYTES.format(context, item.bytes), - context.getString(labelResId), - ) - } else { - context.getString(emptyResId) - } - } - - private fun getHue(hex: String): Float { - val r = (hex.substring(0, 2).toInt(16)).toFloat() - val g = (hex.substring(2, 4).toInt(16)).toFloat() - val b = (hex.substring(4, 6).toInt(16)).toFloat() - - var hue = 0F - if ((r >= g) && (g >= b)) { - hue = 60 * (g - b) / (r - b) - } else if ((g > r) && (r >= b)) { - hue = 60 * (2 - (r - b) / (g - b)) - } - return hue - } - - @ColorInt - private fun segmentColor(@AttrRes resId: Int): Int { - val colorHex = String.format("%06x", context.getThemeColor(resId)) - val hue = getHue(colorHex) - val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f)) - val backgroundColor = context.getThemeColor(materialR.attr.colorSurfaceContainerHigh) - return MaterialColors.harmonize(color, backgroundColor) - } -} 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 deleted file mode 100644 index 2e5776ebc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ /dev/null @@ -1,244 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -import android.content.Intent -import android.content.SharedPreferences -import android.net.Uri -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.core.view.postDelayed -import androidx.fragment.app.viewModels -import androidx.preference.Preference -import androidx.preference.TwoStatePreference -import androidx.preference.forEach -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.os.AppShortcutManager -import org.koitharu.kotatsu.core.prefs.AppSettings -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.tryLaunch -import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.settings.backup.BackupDialogFragment -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment -import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity -import javax.inject.Inject - -@AndroidEntryPoint -class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy), - SharedPreferences.OnSharedPreferenceChangeListener, - ActivityResultCallback { - - @Inject - lateinit var appShortcutManager: AppShortcutManager - - @Inject - lateinit var activityRecreationHandle: ActivityRecreationHandle - - private val viewModel: UserDataSettingsViewModel by viewModels() - - private val backupSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocument(), - this, - ) - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_user_data) - findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = - appShortcutManager.isDynamicShortcutsAvailable() - findPreference(AppSettings.KEY_PROTECT_APP) - ?.isChecked = !settings.appPassword.isNullOrEmpty() - } - - 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) - } - viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> - preferenceScreen.forEach { pref -> - pref.isEnabled = pref.key !in keys - } - } - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - 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_UPDATES_FEED_CLEAR -> { - viewModel.clearUpdatesFeed() - true - } - - AppSettings.KEY_BACKUP -> { - BackupDialogFragment.show(childFragmentManager) - true - } - - AppSettings.KEY_RESTORE -> { - if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { - Snackbar.make( - listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - true - } - - AppSettings.KEY_PROTECT_APP -> { - val pref = (preference as? TwoStatePreference ?: return false) - if (pref.isChecked) { - pref.isChecked = false - startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) - } else { - settings.appPassword = null - } - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_APP_PASSWORD -> { - 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 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 bindPeriodicalBackupSummary() { - val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return - val entries = resources.getStringArray(R.array.backup_frequency) - val entryValues = resources.getStringArray(R.array.values_backup_frequency) - viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq -> - preference.summary = if (freq == 0L) { - getString(R.string.disabled) - } else { - val index = entryValues.indexOf(freq.toString()) - entries.getOrNull(index) - } - } - } - - 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 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 deleted file mode 100644 index 6f6e4c294..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -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.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.local.data.CacheDir -import org.koitharu.kotatsu.local.data.LocalStorageManager -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, -) : 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 periodicalBackupFrequency = settings.observeAsFlow( - key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, - valueProducer = { isPeriodicalBackupEnabled }, - ).flatMapLatest { isEnabled -> - if (isEnabled) { - settings.observeAsFlow( - key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY, - valueProducer = { periodicalBackupFrequency }, - ) - } else { - flowOf(0) - } - } - - 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)) - } - } - - private fun loadStorageUsage() { - val prevJob = storageUsageJob - storageUsageJob = 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(), - ), - ) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt deleted file mode 100644 index 43e38d720..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ActivityListPreference.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.util.AttributeSet -import androidx.preference.ListPreference -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug - -class ActivityListPreference : ListPreference { - - var activityIntent: Intent? = null - - constructor( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int, - defStyleRes: Int - ) : super(context, attrs, defStyleAttr, defStyleRes) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context) : super(context) - - override fun onClick() { - val intent = activityIntent - if (intent == null) { - super.onClick() - return - } - try { - context.startActivity(intent) - } catch (e: ActivityNotFoundException) { - e.printStackTraceDebug() - super.onClick() - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt deleted file mode 100644 index 35d0dc359..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.PowerManager -import android.provider.Settings -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.powerManager - -@SuppressLint("BatteryLife") -class DozeHelper( - private val fragment: PreferenceFragmentCompat, -) { - - private val startForDozeResult = fragment.registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { - updatePreference() - } - - fun updatePreference() { - val preference = fragment.findPreference(AppSettings.KEY_IGNORE_DOZE) ?: return - preference.isVisible = isDozeIgnoreAvailable() - } - - fun startIgnoreDoseActivity(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Snackbar.make(fragment.listView ?: return false, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - return false - } - val context = fragment.context ?: return false - val packageName = context.packageName - val powerManager = context.powerManager ?: return false - return if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { - try { - val intent = Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - "package:$packageName".toUri(), - ) - startForDozeResult.launch(intent) - true - } catch (e: ActivityNotFoundException) { - Snackbar.make(fragment.listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - false - } - } else { - false - } - } - - private fun isDozeIgnoreAvailable(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return false - } - val context = fragment.context ?: return false - val packageName = context.packageName - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - return !powerManager.isIgnoringBatteryOptimizations(packageName) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt deleted file mode 100644 index b787333c7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/PasswordSummaryProvider.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import android.text.TextUtils -import androidx.preference.EditTextPreference -import androidx.preference.Preference - -class PasswordSummaryProvider : Preference.SummaryProvider { - - private val delegate = EditTextPreference.SimpleSummaryProvider.getInstance() - - override fun provideSummary(preference: EditTextPreference): CharSequence? { - val summary = delegate.provideSummary(preference) - return if (summary != null && !TextUtils.isEmpty(preference.text)) { - String(CharArray(summary.length) { '\u2022' }) - } else { - summary - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ThemeChooserPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ThemeChooserPreference.kt deleted file mode 100644 index 5a92f92db..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/ThemeChooserPreference.kt +++ /dev/null @@ -1,188 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import android.content.Context -import android.content.res.TypedArray -import android.os.Build -import android.os.Parcel -import android.os.Parcelable -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewTreeObserver -import android.widget.HorizontalScrollView -import androidx.appcompat.view.ContextThemeWrapper -import androidx.core.view.isVisible -import androidx.core.view.updatePaddingRelative -import androidx.customview.view.AbsSavedState -import androidx.preference.Preference -import androidx.preference.PreferenceViewHolder -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.ColorScheme -import org.koitharu.kotatsu.databinding.ItemColorSchemeBinding -import org.koitharu.kotatsu.databinding.PreferenceThemeBinding -import java.lang.ref.WeakReference -import com.google.android.material.R as materialR - -class ThemeChooserPreference @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.themeChooserPreferenceStyle, - defStyleRes: Int = R.style.Preference_ThemeChooser, -) : Preference(context, attrs, defStyleAttr, defStyleRes) { - - private val entries = ColorScheme.getAvailableList() - private var currentValue: ColorScheme = ColorScheme.default - private val lastScrollPosition = intArrayOf(-1) - private val itemClickListener = View.OnClickListener { - val tag = it.tag as? ColorScheme ?: return@OnClickListener - setValueInternal(tag.name, true) - } - private var scrollPersistListener: ScrollPersistListener? = null - - var value: String - get() = currentValue.name - set(value) = setValueInternal(value, notifyChanged = true) - - override fun onBindViewHolder(holder: PreferenceViewHolder) { - super.onBindViewHolder(holder) - val binding = PreferenceThemeBinding.bind(holder.itemView) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - binding.scrollView.suppressLayout(true) - binding.linear.suppressLayout(true) - } - binding.linear.removeAllViews() - for (theme in entries) { - val context = ContextThemeWrapper(context, theme.styleResId) - val item = - ItemColorSchemeBinding.inflate(LayoutInflater.from(context), binding.linear, false) - if (binding.linear.childCount == 0) { - item.root.updatePaddingRelative(start = 0) - } - val isSelected = theme == currentValue - item.card.isChecked = isSelected - item.card.strokeWidth = if (isSelected) context.resources.getDimensionPixelSize( - materialR.dimen.m3_comp_outlined_card_outline_width, - ) else 0 - item.textViewTitle.setText(theme.titleResId) - item.root.tag = theme - item.card.tag = theme - item.imageViewCheck.isVisible = theme == currentValue - item.root.setOnClickListener(itemClickListener) - item.card.setOnClickListener(itemClickListener) - binding.linear.addView(item.root) - if (isSelected) { - item.root.requestFocus() - } - } - if (lastScrollPosition[0] >= 0) { - val scroller = Scroller(binding.scrollView, lastScrollPosition[0]) - scroller.run() - binding.scrollView.post(scroller) - } - binding.scrollView.viewTreeObserver.run { - scrollPersistListener?.let { removeOnScrollChangedListener(it) } - scrollPersistListener = - ScrollPersistListener(WeakReference(binding.scrollView), lastScrollPosition) - addOnScrollChangedListener(scrollPersistListener) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - binding.linear.suppressLayout(false) - binding.scrollView.suppressLayout(false) - } - } - - override fun onSetInitialValue(defaultValue: Any?) { - value = getPersistedString( - when (defaultValue) { - is String -> ColorScheme.safeValueOf(defaultValue) ?: ColorScheme.default - is ColorScheme -> defaultValue - else -> ColorScheme.default - }.name, - ) - } - - override fun onGetDefaultValue(a: TypedArray, index: Int): Any { - return a.getInt(index, 0) - } - - override fun onSaveInstanceState(): Parcelable? { - val superState = super.onSaveInstanceState() ?: return null - return SavedState( - superState = superState, - scrollPosition = lastScrollPosition[0], - ) - } - - override fun onRestoreInstanceState(state: Parcelable?) { - if (state !is SavedState) { - super.onRestoreInstanceState(state) - return - } - super.onRestoreInstanceState(state.superState) - lastScrollPosition[0] = state.scrollPosition - } - - private fun setValueInternal(enumName: String, notifyChanged: Boolean) { - val newValue = ColorScheme.safeValueOf(enumName) ?: return - if (newValue != currentValue) { - currentValue = newValue - persistString(newValue.name) - if (notifyChanged) { - notifyChanged() - } - } - } - - private class SavedState : AbsSavedState { - - val scrollPosition: Int - - constructor( - superState: Parcelable, - scrollPosition: Int, - ) : super(superState) { - this.scrollPosition = scrollPosition - } - - constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { - scrollPosition = source.readInt() - } - - override fun writeToParcel(out: Parcel, flags: Int) { - super.writeToParcel(out, flags) - out.writeInt(scrollPosition) - } - - companion object { - @Suppress("unused") - @JvmField - val CREATOR: Parcelable.Creator = object : Parcelable.Creator { - override fun createFromParcel(`in`: Parcel) = - SavedState(`in`, SavedState::class.java.classLoader) - - override fun newArray(size: Int): Array = arrayOfNulls(size) - } - } - } - - private class ScrollPersistListener( - private val scrollViewRef: WeakReference, - private val lastScrollPosition: IntArray, - ) : ViewTreeObserver.OnScrollChangedListener { - - override fun onScrollChanged() { - val scrollView = scrollViewRef.get() ?: return - lastScrollPosition[0] = scrollView.scrollX - } - } - - private class Scroller( - private val scrollView: HorizontalScrollView, - private val position: Int, - ) : Runnable { - - override fun run() { - scrollView.scrollTo(position, 0) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt deleted file mode 100644 index 201fabeec..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/DomainValidator.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.settings.utils.validation - -import okhttp3.HttpUrl -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.EditTextValidator - -class DomainValidator : EditTextValidator() { - - override fun validate(text: String): ValidationResult { - val trimmed = text.trim() - if (trimmed.isEmpty()) { - return ValidationResult.Success - } - return if (!checkCharacters(trimmed)) { - ValidationResult.Failed(context.getString(R.string.invalid_domain_message)) - } else { - ValidationResult.Success - } - } - - private fun checkCharacters(value: String): Boolean = runCatching { - val parts = value.split(':') - require(parts.size <= 2) - val urlBuilder = HttpUrl.Builder() - urlBuilder.host(parts.first()) - if (parts.size == 2) { - urlBuilder.port(parts[1].toInt()) - } - }.isSuccess -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt deleted file mode 100644 index 36891f980..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/HeaderValidator.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.settings.utils.validation - -import okhttp3.Headers -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.util.EditTextValidator - -class HeaderValidator : EditTextValidator() { - - private val headers = Headers.Builder() - - override fun validate(text: String): ValidationResult { - val trimmed = text.trim() - if (trimmed.isEmpty()) { - return ValidationResult.Success - } - return if (!validateImpl(trimmed)) { - ValidationResult.Failed(context.getString(R.string.invalid_value_message)) - } else { - ValidationResult.Success - } - } - - private fun validateImpl(value: String): Boolean = runCatching { - headers[CommonHeaders.USER_AGENT] = value - }.isSuccess -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt deleted file mode 100644 index 3bee9f9a5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/validation/PortNumberValidator.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.settings.utils.validation - -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.EditTextValidator - -class PortNumberValidator : EditTextValidator() { - - override fun validate(text: String): ValidationResult { - val trimmed = text.trim() - if (trimmed.isEmpty()) { - return ValidationResult.Success - } - return if (!checkCharacters(trimmed)) { - ValidationResult.Failed(context.getString(R.string.invalid_port_number)) - } else { - ValidationResult.Success - } - } - - private fun checkCharacters(value: String): Boolean { - val intValue = value.toIntOrNull() ?: return false - return intValue in 1..65535 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/PeriodicWorkScheduler.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/PeriodicWorkScheduler.kt deleted file mode 100644 index 5b37d5dfd..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/PeriodicWorkScheduler.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.settings.work - -interface PeriodicWorkScheduler { - - suspend fun schedule() - - suspend fun unschedule() - - suspend fun isScheduled(): Boolean -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt deleted file mode 100644 index 47b9eb6b3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.settings.work - -import android.content.SharedPreferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker -import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker -import org.koitharu.kotatsu.tracker.work.TrackWorker -import javax.inject.Inject - -class WorkScheduleManager @Inject constructor( - private val settings: AppSettings, - private val suggestionScheduler: SuggestionsWorker.Scheduler, - private val trackerScheduler: TrackWorker.Scheduler, - private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler, -) : SharedPreferences.OnSharedPreferenceChangeListener { - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_TRACKER_ENABLED, - AppSettings.KEY_TRACKER_WIFI_ONLY -> updateWorker( - scheduler = trackerScheduler, - isEnabled = settings.isTrackerEnabled, - force = key != AppSettings.KEY_TRACKER_ENABLED, - ) - - AppSettings.KEY_SUGGESTIONS, - AppSettings.KEY_SUGGESTIONS_WIFI_ONLY -> updateWorker( - scheduler = suggestionScheduler, - isEnabled = settings.isSuggestionsEnabled, - force = key != AppSettings.KEY_SUGGESTIONS, - ) - - AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, - AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker( - scheduler = periodicalBackupScheduler, - isEnabled = settings.isPeriodicalBackupEnabled, - force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, - ) - } - } - - fun init() { - settings.subscribe(this) - processLifecycleScope.launch(Dispatchers.Default) { - updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false) - updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) - updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false) - } - } - - private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { - processLifecycleScope.launch(Dispatchers.Default) { - updateWorkerImpl(scheduler, isEnabled, force) - } - } - - private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean, force: Boolean) { - if (force || scheduler.isScheduled() != isEnabled) { - if (isEnabled) { - scheduler.schedule() - } else { - scheduler.unschedule() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt deleted file mode 100644 index a408dd722..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.suggestions.domain - -import org.koitharu.kotatsu.core.util.ext.almostEquals -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag - -class TagsBlacklist( - private val tags: Set, - private val threshold: Float, -) { - - fun isNotEmpty() = tags.isNotEmpty() - - operator fun contains(manga: Manga): Boolean { - if (tags.isEmpty()) { - return false - } - for (mangaTag in manga.tags) { - for (tagTitle in tags) { - if (mangaTag.title.almostEquals(tagTitle, threshold)) { - return true - } - } - } - return false - } - - operator fun contains(tag: MangaTag): Boolean = tags.any { - it.almostEquals(tag.title, threshold) - } -} 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 deleted file mode 100644 index 207091960..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.koitharu.kotatsu.suggestions.ui - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner - -@AndroidEntryPoint -class SuggestionsActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, SuggestionsFragment::class.java, null) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) - } -} 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 deleted file mode 100644 index 2dcde8897..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.suggestions.ui - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.util.ext.onFirst -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository -import javax.inject.Inject - -@HiltViewModel -class SuggestionsViewModel @Inject constructor( - repository: SuggestionRepository, - settings: AppSettings, - private val extraProvider: ListExtraProvider, - downloadScheduler: DownloadWorker.Scheduler, - private val suggestionsScheduler: SuggestionsWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { - - override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_SUGGESTIONS) { suggestionsListMode } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.suggestionsListMode) - - override val content = combine( - repository.observeAll(), - listMode, - ) { list, mode -> - when { - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_suggestion_holder, - actionStringRes = 0, - ), - ) - - else -> list.toUi(mode, extraProvider) - } - }.onStart { - loadingCounter.increment() - }.onFirst { - loadingCounter.decrement() - }.catch { - emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - override fun onRefresh() = Unit - - override fun onRetry() = Unit - - fun updateSuggestions() { - suggestionsScheduler.startNow() - } -} 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 deleted file mode 100644 index 50fbd8398..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ /dev/null @@ -1,414 +0,0 @@ -package org.koitharu.kotatsu.suggestions.ui - -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.annotation.FloatRange -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.text.HtmlCompat -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.parseAsHtml -import androidx.hilt.work.HiltWorker -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.await -import androidx.work.workDataOf -import coil.ImageLoader -import coil.request.ImageRequest -import dagger.Reusable -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import org.koitharu.kotatsu.R -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.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.almostEquals -import org.koitharu.kotatsu.core.util.ext.asArrayList -import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -import org.koitharu.kotatsu.core.util.ext.flatten -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.sanitize -import org.koitharu.kotatsu.core.util.ext.takeMostFrequent -import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull -import org.koitharu.kotatsu.core.util.ext.trySetForeground -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.history.data.HistoryRepository -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.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler -import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion -import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository -import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import kotlin.math.pow -import kotlin.random.Random - -@HiltWorker -class SuggestionsWorker @AssistedInject constructor( - @Assisted appContext: Context, - @Assisted params: WorkerParameters, - private val coil: ImageLoader, - private val suggestionRepository: SuggestionRepository, - private val historyRepository: HistoryRepository, - private val favouritesRepository: FavouritesRepository, - private val appSettings: AppSettings, - private val mangaRepositoryFactory: MangaRepository.Factory, - private val sourcesRepository: MangaSourcesRepository, -) : CoroutineWorker(appContext, params) { - - private val notificationManager by lazy { NotificationManagerCompat.from(appContext) } - - override suspend fun doWork(): Result { - trySetForeground() - if (!appSettings.isSuggestionsEnabled) { - suggestionRepository.clear() - return Result.success() - } - val count = doWorkImpl() - val outputData = workDataOf(DATA_COUNT to count) - return Result.success(outputData) - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - val title = applicationContext.getString(R.string.suggestions_updating) - val channel = NotificationChannelCompat.Builder(WORKER_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(title) - .setShowBadge(true) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(true) - .build() - notificationManager.createNotificationChannel(channel) - - val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setDefaults(0) - .setOngoing(false) - .setSilent(true) - .setProgress(0, 0, true) - .setSmallIcon(android.R.drawable.stat_notify_sync) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) - .build() - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo(WORKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - ForegroundInfo(WORKER_NOTIFICATION_ID, notification) - } - } - - private suspend fun doWorkImpl(): Int { - val seed = ( - historyRepository.getList(0, 20) + - favouritesRepository.getLastManga(20) - ).distinctById() - val sources = sourcesRepository.getEnabledSources() - if (seed.isEmpty() || sources.isEmpty()) { - return 0 - } - val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD) - val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10) - - val semaphore = Semaphore(MAX_PARALLELISM) - val producer = channelFlow { - for (it in sources.shuffled()) { - launch { - semaphore.withPermit { - send(getList(it, tags, tagsBlacklist)) - } - } - } - } - val suggestions = producer - .flatten() - .take(MAX_RAW_RESULTS) - .map { manga -> - MangaSuggestion( - manga = manga, - relevance = computeRelevance(manga.tags, tags), - ) - }.toList() - .sortedBy { it.relevance } - .take(MAX_RESULTS) - suggestionRepository.replace(suggestions) - if (appSettings.isSuggestionsNotificationAvailable && applicationContext.checkNotificationPermission()) { - for (i in 0..3) { - try { - val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] - val details = mangaRepositoryFactory.create(manga.manga.source) - .getDetails(manga.manga) - if (details.chapters.isNullOrEmpty()) { - continue - } - if (details.rating > 0 && details.rating < RATING_MIN) { - continue - } - if (details.isNsfw && appSettings.isSuggestionsExcludeNsfw) { - continue - } - if (details in tagsBlacklist) { - continue - } - showNotification(details) - break - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTraceDebug() - } - } - } - return suggestions.size - } - - private suspend fun getList( - source: MangaSource, - tags: List, - blacklist: TagsBlacklist, - ): List = runCatchingCancellable { - val repository = mangaRepositoryFactory.create(source) - val availableOrders = repository.sortOrders - val order = preferredSortOrders.first { it in availableOrders } - val availableTags = repository.getTags() - val tag = tags.firstNotNullOfOrNull { title -> - availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) } - } - val list = repository.getList( - offset = 0, - filter = MangaListFilter.Advanced.Builder(order) - .tags(setOfNotNull(tag)) - .build(), - ).asArrayList() - if (appSettings.isSuggestionsExcludeNsfw) { - list.removeAll { it.isNsfw } - } - if (blacklist.isNotEmpty()) { - list.removeAll { manga -> manga in blacklist } - } - list.shuffle() - list.take(MAX_SOURCE_RESULTS) - }.onFailure { e -> - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) - } - e.printStackTraceDebug() - }.getOrDefault(emptyList()) - - @SuppressLint("MissingPermission") - private suspend fun showNotification(manga: Manga) { - val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(applicationContext.getString(R.string.suggestions)) - .setDescription(applicationContext.getString(R.string.suggestions_summary)) - .setLightsEnabled(true) - .setShowBadge(true) - .build() - notificationManager.createNotificationChannel(channel) - - val id = manga.url.hashCode() - val title = applicationContext.getString(R.string.suggestion_manga, manga.title) - val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID) - val tagsText = manga.tags.joinToString(", ") { it.title } - with(builder) { - setContentText(tagsText) - setContentTitle(title) - setLargeIcon( - coil.execute( - ImageRequest.Builder(applicationContext) - .data(manga.coverUrl) - .tag(manga.source) - .build(), - ).toBitmapOrNull(), - ) - setSmallIcon(R.drawable.ic_stat_suggestion) - val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)?.sanitize() - if (!description.isNullOrBlank()) { - val style = NotificationCompat.BigTextStyle() - style.bigText( - buildSpannedString { - append(tagsText) - val chaptersCount = manga.chapters?.size ?: 0 - appendLine() - bold { - append( - applicationContext.resources.getQuantityString( - R.plurals.chapters, - chaptersCount, - chaptersCount, - ), - ) - } - appendLine() - append(description) - }, - ) - style.setBigContentTitle(title) - setStyle(style) - } - val intent = DetailsActivity.newIntent(applicationContext, manga) - setContentIntent( - PendingIntentCompat.getActivity( - applicationContext, - id, - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - false, - ), - ) - setAutoCancel(true) - setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) - setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC) - setShortcutId(manga.id.toString()) - priority = NotificationCompat.PRIORITY_DEFAULT - - addAction( - R.drawable.ic_read, - applicationContext.getString(R.string.read), - PendingIntentCompat.getActivity( - applicationContext, - id + 2, - IntentBuilder(applicationContext).manga(manga).build(), - 0, - false, - ), - ) - - addAction( - R.drawable.ic_suggestion, - applicationContext.getString(R.string.more), - PendingIntentCompat.getActivity( - applicationContext, - 0, - SuggestionsActivity.newIntent(applicationContext), - 0, - false, - ), - ) - } - notificationManager.notify(TAG, id, builder.build()) - } - - @FloatRange(from = 0.0, to = 1.0) - private fun computeRelevance(mangaTags: Set, allTags: List): Float { - val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 - val weight = mangaTags.sumOf { tag -> - val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD) - if (index < 0) 0 else allTags.size - index - } - return (weight / maxWeight).pow(2.0).toFloat() - } - - private fun Iterable.inexactIndexOf(element: String, threshold: Float): Int { - forEachIndexed { i, t -> - if (t.almostEquals(element, threshold)) { - return i - } - } - return -1 - } - - @Reusable - class Scheduler @Inject constructor( - private val workManager: WorkManager, - private val settings: AppSettings, - ) : PeriodicWorkScheduler { - - override suspend fun schedule() { - val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) - .setConstraints(createConstraints()) - .addTag(TAG) - .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES) - .build() - workManager - .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) - .await() - } - - override suspend fun unschedule() { - workManager - .cancelUniqueWork(TAG) - .await() - } - - override suspend fun isScheduled(): Boolean { - return workManager - .awaitUniqueWorkInfoByName(TAG) - .any { !it.state.isFinished } - } - - fun startNow() { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG_ONESHOT) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - workManager.enqueue(request) - } - - private fun createConstraints() = Constraints.Builder() - .setRequiredNetworkType(if (settings.isSuggestionsWiFiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .build() - } - - private companion object { - - const val TAG = "suggestions" - const val TAG_ONESHOT = "suggestions_oneshot" - const val DATA_COUNT = "count" - const val WORKER_CHANNEL_ID = "suggestion_worker" - const val MANGA_CHANNEL_ID = "suggestions" - const val WORKER_NOTIFICATION_ID = 36 - const val MAX_RESULTS = 80 - const val MAX_PARALLELISM = 3 - const val MAX_SOURCE_RESULTS = 14 - const val MAX_RAW_RESULTS = 200 - const val TAG_EQ_THRESHOLD = 0.4f - const val RATING_MIN = 0.5f - - val preferredSortOrders = listOf( - SortOrder.UPDATED, - SortOrder.NEWEST, - SortOrder.POPULARITY, - SortOrder.RATING, - ) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt deleted file mode 100644 index b9f6b6683..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.koitharu.kotatsu.sync.data - -import dagger.Reusable -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONObject -import org.koitharu.kotatsu.core.exceptions.SyncApiException -import org.koitharu.kotatsu.core.network.BaseHttpClient -import org.koitharu.kotatsu.core.util.ext.toRequestBody -import org.koitharu.kotatsu.parsers.util.await -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.removeSurrounding -import javax.inject.Inject - -@Reusable -class SyncAuthApi @Inject constructor( - @BaseHttpClient private val okHttpClient: OkHttpClient, -) { - - suspend fun authenticate(host: String, email: String, password: String): String { - val body = JSONObject( - mapOf("email" to email, "password" to password), - ).toRequestBody() - val scheme = getScheme(host) - val request = Request.Builder() - .url("$scheme://$host/auth") - .post(body) - .build() - val response = okHttpClient.newCall(request).await() - if (response.isSuccessful) { - return response.parseJson().getString("token") - } else { - val code = response.code - val message = response.use { checkNotNull(it.body).string() }.removeSurrounding('"') - throw SyncApiException(message, code) - } - } - - private suspend fun getScheme(host: String): String { - val request = Request.Builder() - .url("http://$host/") - .head() - .build() - val response = okHttpClient.newCall(request).await() - return response.request.url.scheme - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt deleted file mode 100644 index dc660e381..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.koitharu.kotatsu.sync.data - -import android.accounts.Account -import android.accounts.AccountManager -import android.content.Context -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.CommonHeaders - -class SyncAuthenticator( - context: Context, - private val account: Account, - private val syncSettings: SyncSettings, - private val authApi: SyncAuthApi, -) : Authenticator { - - private val accountManager = AccountManager.get(context) - private val tokenType = context.getString(R.string.account_type_sync) - - override fun authenticate(route: Route?, response: Response): Request? { - val newToken = tryRefreshToken() ?: return null - accountManager.setAuthToken(account, tokenType, newToken) - return response.request.newBuilder() - .header(CommonHeaders.AUTHORIZATION, "Bearer $newToken") - .build() - } - - private fun tryRefreshToken() = runCatching { - runBlocking { - authApi.authenticate( - syncSettings.host, - account.name, - accountManager.getPassword(account), - ) - } - }.getOrNull() -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt deleted file mode 100644 index bcc677e6c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.koitharu.kotatsu.sync.data - -import android.accounts.Account -import android.accounts.AccountManager -import android.content.Context -import okhttp3.Interceptor -import okhttp3.Response -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.DATABASE_VERSION -import org.koitharu.kotatsu.core.network.CommonHeaders - -class SyncInterceptor( - context: Context, - private val account: Account, -) : Interceptor { - - private val accountManager = AccountManager.get(context) - private val tokenType = context.getString(R.string.account_type_sync) - - override fun intercept(chain: Interceptor.Chain): Response { - val token = accountManager.peekAuthToken(account, tokenType) - val requestBuilder = chain.request().newBuilder() - if (token != null) { - requestBuilder.header(CommonHeaders.AUTHORIZATION, "Bearer $token") - } - requestBuilder.header("X-App-Version", BuildConfig.VERSION_CODE.toString()) - requestBuilder.header("X-Db-Version", DATABASE_VERSION.toString()) - return chain.proceed(requestBuilder.build()) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt deleted file mode 100644 index 7e211e3d4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.sync.data - -import android.accounts.Account -import android.accounts.AccountManager -import android.content.Context -import androidx.annotation.WorkerThread -import dagger.Reusable -import dagger.hilt.android.qualifiers.ApplicationContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import javax.inject.Inject - -@Reusable -class SyncSettings( - context: Context, - private val account: Account?, -) { - - @Inject - constructor(@ApplicationContext context: Context) : this( - context, - AccountManager.get(context) - .getAccountsByType(context.getString(R.string.account_type_sync)) - .firstOrNull(), - ) - - private val accountManager = AccountManager.get(context) - private val defaultHost = context.getString(R.string.sync_host_default) - - @get:WorkerThread - @set:WorkerThread - var host: String - get() = account?.let { - accountManager.getUserData(it, KEY_HOST) - }.ifNullOrEmpty { defaultHost } - set(value) { - account?.let { - accountManager.setUserData(it, KEY_HOST, value) - } - } - - companion object { - - const val KEY_HOST = "host" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt deleted file mode 100644 index e8d29ec70..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.sync.domain - -data class SyncAuthResult( - val host: String, - val email: String, - val password: String, - val token: String, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncController.kt deleted file mode 100644 index e5646f7f4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncController.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.koitharu.kotatsu.sync.domain - -import android.accounts.Account -import android.accounts.AccountManager -import android.content.ContentResolver -import android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE -import android.content.Context -import android.os.Bundle -import androidx.room.InvalidationTracker -import androidx.room.withTransaction -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES -import org.koitharu.kotatsu.core.db.TABLE_HISTORY -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton - -@Singleton -class SyncController @Inject constructor( - @ApplicationContext context: Context, - private val dbProvider: Provider, -) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) { - - private val authorityHistory = context.getString(R.string.sync_authority_history) - private val authorityFavourites = context.getString(R.string.sync_authority_favourites) - private val am = AccountManager.get(context) - private val accountType = context.getString(R.string.account_type_sync) - private val mutex = Mutex() - private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled - - override fun onInvalidated(tables: Set) { - val favourites = (TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables) - && !isSyncActiveOrPending(authorityFavourites) - val history = TABLE_HISTORY in tables && !isSyncActiveOrPending(authorityHistory) - if (favourites || history) { - requestSync(favourites, history) - } - } - - fun isEnabled(account: Account): Boolean { - return ContentResolver.getMasterSyncAutomatically() && (ContentResolver.getSyncAutomatically( - account, - authorityFavourites, - ) || ContentResolver.getSyncAutomatically( - account, - authorityHistory, - )) - } - - fun getLastSync(account: Account, authority: String): Long { - val key = "last_sync_" + authority.substringAfterLast('.') - val rawValue = am.getUserData(account, key) ?: return 0L - return rawValue.toLongOrNull() ?: 0L - } - - fun observeSyncStatus(): Flow = callbackFlow { - val handle = ContentResolver.addStatusChangeListener(SYNC_OBSERVER_TYPE_ACTIVE) { which -> - trySendBlocking(which and SYNC_OBSERVER_TYPE_ACTIVE != 0) - } - awaitClose { ContentResolver.removeStatusChangeListener(handle) } - } - - suspend fun requestFullSync() = withContext(Dispatchers.Default) { - requestSyncImpl(favourites = true, history = true) - } - - private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) { - requestSyncImpl(favourites = favourites, history = history) - } - - private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean) = mutex.withLock { - if (!favourites && !history) { - return - } - val db = dbProvider.get() - val account = peekAccount() - if (account == null || !ContentResolver.getMasterSyncAutomatically()) { - db.gc(favourites, history) - return - } - var gcHistory = false - var gcFavourites = false - if (favourites) { - if (ContentResolver.getSyncAutomatically(account, authorityFavourites)) { - ContentResolver.requestSync(account, authorityFavourites, Bundle.EMPTY) - } else { - gcFavourites = true - } - } - if (history) { - if (ContentResolver.getSyncAutomatically(account, authorityHistory)) { - ContentResolver.requestSync(account, authorityHistory, Bundle.EMPTY) - } else { - gcHistory = true - } - } - if (gcHistory || gcFavourites) { - db.gc(gcFavourites, gcHistory) - } - } - - private fun peekAccount(): Account? { - return am.getAccountsByType(accountType).firstOrNull() - } - - private suspend fun MangaDatabase.gc(favourites: Boolean, history: Boolean) = withTransaction { - val deletedAt = System.currentTimeMillis() - defaultGcPeriod - if (history) { - getHistoryDao().gc(deletedAt) - } - if (favourites) { - getFavouritesDao().gc(deletedAt) - getFavouriteCategoriesDao().gc(deletedAt) - } - } - - private fun isSyncActiveOrPending(authority: String): Boolean { - val account = peekAccount() ?: return false - return ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority) - } - - companion object { - - @JvmStatic - fun setLastSync(context: Context, account: Account, authority: String, time: Long) { - val key = "last_sync_" + authority.substringAfterLast('.') - val am = AccountManager.get(context) - am.setUserData(account, key, time.toString()) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt deleted file mode 100644 index 803d5a2d6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ /dev/null @@ -1,329 +0,0 @@ -package org.koitharu.kotatsu.sync.domain - -import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentProviderOperation -import android.content.ContentProviderResult -import android.content.Context -import android.content.OperationApplicationException -import android.content.SyncResult -import android.content.SyncStats -import android.database.Cursor -import android.net.Uri -import androidx.annotation.WorkerThread -import androidx.core.content.contentValuesOf -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.json.JSONArray -import org.json.JSONObject -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES -import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES -import org.koitharu.kotatsu.core.db.TABLE_HISTORY -import org.koitharu.kotatsu.core.db.TABLE_MANGA -import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS -import org.koitharu.kotatsu.core.db.TABLE_TAGS -import org.koitharu.kotatsu.core.logs.FileLogger -import org.koitharu.kotatsu.core.logs.SyncLogger -import org.koitharu.kotatsu.core.network.BaseHttpClient -import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull -import org.koitharu.kotatsu.core.util.ext.toContentValues -import org.koitharu.kotatsu.core.util.ext.toJson -import org.koitharu.kotatsu.core.util.ext.toRequestBody -import org.koitharu.kotatsu.parsers.util.json.mapJSONTo -import org.koitharu.kotatsu.sync.data.SyncAuthApi -import org.koitharu.kotatsu.sync.data.SyncAuthenticator -import org.koitharu.kotatsu.sync.data.SyncInterceptor -import org.koitharu.kotatsu.sync.data.SyncSettings -import java.util.concurrent.TimeUnit - -private const val FIELD_TIMESTAMP = "timestamp" - -class SyncHelper @AssistedInject constructor( - @ApplicationContext context: Context, - @BaseHttpClient baseHttpClient: OkHttpClient, - @Assisted private val account: Account, - @Assisted private val provider: ContentProviderClient, - private val settings: SyncSettings, - @SyncLogger private val logger: FileLogger, -) { - - private val authorityHistory = context.getString(R.string.sync_authority_history) - private val authorityFavourites = context.getString(R.string.sync_authority_favourites) - private val httpClient = baseHttpClient.newBuilder() - .authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient()))) - .addInterceptor(SyncInterceptor(context, account)) - .build() - private val baseUrl: String by lazy { - val host = settings.host - val scheme = getScheme(host) - "$scheme://$host" - } - private val defaultGcPeriod: Long // gc period if sync enabled - get() = TimeUnit.DAYS.toMillis(4) - - @WorkerThread - fun syncFavourites(stats: SyncStats) { - val data = JSONObject() - data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories()) - data.put(TABLE_FAVOURITES, getFavourites()) - data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) - val request = Request.Builder() - .url("$baseUrl/resource/$TABLE_FAVOURITES") - .post(data.toRequestBody()) - .build() - val response = httpClient.newCall(request).execute().log().parseJsonOrNull() - if (response != null) { - val timestamp = response.getLong(FIELD_TIMESTAMP) - val categoriesResult = - upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp) - stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L - stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } - val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES), timestamp) - stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L - stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } - stats.numEntries += stats.numInserts + stats.numDeletes - } - gcFavourites() - } - - @WorkerThread - fun syncHistory(stats: SyncStats) { - val data = JSONObject() - data.put(TABLE_HISTORY, getHistory()) - data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) - val request = Request.Builder() - .url("$baseUrl/resource/$TABLE_HISTORY") - .post(data.toRequestBody()) - .build() - val response = httpClient.newCall(request).execute().log().parseJsonOrNull() - if (response != null) { - val result = upsertHistory( - json = response.getJSONArray(TABLE_HISTORY), - timestamp = response.getLong(FIELD_TIMESTAMP), - ) - stats.numDeletes += result.first().count?.toLong() ?: 0L - stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L } - stats.numEntries += stats.numInserts + stats.numDeletes - } - gcHistory() - } - - fun onError(e: Throwable) { - if (logger.isEnabled) { - logger.log("Sync error", e) - } - } - - fun onSyncComplete(result: SyncResult) { - if (logger.isEnabled) { - logger.log("Sync finshed: ${result.toDebugString()}") - logger.flushBlocking() - } - } - - private fun upsertHistory(json: JSONArray, timestamp: Long): Array { - val uri = uri(authorityHistory, TABLE_HISTORY) - val operations = ArrayList() - json.mapJSONTo(operations) { jo -> - operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory)) - ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) - .build() - } - return provider.applyBatch(operations) - } - - private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array { - val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) - val operations = ArrayList() - json.mapJSONTo(operations) { jo -> - ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) - .build() - } - return provider.applyBatch(operations) - } - - private fun upsertFavourites(json: JSONArray, timestamp: Long): Array { - val uri = uri(authorityFavourites, TABLE_FAVOURITES) - val operations = ArrayList() - json.mapJSONTo(operations) { jo -> - operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites)) - ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) - .build() - } - return provider.applyBatch(operations) - } - - private fun upsertManga(json: JSONObject, authority: String): List { - val tags = json.removeJSONArray(TABLE_TAGS) - val result = ArrayList(tags.length() * 2 + 1) - for (i in 0 until tags.length()) { - val tag = tags.getJSONObject(i) - result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS)) - .withValues(tag.toContentValues()) - .build() - result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS)) - .withValues( - contentValuesOf( - "manga_id" to json.getLong("manga_id"), - "tag_id" to tag.getLong("tag_id"), - ), - ).build() - } - result.add( - 0, - ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA)) - .withValues(json.toContentValues()) - .build(), - ) - return result - } - - private fun getHistory(): JSONArray { - return provider.query(authorityHistory, TABLE_HISTORY).use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - val jo = cursor.toJson() - jo.put("manga", getManga(authorityHistory, jo.getLong("manga_id"))) - json.put(jo) - } while (cursor.moveToNext()) - } - json - } - } - - private fun getFavourites(): JSONArray { - return provider.query(authorityFavourites, TABLE_FAVOURITES).use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - val jo = cursor.toJson() - jo.put("manga", getManga(authorityFavourites, jo.getLong("manga_id"))) - json.put(jo) - } while (cursor.moveToNext()) - } - json - } - } - - private fun getFavouriteCategories(): JSONArray { - return provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - json.put(cursor.toJson()) - } while (cursor.moveToNext()) - } - json - } - } - - private fun getManga(authority: String, id: Long): JSONObject { - val manga = provider.query( - uri(authority, TABLE_MANGA), - null, - "manga_id = ?", - arrayOf(id.toString()), - null, - )?.use { cursor -> - cursor.moveToFirst() - cursor.toJson() - } - requireNotNull(manga) - val tags = provider.query( - uri(authority, TABLE_MANGA_TAGS), - arrayOf("tag_id"), - "manga_id = ?", - arrayOf(id.toString()), - null, - )?.use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - val tagId = cursor.getLong(0) - json.put(getTag(authority, tagId)) - } while (cursor.moveToNext()) - } - json - } - manga.put("tags", requireNotNull(tags)) - return manga - } - - private fun getTag(authority: String, tagId: Long): JSONObject { - val tag = provider.query( - uri(authority, TABLE_TAGS), - null, - "tag_id = ?", - arrayOf(tagId.toString()), - null, - )?.use { cursor -> - if (cursor.moveToFirst()) { - cursor.toJson() - } else { - null - } - } - return requireNotNull(tag) - } - - private fun getScheme(host: String): String { - val request = Request.Builder() - .url("http://$host/") - .head() - .build() - val response = httpClient.newCall(request).execute() - return response.request.url.scheme - } - - private fun gcFavourites() { - val deletedAt = System.currentTimeMillis() - defaultGcPeriod - val selection = "deleted_at != 0 AND deleted_at < ?" - val args = arrayOf(deletedAt.toString()) - provider.delete(uri(authorityFavourites, TABLE_FAVOURITES), selection, args) - provider.delete(uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES), selection, args) - } - - private fun gcHistory() { - val deletedAt = System.currentTimeMillis() - defaultGcPeriod - val selection = "deleted_at != 0 AND deleted_at < ?" - val args = arrayOf(deletedAt.toString()) - provider.delete(uri(authorityHistory, TABLE_HISTORY), selection, args) - } - - private fun ContentProviderClient.query(authority: String, table: String): Cursor { - val uri = uri(authority, table) - return query(uri, null, null, null, null) - ?: throw OperationApplicationException("Query failed: $uri") - } - - private fun uri(authority: String, table: String) = Uri.parse("content://$authority/$table") - - private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject - - private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray - - private fun Response.log() = apply { - if (logger.isEnabled) { - logger.log("$code ${request.url}") - } - } - - @AssistedFactory - interface Factory { - - fun create( - account: Account, - contentProviderClient: ContentProviderClient, - ): SyncHelper - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt deleted file mode 100644 index c824587ff..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.accounts.AbstractAccountAuthenticator -import android.accounts.Account -import android.accounts.AccountAuthenticatorResponse -import android.accounts.AccountManager -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.TextUtils - -class SyncAccountAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) { - - override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null - - override fun addAccount( - response: AccountAuthenticatorResponse?, - accountType: String?, - authTokenType: String?, - requiredFeatures: Array?, - options: Bundle?, - ): Bundle { - val intent = Intent(context, SyncAuthActivity::class.java) - intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) - val bundle = Bundle() - if (options != null) { - bundle.putAll(options) - } - bundle.putParcelable(AccountManager.KEY_INTENT, intent) - return bundle - } - - override fun confirmCredentials( - response: AccountAuthenticatorResponse?, - account: Account?, - options: Bundle?, - ): Bundle? = null - - override fun getAuthToken( - response: AccountAuthenticatorResponse?, - account: Account, - authTokenType: String?, - options: Bundle?, - ): Bundle { - val result = Bundle() - val am = AccountManager.get(context.applicationContext) - val authToken = am.peekAuthToken(account, authTokenType) - if (!TextUtils.isEmpty(authToken)) { - result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) - result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) - result.putString(AccountManager.KEY_AUTHTOKEN, authToken) - } else { - val intent = Intent(context, SyncAuthActivity::class.java) - intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) - val bundle = Bundle() - if (options != null) { - bundle.putAll(options) - } - bundle.putParcelable(AccountManager.KEY_INTENT, intent) - } - return result - } - - override fun getAuthTokenLabel(authTokenType: String?): String? = null - - override fun updateCredentials( - response: AccountAuthenticatorResponse?, - account: Account?, - authTokenType: String?, - options: Bundle?, - ): Bundle? = null - - override fun hasFeatures( - response: AccountAuthenticatorResponse?, - account: Account?, - features: Array?, - ): Bundle? = null -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAdapterEntryPoint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAdapterEntryPoint.kt deleted file mode 100644 index 305405400..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAdapterEntryPoint.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import org.koitharu.kotatsu.sync.domain.SyncHelper - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface SyncAdapterEntryPoint { - val syncHelperFactory: SyncHelper.Factory -} 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 deleted file mode 100644 index 2f90ac328..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt +++ /dev/null @@ -1,215 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.accounts.Account -import android.accounts.AccountAuthenticatorResponse -import android.accounts.AccountManager -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.widget.Button -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.fragment.app.FragmentResultListener -import androidx.transition.Fade -import androidx.transition.TransitionManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding -import org.koitharu.kotatsu.sync.data.SyncSettings -import org.koitharu.kotatsu.sync.domain.SyncAuthResult - -@AndroidEntryPoint -class SyncAuthActivity : BaseActivity(), View.OnClickListener, FragmentResultListener { - - private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null - private var resultBundle: Bundle? = null - private val pageBackCallback = PageBackCallback() - - private val viewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivitySyncAuthBinding.inflate(layoutInflater)) - accountAuthenticatorResponse = - intent.getParcelableExtraCompat(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) - accountAuthenticatorResponse?.onRequestContinued() - viewBinding.buttonCancel.setOnClickListener(this) - viewBinding.buttonNext.setOnClickListener(this) - viewBinding.buttonBack.setOnClickListener(this) - viewBinding.buttonDone.setOnClickListener(this) - viewBinding.layoutProgress.setOnClickListener(this) - viewBinding.buttonSettings.setOnClickListener(this) - viewBinding.editEmail.addTextChangedListener(EmailTextWatcher(viewBinding.buttonNext)) - viewBinding.editPassword.addTextChangedListener(PasswordTextWatcher(viewBinding.buttonDone)) - - onBackPressedDispatcher.addCallback(pageBackCallback) - - viewModel.onTokenObtained.observeEvent(this, ::onTokenReceived) - viewModel.onError.observeEvent(this, ::onError) - viewModel.isLoading.observe(this, ::onLoadingStateChanged) - viewModel.onAccountAlreadyExists.observeEvent(this) { - onAccountAlreadyExists() - } - - supportFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, this, this) - pageBackCallback.update() - } - - override fun onWindowInsetsChanged(insets: Insets) { - val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, - ) - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_cancel -> { - setResult(RESULT_CANCELED) - finish() - } - - R.id.button_next -> { - viewBinding.groupLogin.isVisible = false - viewBinding.groupPassword.isVisible = true - pageBackCallback.update() - viewBinding.editPassword.requestFocus() - } - - R.id.button_back -> { - viewBinding.groupPassword.isVisible = false - viewBinding.groupLogin.isVisible = true - pageBackCallback.update() - viewBinding.editEmail.requestFocus() - } - - R.id.button_done -> { - viewModel.obtainToken( - email = viewBinding.editEmail.text.toString(), - password = viewBinding.editPassword.text.toString(), - ) - } - - R.id.button_settings -> { - SyncHostDialogFragment.show(supportFragmentManager, viewModel.host.value) - } - } - } - - override fun onFragmentResult(requestKey: String, result: Bundle) { - val host = result.getString(SyncHostDialogFragment.KEY_HOST) ?: return - viewModel.host.value = host - } - - override fun finish() { - accountAuthenticatorResponse?.let { response -> - resultBundle?.also { - response.onResult(it) - } ?: response.onError(AccountManager.ERROR_CODE_CANCELED, getString(R.string.canceled)) - } - super.finish() - } - - private fun onLoadingStateChanged(isLoading: Boolean) { - if (isLoading == viewBinding.layoutProgress.isVisible) { - return - } - TransitionManager.beginDelayedTransition(viewBinding.root, Fade()) - viewBinding.layoutProgress.isVisible = isLoading - pageBackCallback.update() - } - - private fun onError(error: Throwable) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.error) - .setMessage(error.getDisplayMessage(resources)) - .setNegativeButton(R.string.close, null) - .show() - } - - private fun onTokenReceived(authResult: SyncAuthResult) { - val am = AccountManager.get(this) - val account = Account(authResult.email, getString(R.string.account_type_sync)) - val userdata = Bundle(1) - userdata.putString(SyncSettings.KEY_HOST, authResult.host) - val result = Bundle() - if (am.addAccountExplicitly(account, authResult.password, userdata)) { - result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) - result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) - result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token) - am.setAuthToken(account, account.type, authResult.token) - } else { - result.putString(AccountManager.KEY_ERROR_MESSAGE, getString(R.string.account_already_exists)) - } - resultBundle = result - setResult(RESULT_OK) - finish() - } - - private fun onAccountAlreadyExists() { - Toast.makeText(this, R.string.account_already_exists, Toast.LENGTH_SHORT) - .show() - accountAuthenticatorResponse?.onError( - AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION, - getString(R.string.account_already_exists), - ) - super.finishAfterTransition() - } - - private class EmailTextWatcher( - private val button: Button, - ) : TextWatcher { - - private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE) - - 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 text = s?.toString() - button.isEnabled = !text.isNullOrEmpty() && regexEmail.matches(text) - } - } - - 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 - - override fun afterTextChanged(s: Editable?) { - val text = s?.toString() - button.isEnabled = text != null && text.length >= 4 - } - } - - private inner class PageBackCallback : OnBackPressedCallback(false) { - - override fun handleOnBackPressed() { - viewBinding.groupLogin.isVisible = true - viewBinding.groupPassword.isVisible = false - viewBinding.editEmail.requestFocus() - update() - } - - fun update() { - isEnabled = !viewBinding.layoutProgress.isVisible && viewBinding.groupPassword.isVisible - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt deleted file mode 100644 index be890d55c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.accounts.AccountManager -import android.content.Context -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.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.sync.data.SyncAuthApi -import org.koitharu.kotatsu.sync.domain.SyncAuthResult -import javax.inject.Inject - -@HiltViewModel -class SyncAuthViewModel @Inject constructor( - @ApplicationContext context: Context, - private val api: SyncAuthApi, -) : BaseViewModel() { - - val onAccountAlreadyExists = MutableEventFlow() - val onTokenObtained = MutableEventFlow() - val host = MutableStateFlow(context.getString(R.string.sync_host_default)) - - init { - launchJob(Dispatchers.Default) { - val am = AccountManager.get(context) - val accounts = am.getAccountsByType(context.getString(R.string.account_type_sync)) - if (accounts.isNotEmpty()) { - onAccountAlreadyExists.call(Unit) - } - } - } - - fun obtainToken(email: String, password: String) { - val hostValue = host.value - launchLoadingJob(Dispatchers.Default) { - val token = api.authenticate(hostValue, email, password) - val result = SyncAuthResult(host.value, email, password, token) - onTokenObtained.call(result) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt deleted file mode 100644 index 1262a9e9c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.app.Service -import android.content.Intent -import android.os.IBinder - -class SyncAuthenticatorService : Service() { - - private lateinit var authenticator: SyncAccountAuthenticator - - override fun onCreate() { - super.onCreate() - authenticator = SyncAccountAuthenticator(this) - } - - override fun onBind(intent: Intent?): IBinder? { - return authenticator.iBinder - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt deleted file mode 100644 index 6c22040a7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.widget.ArrayAdapter -import androidx.core.os.bundleOf -import androidx.core.view.updateLayoutParams -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.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding -import org.koitharu.kotatsu.settings.utils.validation.DomainValidator -import org.koitharu.kotatsu.sync.data.SyncSettings -import javax.inject.Inject - -@AndroidEntryPoint -class SyncHostDialogFragment : AlertDialogFragment(), - DialogInterface.OnClickListener { - - @Inject - lateinit var syncSettings: SyncSettings - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup? - ) = PreferenceDialogAutocompletetextviewBinding.inflate(inflater, container, false) - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, this) - .setCancelable(false) - .setTitle(R.string.server_address) - } - - override fun onViewBindingCreated( - binding: PreferenceDialogAutocompletetextviewBinding, - savedInstanceState: Bundle? - ) { - super.onViewBindingCreated(binding, savedInstanceState) - binding.message.updateLayoutParams { - topMargin = binding.root.resources.getDimensionPixelOffset(R.dimen.screen_padding) - bottomMargin = topMargin - } - binding.message.setText(R.string.sync_host_description) - val entries = binding.root.resources.getStringArray(R.array.sync_host_list) - val editText = binding.edit - editText.setText(arguments?.getString(KEY_HOST).ifNullOrEmpty { syncSettings.host }) - editText.threshold = 0 - editText.setAdapter(ArrayAdapter(binding.root.context, android.R.layout.simple_spinner_dropdown_item, entries)) - binding.dropdown.setOnClickListener { - editText.showDropDown() - } - DomainValidator().attachToEditText(editText) - } - - override fun onClick(dialog: DialogInterface, which: Int) { - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - val result = requireViewBinding().edit.text?.toString().orEmpty() - syncSettings.host = result - parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(KEY_HOST to result)) - } - } - dialog.dismiss() - } - - companion object { - - private const val TAG = "SyncHostDialogFragment" - const val REQUEST_KEY = "sync_host" - const val KEY_HOST = "host" - - fun show(fm: FragmentManager, host: String?) = SyncHostDialogFragment().withArgs(1) { - putString(KEY_HOST, host) - }.show(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt deleted file mode 100644 index 3e9d75c85..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt +++ /dev/null @@ -1,124 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.content.ContentProvider -import android.content.ContentProviderOperation -import android.content.ContentProviderResult -import android.content.ContentValues -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import android.net.Uri -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.SupportSQLiteQueryBuilder -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import org.koitharu.kotatsu.core.db.* -import java.util.concurrent.Callable - -abstract class SyncProvider : ContentProvider() { - - private val entryPoint by lazy { - EntryPointAccessors.fromApplication(checkNotNull(context), SyncProviderEntryPoint::class.java) - } - private val database by lazy { entryPoint.database } - - private val supportedTables = setOf( - TABLE_FAVOURITES, - TABLE_MANGA, - TABLE_TAGS, - TABLE_FAVOURITE_CATEGORIES, - TABLE_HISTORY, - TABLE_MANGA_TAGS, - ) - - override fun onCreate(): Boolean { - return true - } - - override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - sortOrder: String?, - ): Cursor? { - val tableName = getTableName(uri) ?: return null - val sqlQuery = SupportSQLiteQueryBuilder.builder(tableName) - .columns(projection) - .selection(selection, selectionArgs) - .orderBy(sortOrder) - .create() - return database.openHelper.readableDatabase.query(sqlQuery) - } - - override fun getType(uri: Uri): String? { - return getTableName(uri)?.let { "vnd.android.cursor.dir/" } - } - - override fun insert(uri: Uri, values: ContentValues?): Uri? { - val table = getTableName(uri) - if (values == null || table == null) { - return null - } - val db = database.openHelper.writableDatabase - if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) { - db.update(table, values) - } - return uri - } - - override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { - val table = getTableName(uri) ?: return 0 - return database.openHelper.writableDatabase.delete(table, selection, selectionArgs) - } - - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { - val table = getTableName(uri) - if (values == null || table == null) { - return 0 - } - return database.openHelper.writableDatabase - .update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs) - } - - override fun applyBatch(operations: ArrayList): Array { - return runAtomicTransaction { super.applyBatch(operations) } - } - - override fun bulkInsert(uri: Uri, values: Array): Int { - return runAtomicTransaction { super.bulkInsert(uri, values) } - } - - private fun getTableName(uri: Uri): String? { - return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables } - } - - private fun runAtomicTransaction(callable: Callable): R { - return synchronized(database) { - database.runInTransaction(callable) - } - } - - private fun SupportSQLiteDatabase.update(table: String, values: ContentValues) { - val keys = when (table) { - TABLE_TAGS -> listOf("tag_id") - TABLE_MANGA_TAGS -> listOf("tag_id", "manga_id") - TABLE_MANGA -> listOf("manga_id") - TABLE_FAVOURITES -> listOf("manga_id", "category_id") - TABLE_FAVOURITE_CATEGORIES -> listOf("category_id") - TABLE_HISTORY -> listOf("manga_id") - else -> throw IllegalArgumentException("Update for $table is not supported") - } - val whereClause = keys.joinToString(" AND ") { "`$it` = ?" } - val whereArgs = Array(keys.size) { i -> values.get("`${keys[i]}`") ?: values.get(keys[i]) } - this.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, whereClause, whereArgs) - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface SyncProviderEntryPoint { - - val database: MangaDatabase - } -} 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/sync/ui/favourites/FavouritesSyncAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt deleted file mode 100644 index 7e4d58ef5..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.favourites - -import android.accounts.Account -import android.content.AbstractThreadedSyncAdapter -import android.content.ContentProviderClient -import android.content.Context -import android.content.SyncResult -import android.os.Bundle -import dagger.hilt.android.EntryPointAccessors -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.onError -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint - -class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) { - - override fun onPerformSync( - account: Account, - extras: Bundle, - authority: String, - provider: ContentProviderClient, - syncResult: SyncResult, - ) { - if (!context.resources.getBoolean(R.bool.is_sync_enabled)) { - return - } - val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) - val syncHelper = entryPoint.syncHelperFactory.create(account, provider) - runCatchingCancellable { - syncHelper.syncFavourites(syncResult.stats) - SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) - }.onFailure { e -> - syncResult.onError(e) - syncHelper.onError(e) - } - syncHelper.onSyncComplete(syncResult) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt deleted file mode 100644 index d09666ee6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.favourites - -import org.koitharu.kotatsu.sync.ui.SyncProvider - -class FavouritesSyncProvider : SyncProvider() \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt deleted file mode 100644 index 397b4e144..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.favourites - -import android.app.Service -import android.content.Intent -import android.os.IBinder - -class FavouritesSyncService : Service() { - - private lateinit var syncAdapter: FavouritesSyncAdapter - - override fun onCreate() { - super.onCreate() - syncAdapter = FavouritesSyncAdapter(applicationContext) - } - - override fun onBind(intent: Intent?): IBinder { - return syncAdapter.syncAdapterBinder - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt deleted file mode 100644 index fe4dbe74a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.history - -import android.accounts.Account -import android.content.AbstractThreadedSyncAdapter -import android.content.ContentProviderClient -import android.content.Context -import android.content.SyncResult -import android.os.Bundle -import dagger.hilt.android.EntryPointAccessors -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.onError -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.sync.ui.SyncAdapterEntryPoint - -class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) { - - override fun onPerformSync( - account: Account, - extras: Bundle, - authority: String, - provider: ContentProviderClient, - syncResult: SyncResult, - ) { - if (!context.resources.getBoolean(R.bool.is_sync_enabled)) { - return - } - val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) - val syncHelper = entryPoint.syncHelperFactory.create(account, provider) - runCatchingCancellable { - syncHelper.syncHistory(syncResult.stats) - SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) - }.onFailure { e -> - syncResult.onError(e) - syncHelper.onError(e) - } - syncHelper.onSyncComplete(syncResult) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt deleted file mode 100644 index f4bf2cdd3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.history - -import org.koitharu.kotatsu.sync.ui.SyncProvider - -class HistorySyncProvider : SyncProvider() \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt deleted file mode 100644 index 4fdc8f00e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.sync.ui.history - -import android.app.Service -import android.content.Intent -import android.os.IBinder - -class HistorySyncService : Service() { - - private lateinit var syncAdapter: HistorySyncAdapter - - override fun onCreate() { - super.onCreate() - syncAdapter = HistorySyncAdapter(applicationContext) - } - - override fun onBind(intent: Intent?): IBinder { - return syncAdapter.syncAdapterBinder - } -} \ No newline at end of file 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 deleted file mode 100644 index 2f84a7b61..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.koitharu.kotatsu.tracker.data - -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.db.entity.toMangaTags -import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem -import java.time.Instant - -fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap): TrackingLogItem { - val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() } - return TrackingLogItem( - id = trackLog.id, - chapters = chaptersList, - manga = manga.toManga(tags.toMangaTags()), - createdAt = Instant.ofEpochMilli(trackLog.createdAt), - isNew = counters.decrement(trackLog.mangaId, chaptersList.size), - ) -} - -private fun MutableMap.decrement(key: Long, count: Int): Boolean = synchronized(this) { - val counter = get(key) - if (counter == null || counter <= 0) { - return false - } - if (counter < count) { - remove(key) - } else { - put(key, counter - count) - } - return true -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt deleted file mode 100644 index d0c8267f2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TracksDao.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.koitharu.kotatsu.tracker.data - -import androidx.room.Dao -import androidx.room.MapColumn -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Upsert -import kotlinx.coroutines.flow.Flow -import org.koitharu.kotatsu.core.db.entity.MangaWithTags - -@Dao -abstract class TracksDao { - - @Query("SELECT * FROM tracks") - abstract suspend fun findAll(): List - - @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)") - abstract suspend fun findAll(ids: Collection): List - - @Query("SELECT * FROM tracks WHERE manga_id = :mangaId") - abstract suspend fun find(mangaId: Long): TrackEntity? - - @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") - abstract suspend fun findNewChapters(mangaId: Long): Int? - - @Query("SELECT manga_id, chapters_new FROM tracks") - abstract fun observeNewChaptersMap(): Flow> - - @Query("SELECT chapters_new FROM tracks") - abstract fun observeNewChapters(): Flow> - - @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId") - abstract fun observeNewChapters(mangaId: Long): Flow - - @Transaction - @Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC") - abstract fun observeUpdatedManga(): Flow> - - @Transaction - @Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC LIMIT :limit") - abstract fun observeUpdatedManga(limit: Int): Flow> - - @Query("DELETE FROM tracks") - abstract suspend fun clear() - - @Query("UPDATE tracks SET chapters_new = 0") - abstract suspend fun clearCounters() - - @Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId") - abstract suspend fun clearCounter(mangaId: Long) - - @Query("DELETE FROM tracks WHERE manga_id = :mangaId") - abstract suspend fun delete(mangaId: Long) - - @Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites WHERE category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1))") - abstract suspend fun gc() - - @Upsert - abstract suspend fun upsert(entity: TrackEntity) -} 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 deleted file mode 100644 index 406735fb9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ /dev/null @@ -1,253 +0,0 @@ -package org.koitharu.kotatsu.tracker.domain - -import androidx.annotation.VisibleForTesting -import androidx.room.withTransaction -import dagger.Reusable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.toManga -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.model.isLocal -import org.koitharu.kotatsu.core.util.ext.mapItems -import org.koitharu.kotatsu.favourites.data.toFavouriteCategory -import org.koitharu.kotatsu.local.data.LocalMangaRepository -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.tracker.data.TrackEntity -import org.koitharu.kotatsu.tracker.data.TrackLogEntity -import org.koitharu.kotatsu.tracker.data.toTrackingLogItem -import org.koitharu.kotatsu.tracker.domain.model.MangaTracking -import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates -import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem -import java.time.Instant -import java.util.concurrent.atomic.AtomicBoolean -import javax.inject.Inject -import javax.inject.Provider - -private const val NO_ID = 0L -private const val MAX_QUERY_IDS = 100 - -@Reusable -class TrackingRepository @Inject constructor( - private val db: MangaDatabase, - private val localMangaRepositoryProvider: Provider, -) { - - private var isGcCalled = AtomicBoolean(false) - - suspend fun getNewChaptersCount(mangaId: Long): Int { - return db.getTracksDao().findNewChapters(mangaId) ?: 0 - } - - fun observeNewChaptersCount(mangaId: Long): Flow { - return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 } - } - - fun observeUpdatedMangaCount(): Flow { - return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } } - .onStart { gcIfNotCalled() } - } - - fun observeUpdatedManga(limit: Int = 0): Flow> { - return if (limit == 0) { - db.getTracksDao().observeUpdatedManga() - } else { - db.getTracksDao().observeUpdatedManga(limit) - }.mapItems { it.toManga() } - .distinctUntilChanged() - .onStart { gcIfNotCalled() } - } - - suspend fun getTracks(mangaList: Collection): List { - val ids = mangaList.mapToSet { it.id } - val dao = db.getTracksDao() - val tracks = if (ids.size <= MAX_QUERY_IDS) { - dao.findAll(ids) - } else { - // TODO split tracks in the worker - ids.windowed(MAX_QUERY_IDS, MAX_QUERY_IDS, true) - .flatMap { dao.findAll(it) } - }.groupBy { it.mangaId } - val idSet = HashSet() - val result = ArrayList(mangaList.size) - for (item in mangaList) { - val manga = if (item.isLocal) { - localMangaRepositoryProvider.get().getRemoteManga(item) ?: continue - } else { - item - } - if (!idSet.add(manga.id)) { - continue - } - val track = tracks[manga.id]?.lastOrNull() - result += MangaTracking( - manga = manga, - lastChapterId = track?.lastChapterId ?: NO_ID, - lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli), - ) - } - return result - } - - @VisibleForTesting - suspend fun getTrack(manga: Manga): MangaTracking { - val track = db.getTracksDao().find(manga.id) - return MangaTracking( - manga = manga, - lastChapterId = track?.lastChapterId ?: NO_ID, - lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(Instant::ofEpochMilli), - ) - } - - @VisibleForTesting - suspend fun deleteTrack(mangaId: Long) { - db.getTracksDao().delete(mangaId) - } - - fun observeTrackingLog(limit: Flow): Flow> { - return limit.flatMapLatest { limitValue -> - combine( - db.getTracksDao().observeNewChaptersMap(), - db.getTrackLogsDao().observeAll(limitValue), - ) { counters, entities -> - val countersMap = counters.toMutableMap() - entities.map { x -> x.toTrackingLogItem(countersMap) } - } - }.onStart { - gcIfNotCalled() - } - } - - suspend fun getLogsCount() = db.getTrackLogsDao().count() - - suspend fun clearLogs() = db.getTrackLogsDao().clear() - - suspend fun clearCounters() = db.getTracksDao().clearCounters() - - suspend fun gc() { - db.getTracksDao().gc() - db.getTrackLogsDao().gc() - } - - suspend fun saveUpdates(updates: MangaUpdates.Success) { - db.withTransaction { - val track = getOrCreateTrack(updates.manga.id).mergeWith(updates) - db.getTracksDao().upsert(track) - if (updates.isValid && updates.newChapters.isNotEmpty()) { - updatePercent(updates) - val logEntity = TrackLogEntity( - mangaId = updates.manga.id, - chapters = updates.newChapters.joinToString("\n") { x -> x.name }, - createdAt = System.currentTimeMillis(), - ) - db.getTrackLogsDao().insert(logEntity) - } - } - } - - suspend fun clearUpdates(ids: Collection) { - when { - ids.isEmpty() -> return - ids.size == 1 -> db.getTracksDao().clearCounter(ids.single()) - else -> db.withTransaction { - for (id in ids) { - db.getTracksDao().clearCounter(id) - } - } - } - } - - suspend fun syncWithHistory(manga: Manga, chapterId: Long) { - val chapters = manga.chapters ?: return - val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } - val track = getOrCreateTrack(manga.id) - val lastNewChapterIndex = chapters.size - track.newChapters - val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID - val entity = TrackEntity( - mangaId = manga.id, - totalChapters = chapters.size, - lastChapterId = lastChapterId, - newChapters = when { - track.newChapters == 0 -> 0 - chapterIndex < 0 -> track.newChapters - chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex - else -> track.newChapters - }, - lastCheck = System.currentTimeMillis(), - lastNotifiedChapterId = lastChapterId, - ) - db.getTracksDao().upsert(entity) - } - - suspend fun getCategoriesCount(): IntArray { - val categories = db.getFavouriteCategoriesDao().findAll() - return intArrayOf( - categories.count { it.track }, - categories.size, - ) - } - - suspend fun getAllFavouritesManga(): Map> { - val categories = db.getFavouriteCategoriesDao().findAll() - return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity -> - categoryEntity.toFavouriteCategory() to - db.getFavouritesDao().findAllManga(categoryEntity.categoryId).toMangaList() - } - } - - suspend fun getAllHistoryManga(): List { - return db.getHistoryDao().findAllManga().toMangaList() - } - - private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { - return db.getTracksDao().find(mangaId) ?: TrackEntity( - mangaId = mangaId, - totalChapters = 0, - lastChapterId = 0L, - newChapters = 0, - lastCheck = 0L, - lastNotifiedChapterId = 0L, - ) - } - - private suspend fun updatePercent(updates: MangaUpdates.Success) { - val history = db.getHistoryDao().find(updates.manga.id) ?: return - val chapters = updates.manga.chapters - if (chapters.isNullOrEmpty()) { - return - } - val chapterIndex = chapters.indexOfFirst { it.id == history.chapterId } - if (chapterIndex < 0) { - return - } - val position = (chapters.size - updates.newChapters.size) * history.percent - val newPercent = position / chapters.size.toFloat() - db.getHistoryDao().update(history.copy(percent = newPercent)) - } - - private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity { - val chapters = updates.manga.chapters.orEmpty() - return TrackEntity( - mangaId = mangaId, - totalChapters = chapters.size, - lastChapterId = chapters.lastOrNull()?.id ?: NO_ID, - newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0, - lastCheck = System.currentTimeMillis(), - lastNotifiedChapterId = NO_ID, - ) - } - - private suspend fun gcIfNotCalled() { - if (isGcCalled.compareAndSet(false, true)) { - gc() - } - } - - private fun Collection.toMangaList() = map { it.toManga(emptySet()) } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt deleted file mode 100644 index 9f11991b8..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.tracker.domain.model - -import org.koitharu.kotatsu.parsers.model.Manga -import java.time.Instant - -data class MangaTracking( - val manga: Manga, - val lastChapterId: Long, - val lastCheck: Instant?, -) { - fun isEmpty(): Boolean { - return lastChapterId == 0L - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt deleted file mode 100644 index 6dc13dd60..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.koitharu.kotatsu.tracker.domain.model - -import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter - -sealed interface MangaUpdates { - - val manga: Manga - - data class Success( - override val manga: Manga, - val newChapters: List, - val isValid: Boolean, - val channelId: String?, - ) : MangaUpdates { - - fun isNotEmpty() = newChapters.isNotEmpty() - } - - data class Failure( - override val manga: Manga, - val error: Throwable?, - ) : MangaUpdates { - - fun shouldRetry() = error is TooManyRequestExceptions - } -} 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 deleted file mode 100644 index d399058a0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.feed - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import coil.ImageLoader -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener -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.FragmentFeedBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver -import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity -import javax.inject.Inject - -@AndroidEntryPoint -class FeedFragment : - BaseFragment(), - PaginationScrollListener.Callback, - MangaListListener, SwipeRefreshLayout.OnRefreshListener { - - @Inject - lateinit var coil: ImageLoader - - private val viewModel by viewModels() - - private var feedAdapter: FeedAdapter? = null - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentFeedBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated(binding: FragmentFeedBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)) - feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) - with(binding.recyclerView) { - adapter = feedAdapter - setHasFixedSize(true) - addOnScrollListener(PaginationScrollListener(4, this@FeedFragment)) - addItemDecoration(TypedListSpacingDecoration(context, true)) - } - binding.swipeRefreshLayout.setOnRefreshListener(this) - addMenuProvider( - FeedMenuProvider( - binding.recyclerView, - viewModel, - ), - ) - - viewModel.content.observe(viewLifecycleOwner, this::onListChanged) - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { - onFeedCleared() - } - viewModel.isRunning.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) - } - - override fun onDestroyView() { - feedAdapter = null - super.onDestroyView() - } - - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - } - - override fun onRefresh() { - viewModel.update() - } - - override fun onRetryClick(error: Throwable) = Unit - - override fun onUpdateFilter(tags: Set) = Unit - - override fun onFilterClick(view: View?) = Unit - - override fun onEmptyActionClick() = Unit - - override fun onListHeaderClick(item: ListHeader, view: View) { - val context = view.context - context.startActivity(UpdatesActivity.newIntent(context)) - } - - private fun onListChanged(list: List) { - feedAdapter?.items = list - } - - private fun onFeedCleared() { - val snackbar = Snackbar.make( - requireViewBinding().recyclerView, - R.string.updates_feed_cleared, - Snackbar.LENGTH_LONG, - ) - snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav - snackbar.show() - } - - private fun onIsTrackerRunningChanged(isRunning: Boolean) { - requireViewBinding().swipeRefreshLayout.isRefreshing = isRunning - } - - override fun onScrolledToEnd() { - viewModel.requestMoreItems() - } - - override fun onItemClick(item: Manga, view: View) { - startActivity(DetailsActivity.newIntent(context ?: return, item)) - } - - override fun onReadClick(manga: Manga, view: View) = Unit - - override fun onTagClick(manga: Manga, tag: MangaTag, 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 deleted file mode 100644 index 48ac6dd48..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.feed - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem -import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader -import org.koitharu.kotatsu.tracker.ui.feed.model.toFeedItem -import org.koitharu.kotatsu.tracker.work.TrackWorker -import java.util.concurrent.atomic.AtomicBoolean -import javax.inject.Inject - -private const val PAGE_SIZE = 20 - -@HiltViewModel -class FeedViewModel @Inject constructor( - private val repository: TrackingRepository, - private val scheduler: TrackWorker.Scheduler, - private val listExtraProvider: ListExtraProvider, -) : BaseViewModel() { - - private val limit = MutableStateFlow(PAGE_SIZE) - private val isReady = AtomicBoolean(false) - - val isRunning = scheduler.observeIsRunning() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) - - val onFeedCleared = MutableEventFlow() - val content = combine( - observeHeader(), - repository.observeTrackingLog(limit), - ) { header, list -> - val result = ArrayList((list.size * 1.4).toInt().coerceAtLeast(2)) - if (header != null) { - result += header - } - if (list.isEmpty()) { - result += EmptyState( - icon = R.drawable.ic_empty_feed, - textPrimary = R.string.text_empty_holder_primary, - textSecondary = R.string.text_feed_holder, - actionStringRes = 0, - ) - } else { - isReady.set(true) - list.mapListTo(result) - } - result - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - init { - launchJob(Dispatchers.Default) { - repository.gc() - } - } - - fun clearFeed(clearCounters: Boolean) { - launchLoadingJob(Dispatchers.Default) { - repository.clearLogs() - if (clearCounters) { - repository.clearCounters() - } - onFeedCleared.call(Unit) - } - } - - fun requestMoreItems() { - if (isReady.compareAndSet(true, false)) { - limit.value += PAGE_SIZE - } - } - - fun update() { - scheduler.startNow() - } - - private fun List.mapListTo(destination: MutableList) { - var prevDate: DateTimeAgo? = null - for (item in this) { - val date = calculateTimeAgo(item.createdAt) - if (prevDate != date) { - destination += ListHeader(date) - } - prevDate = date - destination += item.toFeedItem() - } - } - - private fun observeHeader() = repository.observeUpdatedManga(10).map { mangaList -> - if (mangaList.isEmpty()) { - null - } else { - UpdatedMangaHeader(mangaList.toUi(ListMode.GRID, listExtraProvider)) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt deleted file mode 100644 index e39f5055d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedAdapter.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.feed.adapter - -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.MangaListListener -import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD -import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD -import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD -import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD -import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD -import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver - -class FeedAdapter( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - listener: MangaListListener, - sizeResolver: ItemSizeResolver, -) : BaseListAdapter(), FastScroller.SectionIndexer { - - init { - addDelegate(ListItemType.FEED, feedItemAD(coil, lifecycleOwner, listener)) - addDelegate( - ListItemType.MANGA_NESTED_GROUP, - updatedMangaAD( - lifecycleOwner = lifecycleOwner, - coil = coil, - sizeResolver = sizeResolver, - listener = listener, - headerClickListener = listener, - ), - ) - addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) - addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) - addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(listener)) - addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) - addDelegate(ListItemType.HEADER, listHeaderAD(listener)) - addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) - } - - override fun getSectionText(context: Context, position: Int): CharSequence? { - val list = items - for (i in (0..position).reversed()) { - val item = list.getOrNull(i) ?: continue - if (item is ListHeader) { - return item.getText(context) - } - } - return null - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt deleted file mode 100644 index 13c59d044..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/UpdatedMangaAD.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.feed.adapter - -import androidx.lifecycle.LifecycleOwner -import coil.ImageLoader -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration -import org.koitharu.kotatsu.databinding.ItemListGroupBinding -import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener -import org.koitharu.kotatsu.list.ui.adapter.ListItemType -import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD -import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader - -fun updatedMangaAD( - lifecycleOwner: LifecycleOwner, - coil: ImageLoader, - sizeResolver: ItemSizeResolver, - listener: OnListItemClickListener, - headerClickListener: ListHeaderClickListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }, -) { - - val adapter = BaseListAdapter() - .addDelegate(ListItemType.MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, sizeResolver, listener)) - binding.recyclerView.adapter = adapter - val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing) - binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) - binding.buttonMore.setOnClickListener { v -> - headerClickListener.onListHeaderClick(ListHeader(0, payload = item), v) - } - binding.textViewTitle.setText(R.string.updates) - binding.buttonMore.setText(R.string.more) - - bind { - adapter.items = item.list - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/UpdatedMangaHeader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/UpdatedMangaHeader.kt deleted file mode 100644 index 71e2ce8de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/model/UpdatedMangaHeader.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.feed.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.MangaItemModel - -data class UpdatedMangaHeader( - val list: List, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is UpdatedMangaHeader - } - - override fun getChangePayload(previousState: ListModel): Any { - return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED - } -} 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 deleted file mode 100644 index 047b0d7ca..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.updates - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner - -@AndroidEntryPoint -class UpdatesActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = UpdatesFragment.newInstance() - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, UpdatesActivity::class.java) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt deleted file mode 100644 index 73f562648..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.updates - -import android.view.Menu -import android.view.MenuItem -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.ui.list.ListSelectionController -import org.koitharu.kotatsu.list.ui.MangaListFragment - -@AndroidEntryPoint -class UpdatesFragment : MangaListFragment() { - - override val viewModel by viewModels() - override val isSwipeRefreshEnabled = false - - override fun onScrolledToEnd() = Unit - - override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.mode_updates, menu) - return super.onCreateActionMode(controller, mode, menu) - } - - override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_remove -> { - viewModel.remove(controller.snapshot()) - true - } - - else -> super.onActionItemClicked(controller, mode, item) - } - } - - companion object { - - fun newInstance() = UpdatesFragment() - } -} 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 deleted file mode 100644 index c6d9faf98..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.koitharu.kotatsu.tracker.ui.updates - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.onFirst -import org.koitharu.kotatsu.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import javax.inject.Inject - -@HiltViewModel -class UpdatesViewModel @Inject constructor( - private val repository: TrackingRepository, - settings: AppSettings, - private val extraProvider: ListExtraProvider, - downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler) { - - override val content = combine( - repository.observeUpdatedManga(), - listMode, - ) { mangaList, mode -> - when { - mangaList.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_history, - textPrimary = R.string.text_history_holder_primary, - textSecondary = R.string.text_history_holder_secondary, - actionStringRes = 0, - ), - ) - - else -> mangaList.toUi(mode, extraProvider) - } - }.onStart { - loadingCounter.increment() - }.onFirst { - loadingCounter.decrement() - }.catch { - emit(listOf(it.toErrorState(canRetry = false))) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) - - init { - launchJob(Dispatchers.Default) { - repository.gc() - } - } - - override fun onRefresh() = Unit - - override fun onRetry() = Unit - - fun remove(ids: Set) { - launchJob(Dispatchers.Default) { - repository.clearUpdates(ids) - } - } -} 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 deleted file mode 100644 index 8091f57aa..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ /dev/null @@ -1,381 +0,0 @@ -package org.koitharu.kotatsu.tracker.work - -import android.app.PendingIntent -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC -import androidx.core.app.NotificationCompat.VISIBILITY_SECRET -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.hilt.work.HiltWorker -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkQuery -import androidx.work.WorkerParameters -import androidx.work.await -import coil.ImageLoader -import coil.request.ImageRequest -import dagger.Reusable -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.runInterruptible -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier -import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException -import org.koitharu.kotatsu.core.logs.FileLogger -import org.koitharu.kotatsu.core.logs.TrackerLogger -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -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.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler -import org.koitharu.kotatsu.tracker.domain.Tracker -import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@HiltWorker -class TrackWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted workerParams: WorkerParameters, - private val coil: ImageLoader, - private val settings: AppSettings, - private val tracker: Tracker, - @TrackerLogger private val logger: FileLogger, -) : CoroutineWorker(context, workerParams) { - - private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } - - override suspend fun doWork(): Result { - trySetForeground() - logger.log("doWork(): attempt $runAttemptCount") - return try { - doWorkImpl() - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - logger.log("fatal", e) - Result.failure() - } finally { - withContext(NonCancellable) { - logger.flush() - notificationManager.cancel(WORKER_NOTIFICATION_ID) - } - } - } - - private suspend fun doWorkImpl(): Result { - if (!settings.isTrackerEnabled) { - return Result.success(workDataOf(0, 0)) - } - val retryIds = getRetryIds() - val tracks = if (retryIds.isNotEmpty()) { - tracker.getTracks(retryIds) - } else { - tracker.getAllTracks() - } - logger.log("Total ${tracks.size} tracks") - if (tracks.isEmpty()) { - return Result.success(workDataOf(0, 0)) - } - - val results = checkUpdatesAsync(tracks) - tracker.gc() - - var success = 0 - var failed = 0 - val retry = HashSet() - results.forEach { x -> - when (x) { - is MangaUpdates.Success -> success++ - is MangaUpdates.Failure -> { - failed++ - if (x.shouldRetry()) { - retry += x.manga.id - } - } - } - } - if (runAttemptCount > MAX_ATTEMPTS) { - retry.clear() - } - setRetryIds(retry) - logger.log("Result: success: $success, failed: $failed, retry: ${retry.size}") - val resultData = workDataOf(success, failed) - return when { - retry.isNotEmpty() -> Result.retry() - success == 0 && failed != 0 -> Result.failure(resultData) - else -> Result.success(resultData) - } - } - - private suspend fun checkUpdatesAsync(tracks: List): List { - val semaphore = Semaphore(MAX_PARALLELISM) - return channelFlow { - for ((track, channelId) in tracks) { - launch { - semaphore.withPermit { - send( - runCatchingCancellable { - tracker.fetchUpdates(track, commit = true) - .copy(channelId = channelId) - }.onFailure { e -> - logger.log("checkUpdatesAsync", e) - }.getOrElse { error -> - MangaUpdates.Failure( - manga = track.manga, - error = error, - ) - }, - ) - } - } - } - }.onEach { - when (it) { - is MangaUpdates.Failure -> { - val e = it.error - if (e is CloudFlareProtectedException) { - CaptchaNotifier(applicationContext).notify(e) - } - } - - is MangaUpdates.Success -> { - if (it.isValid && it.isNotEmpty()) { - showNotification( - manga = it.manga, - channelId = it.channelId, - newChapters = it.newChapters, - ) - } - } - } - }.toList(ArrayList(tracks.size)) - } - - private suspend fun showNotification( - manga: Manga, - channelId: String?, - newChapters: List, - ) { - if (newChapters.isEmpty() || channelId == null || !applicationContext.checkNotificationPermission()) { - return - } - val id = manga.url.hashCode() - val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary) - val builder = NotificationCompat.Builder(applicationContext, channelId) - val summary = applicationContext.resources.getQuantityString( - R.plurals.new_chapters, - newChapters.size, - newChapters.size, - ) - with(builder) { - setContentText(summary) - setContentTitle(manga.title) - setNumber(newChapters.size) - setLargeIcon( - coil.execute( - ImageRequest.Builder(applicationContext) - .data(manga.coverUrl) - .tag(manga.source) - .build(), - ).toBitmapOrNull(), - ) - setSmallIcon(R.drawable.ic_stat_book_plus) - val style = NotificationCompat.InboxStyle(this) - for (chapter in newChapters) { - style.addLine(chapter.name) - } - style.setSummaryText(manga.title) - style.setBigContentTitle(summary) - setStyle(style) - val intent = DetailsActivity.newIntent(applicationContext, manga) - setContentIntent( - PendingIntentCompat.getActivity( - applicationContext, - id, - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - false, - ), - ) - setAutoCancel(true) - setCategory(NotificationCompat.CATEGORY_PROMO) - setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC) - setShortcutId(manga.id.toString()) - priority = NotificationCompat.PRIORITY_DEFAULT - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - builder.setSound(settings.notificationSound) - var defaults = if (settings.notificationLight) { - setLights(colorPrimary, 1000, 5000) - NotificationCompat.DEFAULT_LIGHTS - } else 0 - if (settings.notificationVibrate) { - builder.setVibrate(longArrayOf(500, 500, 500, 500)) - defaults = defaults or NotificationCompat.DEFAULT_VIBRATE - } - builder.setDefaults(defaults) - } - } - notificationManager.notify(TAG, id, builder.build()) - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - val title = applicationContext.getString(R.string.check_for_new_chapters) - val channel = NotificationChannelCompat.Builder( - WORKER_CHANNEL_ID, - NotificationManagerCompat.IMPORTANCE_LOW - ) - .setName(title) - .setShowBadge(false) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - notificationManager.createNotificationChannel(channel) - - val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setDefaults(0) - .setOngoing(false) - .setSilent(true) - .setProgress(0, 0, true) - .setSmallIcon(android.R.drawable.stat_notify_sync) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) - .build() - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo( - WORKER_NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } else { - ForegroundInfo(WORKER_NOTIFICATION_ID, notification) - } - } - - private suspend fun setRetryIds(ids: Set) = runInterruptible(Dispatchers.IO) { - val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE) - prefs.edit(commit = true) { - if (ids.isEmpty()) { - remove(KEY_RETRY_IDS) - } else { - putStringSet(KEY_RETRY_IDS, ids.mapToSet { it.toString() }) - } - } - } - - private fun getRetryIds(): Set { - val prefs = applicationContext.getSharedPreferences(TAG, Context.MODE_PRIVATE) - return prefs.getStringSet(KEY_RETRY_IDS, null)?.mapToSet { it.toLong() }.orEmpty() - } - - private fun workDataOf(success: Int, failed: Int): Data { - return Data.Builder() - .putInt(DATA_KEY_SUCCESS, success) - .putInt(DATA_KEY_FAILED, failed) - .build() - } - - @Reusable - class Scheduler @Inject constructor( - private val workManager: WorkManager, - private val settings: AppSettings, - ) : PeriodicWorkScheduler { - - override suspend fun schedule() { - val constraints = createConstraints() - val request = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS) - .setConstraints(constraints) - .addTag(TAG) - .setBackoffCriteria(BackoffPolicy.LINEAR, 5, TimeUnit.MINUTES) - .build() - workManager - .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) - .await() - } - - override suspend fun unschedule() { - workManager - .cancelUniqueWork(TAG) - .await() - } - - override suspend fun isScheduled(): Boolean { - return workManager - .awaitUniqueWorkInfoByName(TAG) - .any { !it.state.isFinished } - } - - fun startNow() { - val constraints = - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG_ONESHOT) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - workManager.enqueue(request) - } - - fun observeIsRunning(): Flow { - val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build() - return workManager.getWorkInfosFlow(query) - .map { works -> - works.any { x -> x.state == WorkInfo.State.RUNNING } - } - } - - private fun createConstraints() = Constraints.Builder() - .setRequiredNetworkType(if (settings.isTrackerWifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) - .build() - } - - private companion object { - - const val WORKER_CHANNEL_ID = "track_worker" - const val WORKER_NOTIFICATION_ID = 35 - const val TAG = "tracking" - const val TAG_ONESHOT = "tracking_oneshot" - const val MAX_PARALLELISM = 3 - const val MAX_ATTEMPTS = 4 - const val DATA_KEY_SUCCESS = "success" - const val DATA_KEY_FAILED = "failed" - const val KEY_RETRY_IDS = "retry" - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt deleted file mode 100644 index 38ad9d4ec..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackingItem.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.tracker.work - -import org.koitharu.kotatsu.tracker.domain.model.MangaTracking - -data class TrackingItem( - val tracking: MangaTracking, - val channelId: String?, -) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt deleted file mode 100644 index aba7d594d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt +++ /dev/null @@ -1,74 +0,0 @@ -package org.koitharu.kotatsu.widget.recent - -import android.app.Activity -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppWidgetConfig -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityAppwidgetRecentBinding -import com.google.android.material.R as materialR - -@AndroidEntryPoint -class RecentWidgetConfigActivity : - BaseActivity(), - View.OnClickListener { - - private lateinit var config: AppWidgetConfig - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityAppwidgetRecentBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } - viewBinding.buttonDone.setOnClickListener(this) - val appWidgetId = intent?.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) ?: AppWidgetManager.INVALID_APPWIDGET_ID - if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { - finishAfterTransition() - return - } - config = AppWidgetConfig(this, RecentWidgetProvider::class.java, appWidgetId) - viewBinding.switchBackground.isChecked = config.hasBackground - } - - override fun onClick(v: View) { - when (v.id) { - R.id.button_done -> { - config.hasBackground = viewBinding.switchBackground.isChecked - updateWidget() - setResult( - Activity.RESULT_OK, - Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), - ) - finish() - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - top = insets.top, - ) - } - - private fun updateWidget() { - val intent = Intent(this, RecentWidgetProvider::class.java) - intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - val ids = intArrayOf(config.widgetId) - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - sendBroadcast(intent) - } -} 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 deleted file mode 100644 index c94e449b9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.widget.recent - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.net.Uri -import android.widget.RemoteViews -import androidx.core.app.PendingIntentCompat -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppWidgetConfig -import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider -import org.koitharu.kotatsu.reader.ui.ReaderActivity - -class RecentWidgetProvider : BaseAppWidgetProvider() { - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.stackView) - } - - override fun onUpdateWidget(context: Context, config: AppWidgetConfig): RemoteViews { - val views = RemoteViews(context.packageName, R.layout.widget_recent) - if (!config.hasBackground) { - views.setInt(R.id.widget_root, "setBackgroundColor", Color.TRANSPARENT) - } else { - views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.bg_appwidget_root) - } - val adapter = Intent(context, RecentWidgetService::class.java) - adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) - 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 - views.setPendingIntentTemplate( - R.id.stackView, - PendingIntentCompat.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - true, - ), - ) - views.setEmptyView(R.id.stackView, R.id.textView_holder) - return views - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt deleted file mode 100644 index a5052477a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetService.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.koitharu.kotatsu.widget.recent - -import android.content.Intent -import android.widget.RemoteViewsService -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.history.data.HistoryRepository -import javax.inject.Inject - -@AndroidEntryPoint -class RecentWidgetService : RemoteViewsService() { - - @Inject - lateinit var historyRepository: HistoryRepository - - @Inject - lateinit var coil: ImageLoader - - override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { - return RecentListFactory(applicationContext, historyRepository, coil) - } -} 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 deleted file mode 100644 index 5246ecd3f..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.widget.shelf - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.net.Uri -import android.widget.RemoteViews -import androidx.core.app.PendingIntentCompat -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppWidgetConfig -import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider -import org.koitharu.kotatsu.reader.ui.ReaderActivity - -class ShelfWidgetProvider : BaseAppWidgetProvider() { - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.gridView) - } - - override fun onUpdateWidget(context: Context, config: AppWidgetConfig): RemoteViews { - val views = RemoteViews(context.packageName, R.layout.widget_shelf) - if (!config.hasBackground) { - views.setInt(R.id.widget_root, "setBackgroundColor", Color.TRANSPARENT) - } else { - views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.bg_appwidget_root) - } - val adapter = Intent(context, ShelfWidgetService::class.java) - adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId) - 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 - views.setPendingIntentTemplate( - R.id.gridView, - PendingIntentCompat.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT, - true, - ), - ) - views.setEmptyView(R.id.gridView, R.id.textView_holder) - return views - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt deleted file mode 100644 index 40c0d86bb..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.koitharu.kotatsu.widget.shelf - -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.widget.RemoteViewsService -import coil.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import org.koitharu.kotatsu.favourites.domain.FavouritesRepository - -@AndroidEntryPoint -class ShelfWidgetService : RemoteViewsService() { - - @Inject - lateinit var favouritesRepository: FavouritesRepository - - @Inject - lateinit var coil: ImageLoader - - override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { - val widgetId = intent.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID, - ) - return ShelfListFactory(applicationContext, favouritesRepository, coil, widgetId) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt deleted file mode 100644 index 0bd0a6789..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/adapter/CategorySelectAdapter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.koitharu.kotatsu.widget.shelf.adapter - -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.widget.shelf.model.CategoryItem - -class CategorySelectAdapter( - clickListener: OnListItemClickListener -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(categorySelectItemAD(clickListener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt deleted file mode 100644 index 82e5082a0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/model/CategoryItem.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.widget.shelf.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class CategoryItem( - val id: Long, - val name: String?, - val isSelected: Boolean -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is CategoryItem && other.id == id - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is CategoryItem && previousState.isSelected != isSelected) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - null - } - } -} diff --git a/app/src/main/res/anim/bottom_sheet_slide_in.xml b/app/src/main/res/anim/bottom_sheet_slide_in.xml index 288946773..20c7c0d4e 100644 --- a/app/src/main/res/anim/bottom_sheet_slide_in.xml +++ b/app/src/main/res/anim/bottom_sheet_slide_in.xml @@ -1,7 +1,6 @@ - - - - - diff --git a/app/src/main/res/color-v23/bottom_menu_active_item.xml b/app/src/main/res/color-v23/bottom_menu_active_item.xml deleted file mode 100644 index 6b6938f5e..000000000 --- a/app/src/main/res/color-v23/bottom_menu_active_item.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/color-v23/colored_button.xml b/app/src/main/res/color-v23/colored_button.xml deleted file mode 100644 index 8c3ee7b0d..000000000 --- a/app/src/main/res/color-v23/colored_button.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color-v23/selector_overlay.xml b/app/src/main/res/color-v23/selector_overlay.xml index 538011ef8..e58e11d7c 100644 --- a/app/src/main/res/color-v23/selector_overlay.xml +++ b/app/src/main/res/color-v23/selector_overlay.xml @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/color/bottom_menu_active_indicator.xml b/app/src/main/res/color/bottom_menu_active_indicator.xml deleted file mode 100644 index 3dd89e625..000000000 --- a/app/src/main/res/color/bottom_menu_active_indicator.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color/bottom_menu_active_item.xml b/app/src/main/res/color/bottom_menu_active_item.xml deleted file mode 100644 index a56980d3c..000000000 --- a/app/src/main/res/color/bottom_menu_active_item.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/color/colored_button.xml b/app/src/main/res/color/colored_button.xml deleted file mode 100644 index a9b4af88f..000000000 --- a/app/src/main/res/color/colored_button.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color/navigation_bar_scrim.xml b/app/src/main/res/color/navigation_bar_scrim.xml index 239f1d34d..6d9fe48af 100644 --- a/app/src/main/res/color/navigation_bar_scrim.xml +++ b/app/src/main/res/color/navigation_bar_scrim.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/color/ripple_toolbar.xml b/app/src/main/res/color/ripple_toolbar.xml index 979d0f94a..7ef8081de 100644 --- a/app/src/main/res/color/ripple_toolbar.xml +++ b/app/src/main/res/color/ripple_toolbar.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/color/selector_overlay.xml b/app/src/main/res/color/selector_overlay.xml index 515d4a190..5a4a4ecb0 100644 --- a/app/src/main/res/color/selector_overlay.xml +++ b/app/src/main/res/color/selector_overlay.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml index b06600927..3d3c49f04 100644 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_book_plus.xml @@ -6,5 +6,5 @@ android:viewportHeight="24"> - + android:pathData="M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M20,12L17.5,10.5L15,12V4H20V12Z" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml deleted file mode 100644 index 603163b23..000000000 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_done.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml deleted file mode 100644 index 55d7a60db..000000000 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_paused.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml deleted file mode 100644 index 3da7d00e0..000000000 --- a/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-hdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-hdpi/ic_shikimori.png similarity index 100% rename from app/src/main/res/drawable-hdpi/ic_shikimori_raw.png rename to app/src/main/res/drawable-hdpi/ic_shikimori.png diff --git a/app/src/main/res/drawable-hdpi/ic_stat_done.png b/app/src/main/res/drawable-hdpi/ic_stat_done.png deleted file mode 100644 index 315f0e22d..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_stat_done.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_paused.png b/app/src/main/res/drawable-hdpi/ic_stat_paused.png deleted file mode 100644 index e42a3d68c..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_stat_paused.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png deleted file mode 100644 index 76ab52d8a..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-mdpi/ic_shikimori.png similarity index 100% rename from app/src/main/res/drawable-mdpi/ic_shikimori_raw.png rename to app/src/main/res/drawable-mdpi/ic_shikimori.png diff --git a/app/src/main/res/drawable-mdpi/ic_stat_done.png b/app/src/main/res/drawable-mdpi/ic_stat_done.png deleted file mode 100644 index 7fe5fc77e..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_stat_done.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_paused.png b/app/src/main/res/drawable-mdpi/ic_stat_paused.png deleted file mode 100644 index 979fc6fc3..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_stat_paused.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png deleted file mode 100644 index 7fe8da107..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_stat_suggestion.png and /dev/null differ diff --git a/app/src/main/res/drawable-night/avd_splash.xml b/app/src/main/res/drawable-night/avd_splash.xml deleted file mode 100644 index ef6c0b088..000000000 --- a/app/src/main/res/drawable-night/avd_splash.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v23/m3_popup_background.xml b/app/src/main/res/drawable-v23/m3_popup_background.xml deleted file mode 100644 index 3ede5c457..000000000 --- a/app/src/main/res/drawable-v23/m3_popup_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml b/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml deleted file mode 100644 index 04ee60a0a..000000000 --- a/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-v23/tabs_background.xml b/app/src/main/res/drawable-v23/tabs_background.xml deleted file mode 100644 index e70169f07..000000000 --- a/app/src/main/res/drawable-v23/tabs_background.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable-xhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xhdpi/ic_shikimori.png similarity index 100% rename from app/src/main/res/drawable-xhdpi/ic_shikimori_raw.png rename to app/src/main/res/drawable-xhdpi/ic_shikimori.png diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_done.png b/app/src/main/res/drawable-xhdpi/ic_stat_done.png deleted file mode 100644 index a958ba762..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_stat_done.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xhdpi/ic_stat_paused.png deleted file mode 100644 index 834a83c8d..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_stat_paused.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png deleted file mode 100644 index e85724bb9..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xxhdpi/ic_shikimori.png similarity index 100% rename from app/src/main/res/drawable-xxhdpi/ic_shikimori_raw.png rename to app/src/main/res/drawable-xxhdpi/ic_shikimori.png diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_done.png b/app/src/main/res/drawable-xxhdpi/ic_stat_done.png deleted file mode 100644 index 357b330e3..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_stat_done.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png b/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png deleted file mode 100644 index 883a124a3..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_stat_paused.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png deleted file mode 100644 index 17f64644a..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_shikimori_raw.png b/app/src/main/res/drawable-xxxhdpi/ic_shikimori.png similarity index 100% rename from app/src/main/res/drawable-xxxhdpi/ic_shikimori_raw.png rename to app/src/main/res/drawable-xxxhdpi/ic_shikimori.png diff --git a/app/src/main/res/drawable/avd_explore_enter.xml b/app/src/main/res/drawable/avd_explore_enter.xml deleted file mode 100644 index eb262e559..000000000 --- a/app/src/main/res/drawable/avd_explore_enter.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/avd_explore_leave.xml b/app/src/main/res/drawable/avd_explore_leave.xml deleted file mode 100644 index d564f977c..000000000 --- a/app/src/main/res/drawable/avd_explore_leave.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/avd_favourites_enter.xml b/app/src/main/res/drawable/avd_favourites_enter.xml deleted file mode 100644 index 89deddcb6..000000000 --- a/app/src/main/res/drawable/avd_favourites_enter.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_favourites_leave.xml b/app/src/main/res/drawable/avd_favourites_leave.xml deleted file mode 100644 index d15328702..000000000 --- a/app/src/main/res/drawable/avd_favourites_leave.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_feed_enter.xml b/app/src/main/res/drawable/avd_feed_enter.xml deleted file mode 100644 index dc08f558e..000000000 --- a/app/src/main/res/drawable/avd_feed_enter.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_history_enter.xml b/app/src/main/res/drawable/avd_history_enter.xml deleted file mode 100644 index fcca925e3..000000000 --- a/app/src/main/res/drawable/avd_history_enter.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/avd_splash.xml b/app/src/main/res/drawable/avd_splash.xml deleted file mode 100644 index 2c52ce779..000000000 --- a/app/src/main/res/drawable/avd_splash.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_appwidget_card.xml b/app/src/main/res/drawable/bg_appwidget_card.xml index 9dcc796fd..35a460504 100644 --- a/app/src/main/res/drawable/bg_appwidget_card.xml +++ b/app/src/main/res/drawable/bg_appwidget_card.xml @@ -4,4 +4,4 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_appwidget_root.xml b/app/src/main/res/drawable/bg_appwidget_root.xml deleted file mode 100644 index 7625f547c..000000000 --- a/app/src/main/res/drawable/bg_appwidget_root.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/bg_badge_accent.xml b/app/src/main/res/drawable/bg_badge_accent.xml index aafa2c127..143b10f71 100644 --- a/app/src/main/res/drawable/bg_badge_accent.xml +++ b/app/src/main/res/drawable/bg_badge_accent.xml @@ -1,11 +1,12 @@ + android:shape="rectangle"> + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_default.xml b/app/src/main/res/drawable/bg_badge_default.xml index f010f44f6..46fd21923 100644 --- a/app/src/main/res/drawable/bg_badge_default.xml +++ b/app/src/main/res/drawable/bg_badge_default.xml @@ -1,11 +1,12 @@ + android:shape="rectangle"> + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_empty.xml b/app/src/main/res/drawable/bg_badge_empty.xml index 01437c12e..55ad85bd1 100644 --- a/app/src/main/res/drawable/bg_badge_empty.xml +++ b/app/src/main/res/drawable/bg_badge_empty.xml @@ -1,7 +1,8 @@ + android:shape="rectangle"> + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_outline.xml b/app/src/main/res/drawable/bg_badge_outline.xml index c4ce0d8b2..938b47983 100644 --- a/app/src/main/res/drawable/bg_badge_outline.xml +++ b/app/src/main/res/drawable/bg_badge_outline.xml @@ -1,7 +1,8 @@ + android:shape="rectangle"> + @@ -10,4 +11,4 @@ android:left="2dp" android:right="2dp" android:top="2dp" /> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_primary.xml b/app/src/main/res/drawable/bg_badge_primary.xml deleted file mode 100644 index 1393b8638..000000000 --- a/app/src/main/res/drawable/bg_badge_primary.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/bg_circle.xml b/app/src/main/res/drawable/bg_circle.xml deleted file mode 100644 index 7b7113bfe..000000000 --- a/app/src/main/res/drawable/bg_circle.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_circle_button.xml b/app/src/main/res/drawable/bg_circle_button.xml deleted file mode 100644 index 341baf3ae..000000000 --- a/app/src/main/res/drawable/bg_circle_button.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_rounded_square.xml b/app/src/main/res/drawable/bg_rounded_square.xml deleted file mode 100644 index 48edee83b..000000000 --- a/app/src/main/res/drawable/bg_rounded_square.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/divider_transparent.xml b/app/src/main/res/drawable/divider_transparent.xml deleted file mode 100644 index 64725fa5b..000000000 --- a/app/src/main/res/drawable/divider_transparent.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/fading_snackbar_background.xml similarity index 58% rename from app/src/main/res/drawable/bg_card.xml rename to app/src/main/res/drawable/fading_snackbar_background.xml index 6da4fae05..b439322e0 100644 --- a/app/src/main/res/drawable/bg_card.xml +++ b/app/src/main/res/drawable/fading_snackbar_background.xml @@ -2,7 +2,6 @@ - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble.xml b/app/src/main/res/drawable/fastscroll_bubble.xml deleted file mode 100644 index 88fac1384..000000000 --- a/app/src/main/res/drawable/fastscroll_bubble.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_bubble_small.xml b/app/src/main/res/drawable/fastscroll_bubble_small.xml deleted file mode 100644 index 90b7d0bb0..000000000 --- a/app/src/main/res/drawable/fastscroll_bubble_small.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_handle.xml b/app/src/main/res/drawable/fastscroll_handle.xml deleted file mode 100644 index f49c6c5fc..000000000 --- a/app/src/main/res/drawable/fastscroll_handle.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/fastscroll_track.xml b/app/src/main/res/drawable/fastscroll_track.xml deleted file mode 100644 index aac6cb020..000000000 --- a/app/src/main/res/drawable/fastscroll_track.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_4pda.xml similarity index 50% rename from app/src/main/res/drawable/ic_sort.xml rename to app/src/main/res/drawable/ic_4pda.xml index 944c99fd4..f0920f0d8 100644 --- a/app/src/main/res/drawable/ic_sort.xml +++ b/app/src/main/res/drawable/ic_4pda.xml @@ -1,12 +1,11 @@ - + android:viewportWidth="1024" + android:viewportHeight="1024"> + android:pathData="M426.1,112 L112,545.6l0,247.7l486.7,0l0,118.8l313.3,0L912,112ZM599.5,312L599.5,577.6L390.1,577.6Z" /> diff --git a/app/src/main/res/drawable/ic_action_pause.xml b/app/src/main/res/drawable/ic_action_pause.xml deleted file mode 100644 index 147cc6322..000000000 --- a/app/src/main/res/drawable/ic_action_pause.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_action_resume.xml b/app/src/main/res/drawable/ic_action_resume.xml deleted file mode 100644 index 9811d4077..000000000 --- a/app/src/main/res/drawable/ic_action_resume.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_action_skip.xml b/app/src/main/res/drawable/ic_action_skip.xml deleted file mode 100644 index efe97da86..000000000 --- a/app/src/main/res/drawable/ic_action_skip.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_anilist.xml b/app/src/main/res/drawable/ic_anilist.xml deleted file mode 100644 index 88a718884..000000000 --- a/app/src/main/res/drawable/ic_anilist.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_app_update.xml b/app/src/main/res/drawable/ic_app_update.xml deleted file mode 100644 index f9ded1f40..000000000 --- a/app/src/main/res/drawable/ic_app_update.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_auth_key_large.xml b/app/src/main/res/drawable/ic_auth_key_large.xml deleted file mode 100644 index e1f925ee8..000000000 --- a/app/src/main/res/drawable/ic_auth_key_large.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_backup_restore.xml b/app/src/main/res/drawable/ic_backup_restore.xml deleted file mode 100644 index d2066791d..000000000 --- a/app/src/main/res/drawable/ic_backup_restore.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_book_cross.xml b/app/src/main/res/drawable/ic_book_cross.xml new file mode 100644 index 000000000..6dc368b79 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_cross.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_book_search.xml b/app/src/main/res/drawable/ic_book_search.xml new file mode 100644 index 000000000..50f9dec7a --- /dev/null +++ b/app/src/main/res/drawable/ic_book_search.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark_checked.xml b/app/src/main/res/drawable/ic_bookmark_checked.xml deleted file mode 100644 index fb9fc6467..000000000 --- a/app/src/main/res/drawable/ic_bookmark_checked.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_bookmark_selector.xml b/app/src/main/res/drawable/ic_bookmark_selector.xml deleted file mode 100644 index e5fac6c34..000000000 --- a/app/src/main/res/drawable/ic_bookmark_selector.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_bot_large.xml b/app/src/main/res/drawable/ic_bot_large.xml deleted file mode 100644 index f96226251..000000000 --- a/app/src/main/res/drawable/ic_bot_large.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_cancel_multiple.xml b/app/src/main/res/drawable/ic_cancel_multiple.xml deleted file mode 100644 index 44aa35c5b..000000000 --- a/app/src/main/res/drawable/ic_cancel_multiple.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_data_privacy.xml b/app/src/main/res/drawable/ic_data_privacy.xml deleted file mode 100644 index 0697d42ee..000000000 --- a/app/src/main/res/drawable/ic_data_privacy.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_dice.xml b/app/src/main/res/drawable/ic_dice.xml deleted file mode 100644 index 66285027b..000000000 --- a/app/src/main/res/drawable/ic_dice.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml index 73fde1989..ff193346d 100644 --- a/app/src/main/res/drawable/ic_discord.xml +++ b/app/src/main/res/drawable/ic_discord.xml @@ -7,5 +7,5 @@ android:viewportHeight="24"> + android:pathData="M20.349,4.389a0.06,0.06 0,0 0,-0.032 -0.029,19.77 19.77,0 0,0 -4.885,-1.515 0.075,0.075 0,0 0,-0.079 0.037c-0.209,0.375 -0.444,0.865 -0.607,1.249a18.35,18.35 0,0 0,-5.487 0,12.583 12.583,0 0,0 -0.617,-1.249 0.078,0.078 0,0 0,-0.079 -0.037,19.746 19.746,0 0,0 -4.886,1.515 0.06,0.06 0,0 0,-0.031 0.028c-3.111,4.648 -3.964,9.183 -3.545,13.66a0.082,0.082 0,0 0,0.031 0.057,19.912 19.912,0 0,0 5.993,3.03 0.075,0.075 0,0 0,0.084 -0.028c0.461,-0.631 0.873,-1.296 1.226,-1.994a0.076,0.076 0,0 0,-0.042 -0.106,13.172 13.172,0 0,1 -1.872,-0.892 0.077,0.077 0,0 1,-0.007 -0.128c0.125,-0.094 0.251,-0.192 0.371,-0.292a0.078,0.078 0,0 1,0.078 -0.011c3.928,1.794 8.179,1.794 12.062,0a0.073,0.073 0,0 1,0.077 0.01c0.122,0.099 0.247,0.199 0.373,0.293a0.078,0.078 0,0 1,-0.007 0.128c-0.597,0.349 -1.22,0.645 -1.873,0.892a0.075,0.075 0,0 0,-0.04 0.106c0.359,0.697 0.771,1.362 1.225,1.993a0.077,0.077 0,0 0,0.085 0.029,19.842 19.842,0 0,0 6.003,-3.03 0.077,0.077 0,0 0,0.03 -0.056c0.499,-5.177 -0.839,-9.674 -3.549,-13.66zM8.02,15.322c-1.183,0 -2.157,-1.087 -2.157,-2.419 0,-1.334 0.956,-2.42 2.157,-2.42 1.21,0 2.176,1.095 2.157,2.42 0,1.332 -0.955,2.419 -2.157,2.419zM15.995,15.322c-1.184,0 -2.157,-1.087 -2.157,-2.419 0,-1.334 0.955,-2.42 2.157,-2.42 1.211,0 2.175,1.095 2.156,2.42 0,1.332 -0.945,2.419 -2.156,2.419z" /> diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml deleted file mode 100644 index fa3d244b5..000000000 --- a/app/src/main/res/drawable/ic_edit.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_empty_common.xml b/app/src/main/res/drawable/ic_empty_common.xml deleted file mode 100644 index 37d2167b3..000000000 --- a/app/src/main/res/drawable/ic_empty_common.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_empty_favourites.xml b/app/src/main/res/drawable/ic_empty_favourites.xml index 41dcf6836..ccfc51361 100644 --- a/app/src/main/res/drawable/ic_empty_favourites.xml +++ b/app/src/main/res/drawable/ic_empty_favourites.xml @@ -1,43 +1,91 @@ - - - - - - - - + android:width="192dp" + android:height="192dp" + android:viewportWidth="682.67" + android:viewportHeight="682.67"> + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_empty_feed.xml b/app/src/main/res/drawable/ic_empty_feed.xml index 748a05492..1df74d5ed 100644 --- a/app/src/main/res/drawable/ic_empty_feed.xml +++ b/app/src/main/res/drawable/ic_empty_feed.xml @@ -1,71 +1,58 @@ + android:width="192dp" + android:height="192dp" + android:viewportWidth="512" + android:viewportHeight="512"> + android:fillColor="#e2e2e2" + android:pathData="m86.92,509.75c-0.04,-1.24 -0.28,-3.15 -0.53,-4.25 -0.25,-1.1 -0.64,-11.23 -0.87,-22.5 -0.53,-26.07 0.65,-35.47 5.3,-42.4 3.92,-5.84 15.07,-12.45 23.62,-14 1.55,-0.28 5.12,-0.98 7.94,-1.54 2.82,-0.57 8.72,-1.74 13.12,-2.61 4.4,-0.87 9.02,-1.79 10.27,-2.03 2.02,-0.4 12.12,-4 16.33,-5.81 1.38,-0.6 1.73,1.01 2.61,12.11 0.86,10.82 0.67,62.65 -0.26,67.3 -0.16,0.82 -0.33,5.21 -0.37,9.75L164,512L125.5,512 87,512ZM350,511.31c0,-0.4 -4.5,-21.26 -10,-46.36 -5.5,-25.1 -9.99,-47.39 -9.98,-49.54 0.02,-6.16 0.75,-6.67 6.97,-4.82 10.05,2.98 22.4,5.18 31.01,5.52 17.95,0.7 25.64,4.78 31.84,16.88 3.96,7.75 4.64,15.03 4.65,50.51l0.01,28 -27.25,0.27c-14.99,0.15 -27.25,-0.06 -27.25,-0.46zM252,362.36c-5.34,-3.25 -15.27,-10.34 -27,-19.28 -14.45,-11.01 -15.96,-12.42 -15.98,-14.94 -0.01,-1.43 -2.05,-4.59 -5.17,-8 -2.84,-3.1 -5.71,-6.99 -6.38,-8.64 -0.93,-2.29 -1.02,-9.25 -0.36,-29.5 1.35,-41.62 1.06,-38.53 3.51,-38.29 1.59,0.16 2.56,-0.78 3.92,-3.8 1,-2.21 2.14,-4.22 2.54,-4.47 1.18,-0.73 -1.31,6.89 -2.85,8.73 -1.22,1.45 -1.19,2.41 0.18,7 0.88,2.93 2.91,6.9 4.53,8.82l2.94,3.5 9.08,-0.18c5.4,-0.11 8.85,0.2 8.5,0.75 -0.32,0.51 1.01,0.93 2.94,0.93 3.12,0 3.82,-0.52 6.21,-4.59 3,-5.11 4.86,-10.05 5.91,-15.63 0.63,-3.36 0.44,-3.84 -1.91,-4.91C240.68,238.99 240,237.93 240,235.81c0,-4.05 -2.37,-9.24 -5.14,-11.26 -2.33,-1.69 -2.3,-1.71 2.14,-1.05 2.47,0.37 6.11,1.48 8.07,2.48l3.57,1.81 1.07,-2.83c1.02,-2.71 0.9,-2.9 -3.02,-4.46 -2.25,-0.9 -6.25,-1.89 -8.89,-2.2C235.16,217.98 233,217.6 233,217.45c0,-0.61 14.25,-27.01 15.02,-27.82 0.46,-0.48 4.3,-0.55 8.54,-0.15l7.71,0.72 -0.67,8.03C263,205.4 258.41,229.23 253.04,253l-1.92,8.5 17.19,-17.51c15.47,-15.76 18.08,-18.94 26.09,-31.75 6.07,-9.72 9.46,-14.24 10.69,-14.24 2.17,0 15.01,4.54 20.05,7.09l3.64,1.84 0.72,11.09c0.4,6.1 0.9,10.91 1.11,10.7 0.21,-0.21 1.21,-4.4 2.21,-9.3 2.07,-10.16 2.32,-10.3 10.95,-6.17L348.5,215.5l-0.1,11c-0.12,13.39 -1.41,23.07 -6.49,48.7C339.76,286.04 338,295.2 338,295.57c0,0.37 2.05,-1.01 4.56,-3.07l4.56,-3.74 -1.68,3.87c-0.92,2.13 -2.53,6.62 -3.57,9.99 -2.63,8.51 -6.91,14.49 -20.88,29.15 -8.05,8.45 -18.27,15.16 -38.39,25.19 -18.06,9.01 -23.19,9.91 -30.61,5.39zM289,314c0,-1.1 -0.67,-2 -1.49,-2C284.85,312 277,314.39 277,315.21c0,0.44 2.7,0.79 6,0.79 5.33,0 6,-0.22 6,-2zM257.45,311.11c-2.15,-2.01 -12.16,-7.06 -12.78,-6.44 -0.37,0.37 -0.67,1.67 -0.67,2.89 0,1.92 0.77,2.35 5.75,3.2 7.92,1.36 8.82,1.4 7.7,0.35zM280,284c3.03,-3.03 2.4,-4.07 -1.47,-2.45 -1.91,0.8 -4.83,1.45 -6.5,1.45 -3.27,0 -4.14,1.56 -1.28,2.31 4.02,1.05 7.36,0.58 9.25,-1.31zM327.81,271.58c1.48,-0.78 4.15,-2.85 5.93,-4.6l3.24,-3.19 -1.99,-2.53c-1.61,-2.05 -1.99,-4.06 -1.99,-10.64 -0,-5.34 -0.53,-9.12 -1.54,-11.06l-1.53,-2.94 2.18,1.94c3.83,3.41 5.85,9.32 5.94,17.44 0.08,6.42 0.25,7.14 1.22,5 1.6,-3.55 1.61,-3.55 3.21,-1.43 1.35,1.79 1.46,1.64 1.49,-2.14 0.12,-14.97 -10.76,-28.2 -25.21,-30.68 -7.2,-1.23 -10.35,-0.71 -13.53,2.25 -3.21,3 -2.84,4.34 1.02,3.62 1.79,-0.34 5.05,-0.83 7.25,-1.09l4,-0.48 -3.48,0.9C310.04,232.97 306,237.12 306,240.18c0,1.66 -0.27,1.84 -1.41,0.9 -1.16,-0.96 -1.59,-0.33 -2.43,3.63 -2.11,9.86 -2.33,14.7 -0.83,18.8 0.8,2.2 1.96,3.89 2.57,3.75 0.61,-0.14 1.11,0.13 1.11,0.59 0,1.11 4.73,3.26 8.5,3.87 1.65,0.27 3.11,0.59 3.25,0.73 0.95,0.91 8.85,0.3 11.06,-0.85zM199,235.31c0,-0.16 6.6,-10.87 14.67,-23.8L228.35,188h4.94c4.63,0 4.91,0.14 4.35,2.25 -0.32,1.24 -1.82,7.95 -3.33,14.91l-2.74,12.66 -4.04,0.65c-6.35,1.01 -17.75,7.01 -23.39,12.31 -2.83,2.66 -5.15,4.7 -5.15,4.54zM353.3,179.82 L345.09,178.81 344.43,174.36c-0.37,-2.45 -0.5,-4.61 -0.3,-4.81 0.2,-0.2 5.77,-0.65 12.37,-1 6.6,-0.35 17.63,-1.37 24.5,-2.27 19.15,-2.5 17.37,-3.05 18.24,5.65 0.41,4.08 0.5,7.67 0.21,7.96 -1,1 -37.91,0.95 -46.15,-0.07zM334.85,177.26c-5.62,-0.36 -5.65,-0.37 -6.05,-3.84 -0.22,-1.92 -0.2,-3.7 0.06,-3.95 0.26,-0.26 3.09,-0.47 6.3,-0.47 5.78,0 5.83,0.03 5.83,2.92 0,1.61 0.27,3.63 0.61,4.5 0.33,0.87 0.22,1.5 -0.25,1.39 -0.47,-0.1 -3.4,-0.35 -6.5,-0.55zM302.5,171.02c-6.05,-1.53 -14.6,-3.59 -19,-4.57 -4.4,-0.98 -7.43,-1.97 -6.74,-2.2 0.69,-0.23 6.77,0.08 13.5,0.69 6.73,0.62 15.39,1.36 19.24,1.66 9.84,0.77 9.87,0.78 8.01,4.37 -0.86,1.66 -2.11,2.97 -2.78,2.92C314.05,173.85 308.55,172.55 302.5,171.02ZM240.89,159.6c-0.21,-0.19 -5.55,-0.65 -11.86,-1.03 -6.31,-0.38 -14.51,-1.33 -18.24,-2.11 -6.61,-1.39 -31.94,-8.39 -32.65,-9.02 -0.41,-0.37 2.88,-9.58 3.82,-10.69 0.59,-0.7 12.13,3.42 28.53,10.19 3.85,1.59 12.63,4.8 19.5,7.14 14.05,4.78 13.46,4.52 12.21,5.29 -0.51,0.32 -1.1,0.42 -1.32,0.23z" /> + android:fillColor="#b5b5b5" + android:pathData="m164.29,499.5c0.01,-2.75 0.2,-3.76 0.43,-2.24 0.23,1.52 0.22,3.77 -0.01,5 -0.24,1.23 -0.43,-0.01 -0.42,-2.76zM165.3,466c0,-3.03 0.19,-4.26 0.42,-2.75 0.23,1.51 0.23,3.99 0,5.5 -0.23,1.51 -0.42,0.28 -0.42,-2.75zM165.16,436c0,-1.38 0.23,-1.94 0.5,-1.25 0.28,0.69 0.28,1.81 0,2.5 -0.28,0.69 -0.5,0.13 -0.5,-1.25zM164.19,423.5c0.02,-1.65 0.24,-2.2 0.5,-1.23 0.25,0.97 0.24,2.32 -0.04,3 -0.27,0.68 -0.48,-0.12 -0.46,-1.77zM236,396.46c-5.78,-3.01 -13.94,-8.06 -18.13,-11.22l-7.63,-5.74 -0.7,-17c-0.38,-9.35 -0.55,-20.35 -0.37,-24.44l0.33,-7.44 3.5,3.01c8.53,7.34 31.98,24.54 39.29,28.81 7.2,4.21 12.65,3.44 26.72,-3.77C285.1,355.55 290.25,353 290.47,353c0.21,0 0.17,2.14 -0.09,4.75 -0.26,2.61 -0.58,6.33 -0.72,8.26 -0.14,1.93 -0.57,4.03 -0.96,4.66 -0.39,0.64 -1,3.9 -1.35,7.26l-0.64,6.11 -11.11,6.89c-12.3,7.63 -20.31,11.08 -25.61,11.03 -2.25,-0.02 -7.25,-1.99 -14,-5.51zM205.92,323.25 L203.5,320.5l2.75,2.42c2.57,2.27 3.21,3.08 2.42,3.08 -0.18,0 -1.42,-1.24 -2.75,-2.75zM329,322.5c1.87,-1.92 3.62,-3.5 3.89,-3.5 0.28,0 -1.03,1.58 -2.89,3.5 -1.87,1.92 -3.62,3.5 -3.89,3.5 -0.28,0 1.03,-1.58 2.89,-3.5zM277,315.14c0,-0.47 1.13,-0.65 2.5,-0.38 1.38,0.26 2.5,0.65 2.5,0.86 0,0.21 -1.13,0.38 -2.5,0.38 -1.38,0 -2.5,-0.39 -2.5,-0.86zM255.51,310.98c0.35,-0.56 1.05,-0.76 1.56,-0.44 1.39,0.86 1.13,1.46 -0.63,1.46 -0.86,0 -1.28,-0.46 -0.93,-1.02zM196.36,294c0,-5.22 0.17,-7.36 0.37,-4.75 0.21,2.61 0.21,6.89 0,9.5 -0.21,2.61 -0.37,0.47 -0.37,-4.75zM343.38,291.27c1.66,-1.97 3.62,-2.26 3.62,-0.55 0,0.33 -0.3,0.31 -0.67,-0.06 -0.37,-0.37 -1.61,0.12 -2.75,1.08 -2.08,1.75 -2.08,1.75 -0.2,-0.47zM273.25,285.31c0.96,-0.25 2.54,-0.25 3.5,0 0.96,0.25 0.17,0.46 -1.75,0.46 -1.92,0 -2.71,-0.21 -1.75,-0.46zM269,283.91C269,283.41 269.9,283 271,283c1.1,0 2,0.17 2,0.38 0,0.21 -0.9,0.62 -2,0.91 -1.1,0.29 -2,0.12 -2,-0.38zM197.34,263c0,-4.13 0.18,-5.81 0.39,-3.75 0.22,2.06 0.22,5.44 0,7.5 -0.22,2.06 -0.39,0.38 -0.39,-3.75zM344.08,263.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM338.19,256.5c0.02,-1.65 0.24,-2.2 0.5,-1.23 0.25,0.97 0.24,2.32 -0.04,3 -0.27,0.68 -0.48,-0.12 -0.46,-1.77zM252.08,256.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM346.08,251.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM254.08,247.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM347.16,245c0,-1.38 0.23,-1.94 0.5,-1.25 0.28,0.69 0.28,1.81 0,2.5 -0.28,0.69 -0.5,0.13 -0.5,-1.25zM331.31,238.97C331.41,238.58 330.38,237.27 329,236.05l-2.5,-2.22 3.22,2.13c3.82,2.53 4.71,3.71 2.79,3.71 -0.77,0 -1.31,-0.32 -1.21,-0.7zM348.23,236c0,-1.92 0.21,-2.71 0.46,-1.75 0.25,0.96 0.25,2.54 0,3.5 -0.25,0.96 -0.46,0.17 -0.46,-1.75zM333.87,234.25c-1.24,-1.59 -1.21,-1.62 0.38,-0.38 1.67,1.31 2.2,2.13 1.37,2.13 -0.21,0 -1,-0.79 -1.75,-1.75zM199.54,234.07c-0.32,-0.51 -0.19,-1.17 0.27,-1.46 0.47,-0.29 0.85,0.13 0.85,0.93 0,1.69 -0.32,1.83 -1.12,0.53zM280.5,231c1,-1.1 2.03,-2 2.31,-2 0.28,0 -0.31,0.9 -1.31,2 -1,1.1 -2.03,2 -2.31,2 -0.28,0 0.31,-0.9 1.31,-2zM315.81,226.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM331.08,224.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM260.08,219.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM329.3,213c0,-3.03 0.19,-4.26 0.42,-2.75 0.23,1.51 0.23,3.99 0,5.5 -0.23,1.51 -0.42,0.28 -0.42,-2.75zM261.08,214.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM343.54,213.13c-2.73,-1.3 -5.92,-2.43 -7.08,-2.5 -1.95,-0.12 -2.04,-0.45 -1.2,-4.13 1.6,-6.94 1.41,-6.73 4.47,-5.12 5.08,2.66 9.45,8.06 9.09,11.24L348.5,215.5ZM262.08,209.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM325.51,205.26c-5.62,-2.95 -18.97,-7.4 -21.03,-7.01 -1.7,0.33 -0.87,-1.58 4.77,-10.96 3.75,-6.24 6.51,-11.86 6.13,-12.48 -0.41,-0.66 -0.25,-0.85 0.39,-0.45 1.09,0.68 2.87,-3 2.34,-4.86 -0.15,-0.55 0.06,-0.66 0.47,-0.24 0.42,0.42 2.37,-2.06 4.33,-5.5L326.5,157.5l0.3,6.75c0.16,3.71 0.73,6.75 1.25,6.75 0.52,0 0.95,0.79 0.94,1.75 -0.01,1.13 -0.27,1.34 -0.75,0.59 -0.4,-0.64 -0.58,3.45 -0.4,9.09l0.34,10.25 -8.23,-0.48c-10.3,-0.6 -10.14,1.24 0.18,2.1 3.95,0.33 7.59,1.01 8.09,1.5 0.5,0.5 0.76,3.18 0.6,5.96L328.5,206.83ZM263.16,202c0,-1.38 0.23,-1.94 0.5,-1.25 0.28,0.69 0.28,1.81 0,2.5 -0.28,0.69 -0.5,0.13 -0.5,-1.25zM342.69,200.99c-2.1,-1.58 -4.23,-3.01 -4.73,-3.18 -0.5,-0.17 0.1,-4.85 1.35,-10.41 1.25,-5.55 2.09,-11.94 1.88,-14.2l-0.39,-4.11 -5.65,-0.36 -5.65,-0.36 6.25,-0.19c5.84,-0.18 6.26,-0.04 6.37,2.06 0.11,2.13 0.15,2.11 0.75,-0.25 0.6,-2.36 0.92,-2.47 5.63,-1.91l5,0.59 -4.75,0.16c-5.5,0.18 -5.47,0.09 -3.32,10.66 1.83,9.02 3.07,24.52 1.94,24.43 -0.48,-0.04 -2.58,-1.36 -4.68,-2.94zM264,192.09c0,-1.65 -1.02,-2.01 -7.42,-2.61 -4.08,-0.38 -7.95,-0.37 -8.6,0.04 -0.65,0.4 -0.39,-0.48 0.58,-1.96 1.72,-2.62 2.57,-2.75 6.69,-1.06 0.99,0.41 1.75,0.28 1.75,-0.29 0,-0.56 -1.37,-1.49 -3.04,-2.07l-3.04,-1.06 3.67,-6.79C263.27,160.22 266.15,155 266.32,155c0.1,0 -0.13,5.06 -0.52,11.25 -0.39,6.19 -0.77,14.96 -0.85,19.5 -0.08,4.54 -0.33,8.25 -0.55,8.25C264.18,194 264,193.14 264,192.09ZM229,187.13c0,-2.08 3.29,-4.13 6.64,-4.13 3.06,0 3.46,0.28 3.18,2.25 -0.27,1.92 -1.02,2.29 -5.07,2.55 -2.65,0.17 -4.75,-0.13 -4.75,-0.68zM237.17,173.7c2.92,-4.71 5.46,-8.41 5.65,-8.21 0.19,0.19 -0.41,3.76 -1.33,7.93 -1.51,6.78 -1.93,7.58 -4,7.58 -1.27,0 -3.06,0.28 -3.97,0.63 -1.02,0.39 0.38,-2.65 3.65,-7.93zM371.75,181.26c2.89,-0.2 7.61,-0.2 10.5,0 2.89,0.2 0.52,0.37 -5.25,0.37 -5.78,0 -8.14,-0.17 -5.25,-0.37zM357.75,180.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM394.27,180.31c0.97,-0.25 2.32,-0.24 3,0.04 0.68,0.27 -0.12,0.48 -1.77,0.46 -1.65,-0.02 -2.2,-0.24 -1.23,-0.5zM348.75,179.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM331.77,177.28c1.8,-0.22 4.5,-0.22 6,0.01 1.5,0.23 0.03,0.41 -3.27,0.4 -3.3,-0.01 -4.53,-0.19 -2.73,-0.41zM399.19,172.5c0.02,-1.65 0.24,-2.2 0.5,-1.23 0.25,0.97 0.24,2.32 -0.04,3 -0.27,0.68 -0.48,-0.12 -0.46,-1.77zM302.81,171.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM298.81,170.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM354.81,168.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM368.27,167.31c0.97,-0.25 2.32,-0.24 3,0.04 0.68,0.27 -0.12,0.48 -1.77,0.46 -1.65,-0.02 -2.2,-0.24 -1.23,-0.5zM308.75,166.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM376.75,166.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM297.81,165.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM384.75,165.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM274.48,164.04C274.83,163.47 276.29,163 277.73,163c3.13,0 2.77,0.48 -1.06,1.4 -1.86,0.45 -2.61,0.32 -2.19,-0.36zM286.25,164.31c0.96,-0.25 2.54,-0.25 3.5,0 0.96,0.25 0.17,0.46 -1.75,0.46 -1.92,0 -2.71,-0.21 -1.75,-0.46zM391.81,164.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM235.76,159.29c1.24,-0.24 3.04,-0.23 4,0.02 0.96,0.25 -0.06,0.45 -2.26,0.43 -2.2,-0.01 -2.98,-0.22 -1.74,-0.46zM242.44,159.09c0.31,-0.5 1.46,-0.67 2.56,-0.38 2.75,0.72 2.5,1.29 -0.56,1.29 -1.41,0 -2.31,-0.41 -2,-0.91zM222.75,158.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM208.81,156.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6z" /> + android:fillColor="#9b9b9b" + android:pathData="m234.75,181.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5z" /> + android:fillColor="?colorPrimary" + android:pathData="m329.66,214.5c-0.38,-7.7 -0.92,-18.73 -1.2,-24.5 -0.28,-5.78 -0.7,-12.28 -0.92,-14.45 -0.27,-2.65 -0.1,-3.47 0.51,-2.5 0.5,0.8 0.92,2.24 0.93,3.2 0.01,1.47 0.97,1.75 6.02,1.75 3.3,0 6,0.3 6,0.67 0,0.77 -6.16,29.89 -8.85,41.83l-1.8,8z" /> + android:fillColor="#9b9b9b" + android:pathData="M217.33,256.56C213.76,254.99 212,252.35 212,248.57c0,-3.67 1.54,-3.17 3.67,1.18 1.56,3.2 2.37,3.8 5.54,4.06 3.99,0.33 6.67,-1.72 7.94,-6.08 0.51,-1.75 0.67,-1.46 0.75,1.35 0.18,6.32 -6.38,10.23 -12.57,7.49z" /> + android:fillColor="#9b9b9b" + android:pathData="m312,266.29c-3.65,-1.34 -5,-3.62 -5,-8.45 0,-2.66 0.45,-4.85 1,-4.85 0.55,0 1,1.32 1,2.93 0,5.39 5.97,8.63 10.47,5.68 3.26,-2.14 3.82,-2.03 2.48,0.47 -0.88,1.65 -6.88,5.2 -8.17,4.84 -0.16,-0.04 -0.96,-0.33 -1.78,-0.63z" /> + android:fillColor="#9b9b9b" + android:pathData="m273,283.81c1.92,-0.54 4.51,-1.39 5.75,-1.88 1.24,-0.49 2.25,-0.54 2.25,-0.11 0,1.94 -2.77,3.17 -6.95,3.08l-4.55,-0.1z" /> + android:fillColor="#9b9b9b" + android:pathData="m155.81,307.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6z" /> + android:pathData="m317.39,370c0.36,-1.38 1.32,-5.43 2.13,-9 0.82,-3.58 2.16,-8.96 2.98,-11.97 0.82,-3.01 1.52,-6.38 1.56,-7.5 0.06,-1.69 0.19,-1.75 0.75,-0.38 0.59,1.44 1.84,1.1 9.79,-2.62l9.11,-4.27 2.14,-7.04c1.95,-6.43 6.83,-44.86 5.83,-45.87 -0.23,-0.23 -0.98,0.95 -1.67,2.61 -0.69,1.67 -1.7,3.03 -2.24,3.03 -0.54,0 -0.72,0.7 -0.39,1.55 0.37,0.96 0.18,1.3 -0.48,0.89 -0.59,-0.37 -2.89,1.07 -5.1,3.2 -2.9,2.79 -3.86,3.31 -3.44,1.86 1.29,-4.42 7.64,-37.67 9.09,-47.63 1.46,-9.99 2.19,-39.9 1.01,-41.08 -0.3,-0.3 -0.7,-3.86 -0.89,-7.92 -0.2,-4.06 -0.68,-9.99 -1.08,-13.18 -0.72,-5.73 -0.7,-5.79 1.64,-5.28 7.5,1.63 26.74,2.74 37.21,2.13 13.27,-0.77 14.62,-1.08 14.77,-3.41 0.06,-0.9 0.93,6.23 1.94,15.86 3.02,28.78 3.41,30.88 13.92,75.5 0.92,3.89 0.9,3.94 -0.76,1.8C399.93,251.54 392.41,242.05 392,242.03c-0.42,-0.02 -3.74,10.44 -9.49,29.97 -5.19,17.62 -9.69,31.61 -11.38,35.45 -1.1,2.48 -5.35,8 -9.45,12.28C354.91,326.78 347,336.34 347,337.47c0,0.25 -3.34,4.86 -7.42,10.24 -8.98,11.85 -23.18,26.12 -22.19,22.29z" /> + android:pathData="m168.39,369.53c0.89,-1.36 2.4,-3.79 3.37,-5.41 5.63,-9.47 7.97,-13.14 11.25,-17.62 6,-8.18 9.14,-14.46 8.4,-16.78 -0.36,-1.15 -3.03,-7.06 -5.92,-13.15 -2.89,-6.09 -6.2,-13.16 -7.35,-15.71 -1.15,-2.55 -4.31,-9.3 -7.01,-14.99l-4.92,-10.35 -1.14,4.24c-1.22,4.52 -2.39,5.33 -3.59,2.49 -0.41,-0.96 -0.9,-12.98 -1.09,-26.7 -0.19,-13.72 -0.81,-29.41 -1.37,-34.86 -2.3,-22.45 -1.17,-39.62 4.39,-66.62 1.41,-6.84 2.39,-12.6 2.19,-12.8C164.48,140.14 156,167.52 156,172.27c0,3.12 -1.83,4.66 -6.41,5.41 -1.97,0.32 -3.58,0.86 -3.58,1.2 0.01,0.34 2.25,2.42 4.98,4.62l4.96,4 -0.65,5.5c-0.56,4.8 -0.55,17.34 0.05,65.29 0.16,12.32 0.44,11.87 -8.75,13.75 -3.36,0.69 -6.46,1.56 -6.89,1.93 -0.42,0.37 2.05,1.9 5.5,3.38 9.38,4.04 9.21,3.88 9.92,9.76 1.29,10.71 0.98,16.47 -0.98,18.24 -1.01,0.91 -2.47,1.7 -3.25,1.75 -0.78,0.05 -0.36,0.4 0.94,0.76 2.07,0.58 -7.69,3.61 -27.86,8.65 -0.82,0.21 -3.41,0.96 -5.75,1.68l-4.25,1.31 4.98,8c2.74,4.4 5.4,8.9 5.9,10 0.51,1.1 2.09,3.71 3.52,5.79 4.64,6.77 2.77,5.82 -5.55,-2.83 -4.48,-4.66 -10.54,-10.05 -13.46,-11.99 -2.92,-1.94 -5.58,-3.97 -5.92,-4.52 -0.34,-0.55 -2.87,-2.14 -5.63,-3.53 -4.04,-2.05 -5.41,-3.44 -7.06,-7.17 -2.85,-6.43 -1.71,-10.92 3.46,-13.64 3.3,-1.73 5.1,-3.83 3,-3.5 -4.03,0.64 -6.21,-2.22 -6.58,-8.61 -0.16,-2.83 0.19,-3.12 6.61,-5.38 3.73,-1.31 6.66,-2.51 6.5,-2.66 -0.16,-0.15 -1.9,-0.53 -3.87,-0.84 -1.97,-0.31 -4.22,-0.78 -5,-1.05 -0.78,-0.27 -3.04,-0.72 -5.03,-1 -7.14,-1.03 -8.33,-1.98 -10.29,-8.19 -1.56,-4.95 -3.61,-19.34 -3.68,-25.8C75.89,240.26 82.24,233.54 87,230.87l3.5,-1.97 -3.5,0.62c-1.92,0.34 -4.4,0.81 -5.5,1.05 -4.67,1.01 -9.03,1.74 -9.44,1.6 -0.24,-0.09 -0.4,-7.25 -0.36,-15.91 0.07,-15.57 0.04,-15.78 -2.49,-18.49 -1.41,-1.51 -2.37,-2.92 -2.14,-3.14 1.28,-1.19 17.22,-8.88 21.93,-10.59 5.21,-1.89 5.29,-1.97 1.5,-1.48 -7.72,0.99 -23.92,3.51 -26.09,4.06 -3.03,0.76 -3.05,0.73 -4.91,-10.61 -0.41,-2.47 -0.88,-5.27 -1.05,-6.2 -0.97,-5.24 -3.79,-24.64 -3.86,-26.59 -0.09,-2.47 1.51,-3.65 9.83,-7.27 6.12,-2.66 21.61,-6.63 29.86,-7.66 14.15,-1.76 54.29,-0.69 62.53,1.67 1.78,0.51 3.46,0.92 3.73,0.92 0.27,-0.01 1.83,0.28 3.46,0.64 2.45,0.54 3.4,0.16 5.45,-2.17 1.37,-1.55 2.5,-3.35 2.52,-3.99 0.02,-0.64 0.38,-1.32 0.8,-1.5 0.42,-0.18 3.93,-4 7.79,-8.48 3.86,-4.48 9.49,-10.17 12.52,-12.63 14.79,-12.05 45.18,-22.25 71.86,-24.12 1.89,-0.13 5.72,0.12 8.5,0.56 2.78,0.44 6.56,1.04 8.4,1.33 3.54,0.56 11.51,3.72 16.97,6.72 1.82,1 4.97,2.06 7,2.36 7.25,1.06 16.94,4.84 33.24,12.98l16.55,8.26 1.1,-2.65c2.16,-5.21 2.88,-10.63 3.77,-28.15 0.66,-12.99 1.59,-28.19 2.05,-33.5 0.19,-2.2 0.66,-9.85 1.04,-17 0.38,-7.15 0.85,-13.17 1.04,-13.39 0.7,-0.79 6.64,19.29 8.9,30.06 0.19,0.92 1.51,6.55 2.93,12.5 3.16,13.25 4.26,18.49 5.13,24.33 0.37,2.47 0.85,5.4 1.08,6.5 0.22,1.1 0.69,6.4 1.03,11.77 0.75,11.66 -1.15,19.56 -5.92,24.67l-3.01,3.22 3.51,3.32c3.42,3.22 7.1,4.33 14.91,4.48 3.88,0.07 5.24,1.96 6.81,9.47 0.77,3.68 1.65,6.85 1.96,7.03 0.31,0.19 3.71,0.61 7.57,0.93 3.85,0.33 9.7,1.31 13,2.18 3.3,0.87 6.75,1.75 7.68,1.94 0.93,0.19 2.61,0.85 3.75,1.46 1.14,0.61 2.06,0.85 2.06,0.54 0,-0.31 1.35,0.13 2.99,0.99 1.64,0.85 3.11,1.45 3.25,1.33 0.23,-0.19 13.3,4.68 22.26,8.3 1.65,0.67 3.22,1.29 3.5,1.38 4.67,1.59 11.48,6.66 13.16,9.8 0.62,1.16 1.1,3.31 1.05,4.77 -0.08,2.7 -5.37,25.1 -7.66,32.43 -0.69,2.2 -2.28,7.6 -3.53,12 -1.25,4.4 -3.98,13.4 -6.05,20 -4.8,15.27 -5.86,19.7 -6.38,26.5 -0.23,3.03 -0.46,5.68 -0.51,5.9 -0.05,0.22 -0.88,-0.23 -1.84,-1.01 -4.89,-3.96 -14.18,-7.71 -21.25,-8.58l-4.5,-0.55 5.4,3.87c8.4,6.02 14.84,12.22 15.53,14.95 0.36,1.42 -0.02,3.87 -0.88,5.7 -0.83,1.77 -3.33,8.61 -5.56,15.21 -2.23,6.6 -4.47,12.9 -4.96,14 -0.5,1.1 -1.2,3.04 -1.55,4.31 -0.53,1.88 -1.14,2.21 -3.31,1.76 -1.46,-0.31 -4.46,-0.82 -6.66,-1.15 -2.2,-0.32 -6.8,-1.17 -10.22,-1.88 -6.05,-1.25 -6.19,-1.23 -5.17,0.67 0.58,1.08 2.5,3.61 4.28,5.62 5.11,5.78 7.59,9.77 6.68,10.75 -0.46,0.5 -1.85,4.06 -3.09,7.91 -3.39,10.51 -2.62,10.18 -14.87,6.28 -7.37,-2.35 -16.69,-2.1 -24.12,0.64 -2.47,0.91 -5.18,1.83 -6,2.04 -0.82,0.21 -3.24,1.12 -5.36,2.02 -2.12,0.9 -4.08,1.42 -4.36,1.14 -1.64,-1.64 13.53,-12.21 18.87,-13.14 5.39,-0.94 5.61,-2.23 0.77,-4.43 -2.43,-1.1 -5.32,-2.02 -6.42,-2.03 -1.93,-0.02 -1.99,-0.59 -1.7,-16.02 0.22,-11.63 0.63,-16 1.5,-16.01 0.66,-0 1.28,-1.46 1.36,-3.25 0.09,-1.79 2.93,-11.35 6.32,-21.25 3.39,-9.9 7.33,-21.56 8.77,-25.92l2.61,-7.92 7.57,8.06c4.16,4.43 10.33,11.15 13.7,14.92 3.37,3.77 6.39,6.61 6.71,6.31 0.32,-0.3 -0.73,-6.24 -2.33,-13.2 -8.9,-38.71 -13.98,-69.34 -17.59,-106.02 -0.58,-5.88 -1.03,-7.03 -3.45,-8.75C399.27,156.35 399,156.44 399,160.33c0,1.98 -0.51,3.35 -1.25,3.38 -0.69,0.02 -8.84,1.03 -18.11,2.23 -10.54,1.37 -20.57,2.07 -26.75,1.87l-9.89,-0.31 -0.13,2.5c-0.13,2.35 -0.16,2.36 -0.57,0.25 -0.39,-2.03 -1.01,-2.25 -6.29,-2.25 -3.92,0 -6.36,0.51 -7.38,1.53 -1.37,1.37 -1.56,0.82 -1.82,-5.25L326.5,157.5 317.59,173c-10.54,18.35 -27.33,45.36 -32.78,52.74 -3.74,5.07 -31.84,34.57 -32.4,34.01 -0.15,-0.15 1.73,-9.5 4.17,-20.77 6.13,-28.26 7.28,-35.29 7.98,-48.78 0.33,-6.44 0.91,-17 1.28,-23.46 0.37,-6.46 0.58,-11.75 0.48,-11.75 -0.23,0 -6.54,11.49 -12.61,23 -2.47,4.68 -6.54,12.17 -9.04,16.65 -2.5,4.48 -6.18,11.39 -8.18,15.35 -2,3.96 -3.79,7.04 -3.99,6.84 -0.2,-0.2 0.32,-3.28 1.15,-6.85 1.78,-7.68 8.22,-38.19 8.98,-42.49 0.47,-2.67 0.17,-2.45 -2.71,2 -6.17,9.54 -7.64,12 -7.7,12.89 -0.03,0.49 -0.46,1.16 -0.94,1.5 -2.69,1.86 -32.28,49.9 -32.3,52.44 -0.01,1.44 -0.28,1.76 -0.8,0.94 -0.53,-0.83 -4.65,3.63 -12.44,13.46 -6.41,8.09 -11.51,14.84 -11.33,15.02 0.24,0.24 19.75,-8.63 22.59,-10.27 0.28,-0.16 0.11,12.05 -0.37,27.14l-0.87,27.43 2.47,3.73c1.36,2.05 4.33,5.76 6.59,8.23l4.12,4.5 0.03,8.78 0.03,8.78 -4.19,3.72c-2.3,2.05 -6.47,5.75 -9.25,8.22 -6.84,6.08 -9.73,7.98 -18.8,12.3 -9.19,4.39 -10.58,4.59 -8.37,1.22zM319,168.08c0,-0.48 -3.26,-1.17 -7.25,-1.54 -3.99,-0.37 -13.65,-1.31 -21.47,-2.1 -23.99,-2.42 -23.36,-1.7 4.54,5.14l20.68,5.07 1.75,-2.84c0.96,-1.56 1.75,-3.24 1.75,-3.72zM246.92,159.6C246.69,159.38 239.3,156.71 230.5,153.67c-8.8,-3.04 -22.3,-7.98 -30,-10.99 -20.27,-7.9 -18.12,-7.79 -21.06,-1.13 -1.39,3.13 -2.39,5.83 -2.23,5.99 0.16,0.16 7.27,2.31 15.79,4.77 13.4,3.88 22.36,5.54 38,7.04 5.48,0.53 16.38,0.7 15.92,0.25z" /> + android:fillColor="#9b9b9b" + android:pathData="m287.08,377.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31z" /> - + android:fillColor="#9b9b9b" + android:pathData="m209.35,371c0,-4.68 0.17,-6.59 0.38,-4.25 0.21,2.34 0.21,6.16 0,8.5 -0.21,2.34 -0.38,0.43 -0.38,-4.25z" /> + android:fillColor="#9b9b9b" + android:pathData="m164.23,507c0,-1.92 0.21,-2.71 0.46,-1.75 0.25,0.96 0.25,2.54 0,3.5 -0.25,0.96 -0.46,0.17 -0.46,-1.75z" /> + android:fillColor="#696969" + android:pathData="m164.96,511.25c-0.2,-3.39 0.27,-33.51 0.56,-36.25 0.55,-5.09 0.64,-40.06 0.11,-43 -1.13,-6.3 -1.64,-20 -0.74,-20 2.26,0 13.11,-7.35 13.11,-8.88 0,-0.91 1.8,-2.76 4,-4.12 2.2,-1.36 4,-2.85 4,-3.31 0,-2.64 4.11,1.21 12.8,12C207.51,418.5 221.94,433 224,433c0.41,0 1.04,-1.44 1.39,-3.2 0.58,-2.89 0.11,-3.75 -4.75,-8.75 -6.11,-6.29 -6.21,-6.4 -18.65,-22.05 -5.03,-6.32 -9.6,-11.38 -10.14,-11.23 -0.55,0.15 -0.81,-0.07 -0.59,-0.5 3.55,-6.73 5.01,-11.38 4.84,-15.43l-0.19,-4.65 5.3,-1.74c2.91,-0.96 5.86,-1.95 6.55,-2.2 0.93,-0.34 1.25,1.57 1.25,7.59v8.05l8.26,6.16c4.54,3.39 12.88,8.53 18.54,11.44 10.02,5.14 10.19,5.29 6.96,5.9 -1.83,0.34 -4.25,1.28 -5.39,2.07 -2.21,1.55 -7.89,17.16 -19.93,54.86 -7.34,22.96 -7.09,22.71 -11.18,11.68 -1.91,-5.14 -11.22,-27.4 -16.79,-40.13C188.11,427.78 187,424.77 187,424.18c0,-0.59 -0.44,-1.34 -0.97,-1.67 -0.54,-0.33 1.3,5.83 4.09,13.69 2.78,7.86 7.54,21.72 10.57,30.79 4.58,13.73 6.08,17.05 8.9,19.75 4.35,4.16 6.76,4.16 8.74,-0 4.28,-9.01 6.13,-14.38 9.26,-26.75 1.88,-7.43 3.21,-11.93 2.96,-10 -0.25,1.92 -0.73,6.65 -1.06,10.5 -0.34,3.85 -1.01,10.6 -1.49,15 -0.49,4.4 -1.19,12.05 -1.56,17 -0.37,4.95 -0.81,9.68 -0.97,10.5 -0.16,0.82 -0.33,3.19 -0.37,5.25L225,512h-30c-16.5,0 -30.02,-0.34 -30.04,-0.75zM255.54,509.25c0.15,-1.51 0.58,-5.9 0.95,-9.75 0.37,-3.85 0.82,-8.35 1,-10 0.18,-1.65 0.62,-6.6 0.97,-11 0.35,-4.4 0.82,-9.8 1.05,-12 0.75,-7.2 1.43,-16.88 1.62,-23.25 0.1,-3.44 0.51,-6.25 0.91,-6.25 0.4,0 1.49,2.35 2.43,5.22 2.56,7.84 11.04,26.36 14.62,31.93 3.73,5.82 6.07,7.37 8.86,5.87 5.6,-3 12.37,-16.71 16.53,-33.5 3.76,-15.17 4.96,-21.29 3.98,-20.32 -0.71,0.7 -6.05,12.5 -9.43,20.81 -1.01,2.48 -3.5,8.1 -5.53,12.5 -2.04,4.4 -4.28,9.34 -5,10.99l-1.29,3 -1.78,-4c-0.98,-2.2 -4.34,-10.3 -7.48,-18l-5.7,-14 3.11,-2.46c10.95,-8.66 23.25,-23.55 31.94,-38.67 0.36,-0.62 -0.32,-2.42 -1.51,-4l-2.16,-2.87 3.43,3.06c1.88,1.68 5.11,4.29 7.18,5.8 2.06,1.51 3.75,3.39 3.75,4.19 0,0.85 1.04,1.5 2.5,1.57 1.38,0.07 2.84,0.21 3.25,0.31 0.41,0.1 2.21,0.53 4,0.95 3.15,0.75 3.23,0.89 2.67,4.94 -0.32,2.29 -0.77,4.76 -0.99,5.48 -0.22,0.72 2.05,12.42 5.04,26 3,13.58 6.86,31.22 8.58,39.19 1.72,7.97 3.76,17.38 4.52,20.89 0.76,3.51 1.65,7.23 1.97,8.25 0.57,1.8 -1.06,1.86 -46.85,1.86h-47.44zM267.68,425.88c-0.8,-1.86 -2.91,-6.76 -4.68,-10.88 -3.76,-8.76 -4.55,-9.87 -7.96,-11.2 -2.4,-0.93 -2.18,-1.13 4.07,-3.65 6.26,-2.52 26.28,-14.38 27.45,-16.26 0.3,-0.48 0.77,-3.02 1.06,-5.64 0.8,-7.42 0.59,-7.22 6.49,-5.91 5.46,1.21 5.68,1.49 7.32,9.15 0.24,1.1 0.72,3.36 1.08,5.03 1.21,5.65 -12.07,23.26 -29.69,39.37l-3.68,3.37zM241.45,425.55c-3.15,-3.15 -3.19,-7.57 -0.11,-11.16 2.95,-3.43 7.81,-3.68 10.94,-0.55 3.1,3.1 3.01,9.7 -0.17,12.27 -3.27,2.64 -7.69,2.41 -10.66,-0.56zM284.5,315.26c1.65,-0.24 3.33,-0.96 3.73,-1.6 0.48,-0.75 0.74,-0.55 0.75,0.59 0.01,1.38 -0.77,1.72 -3.73,1.6 -3.4,-0.14 -3.47,-0.19 -0.75,-0.59zM343.08,255.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM338.08,251.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM313.75,230.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM311.27,226.31c0.97,-0.25 2.32,-0.24 3,0.04 0.68,0.27 -0.12,0.48 -1.77,0.46 -1.65,-0.02 -2.2,-0.24 -1.23,-0.5zM231.26,218.28c1.52,-0.23 3.77,-0.22 5,0.01 1.23,0.24 -0.01,0.43 -2.76,0.42 -2.75,-0.01 -3.76,-0.2 -2.24,-0.43z" /> + android:fillColor="#666666" + android:pathData="m248.81,402.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM365.49,347.99c-0.3,-0.49 -0.34,-2.06 -0.08,-3.49 0.26,-1.43 0.78,-7.94 1.17,-14.47l0.69,-11.87 4.31,-1.08C373.95,316.49 376.37,316 376.97,316c0.74,0 1,4.93 0.81,15.71l-0.28,15.71 -3.5,0.21c-1.92,0.11 -4.5,0.44 -5.73,0.73 -1.23,0.29 -2.48,0.12 -2.78,-0.37zM325.21,335.04c0.43,-7.81 0.53,-8.05 4.91,-12.75 6.42,-6.88 10.52,-13.22 11.88,-18.33 1.38,-5.18 9.11,-23.18 9.71,-22.59 0.99,0.99 -3.9,39.47 -5.81,45.76 -2.3,7.59 -2.2,7.49 -15.4,13.33l-5.73,2.53zM373,313.73c-1.92,-0.59 -3.62,-1.15 -3.76,-1.24 -0.14,-0.1 0.67,-2.28 1.81,-4.86 1.76,-3.99 6.21,-17.82 11.46,-35.63 5.75,-19.52 9.07,-29.99 9.49,-29.97 0.41,0.02 7.93,9.51 23.2,29.27 1.96,2.54 2.46,5.23 -4.16,-22.55 -2.2,-9.22 -4.65,-20.85 -5.46,-25.86 -2.12,-13.19 -7.12,-64.69 -6.35,-65.45 0.36,-0.36 1.97,0.29 3.59,1.44 2.6,1.85 3.02,2.88 3.6,8.86 3.61,36.68 8.69,67.31 17.59,106.02 1.6,6.96 2.65,12.9 2.33,13.2 -0.32,0.3 -3.34,-2.54 -6.71,-6.31 -3.37,-3.77 -9.54,-10.48 -13.7,-14.92l-7.57,-8.06 -2.61,7.92c-1.44,4.35 -5.29,15.79 -8.57,25.42 -3.28,9.63 -6.27,18.96 -6.65,20.75 -0.75,3.53 -1.65,3.77 -7.53,1.98zM151.53,307.14c4.33,-1.94 4.79,-3.56 4.06,-14.21 -0.38,-5.51 -0.95,-10.7 -1.26,-11.52 -0.48,-1.26 0.01,-1.22 3.05,0.23L161,283.37v11.81c0,10.58 -0.18,11.82 -1.75,11.83 -0.96,0.01 -3.55,0.24 -5.75,0.53 -2.92,0.38 -3.45,0.27 -1.97,-0.4zM155.97,271.75c-1.2,-10.34 -1.6,-67.48 -0.55,-78.75l0.51,-5.5 -4.95,-4c-2.72,-2.2 -4.96,-4.28 -4.96,-4.62 -0.01,-0.34 1.6,-0.88 3.58,-1.2 4.58,-0.74 6.41,-2.29 6.41,-5.41 0,-4.75 8.48,-32.13 9.6,-31.01 0.2,0.2 -0.78,5.97 -2.19,12.8 -5.56,26.98 -6.73,45.05 -4.33,66.93 1.2,10.97 1.49,40.69 0.45,46.83C159.09,270.42 158.29,272 157.41,272c-0.78,0 -1.43,-0.11 -1.44,-0.25zM175.19,264.17c6.03,-8.45 22.32,-27.96 22.75,-27.26 0.3,0.49 0.41,4.78 0.23,9.53l-0.32,8.65 -11.48,5.46C180.05,263.54 174.66,266 174.38,266c-0.27,0 0.09,-0.82 0.81,-1.83zM307.87,264.25c-1.24,-1.59 -1.21,-1.62 0.38,-0.38 0.96,0.75 1.75,1.54 1.75,1.75 0,0.82 -0.82,0.29 -2.13,-1.37zM314.75,262.34c0.69,-0.28 1.81,-0.28 2.5,0 0.69,0.28 0.13,0.5 -1.25,0.5 -1.38,0 -1.94,-0.23 -1.25,-0.5zM306.29,257.5c0.01,-2.75 0.2,-3.76 0.43,-2.24 0.23,1.52 0.22,3.77 -0.01,5 -0.24,1.23 -0.43,-0.01 -0.42,-2.76zM320.23,260.42c0.95,-0.99 1.94,-1.59 2.2,-1.32 0.26,0.26 -0.51,1.07 -1.73,1.8 -2.11,1.26 -2.13,1.24 -0.48,-0.48zM309.08,257.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31zM227.5,255c1,-1.1 2.03,-2 2.31,-2 0.28,0 -0.31,0.9 -1.31,2 -1,1.1 -2.03,2 -2.31,2 -0.28,0 0.31,-0.9 1.31,-2zM216.87,252.25c-1.27,-1.62 -1.22,-1.64 0.63,-0.28 1.1,0.81 2.19,1.59 2.42,1.75 0.23,0.16 -0.05,0.28 -0.63,0.28 -0.57,0 -1.66,-0.79 -2.42,-1.75zM211.19,248.5c0.02,-1.65 0.24,-2.2 0.5,-1.23 0.25,0.97 0.24,2.32 -0.04,3 -0.27,0.68 -0.48,-0.12 -0.46,-1.77zM230.16,249c0,-1.38 0.23,-1.94 0.5,-1.25 0.28,0.69 0.28,1.81 0,2.5 -0.28,0.69 -0.5,0.13 -0.5,-1.25zM238.08,247.42c0.05,-1.16 0.28,-1.4 0.6,-0.6 0.29,0.72 0.25,1.58 -0.08,1.92 -0.33,0.33 -0.57,-0.26 -0.53,-1.31z" /> + android:fillColor="#4e4e4e" + android:pathData="m225.39,504.28c1.12,-13.31 1.67,-19.47 2.61,-28.78 0.5,-4.95 1.15,-11.48 1.44,-14.5 0.29,-3.03 0.96,-9.55 1.49,-14.5 0.52,-4.95 1.26,-14.85 1.63,-22 0.81,-15.58 1.96,-18.99 7.13,-21.15 5.45,-2.28 15.85,-0.53 19.09,3.2 2.12,2.45 2.27,3.48 2.17,14.79 -0.06,6.69 0.01,13.51 0.15,15.16 0.27,3.2 -0.93,26.98 -1.53,30.12 -0.2,1.03 -0.68,6.15 -1.08,11.38 -0.4,5.22 -0.86,10.63 -1.03,12 -0.43,3.52 -1.72,17.1 -1.85,19.5 -0.11,1.9 -0.91,2.01 -15.49,2.28l-15.38,0.28zM252.12,426.11c3.18,-2.57 3.27,-9.17 0.17,-12.27 -3.13,-3.13 -7.99,-2.88 -10.94,0.55 -6.55,7.61 3.01,18 10.77,11.72zM270.71,433.23 L269.22,429.5 280.39,418.5c7.54,-7.43 13.08,-13.98 17.07,-20.2l5.9,-9.2 2.29,3.1c1.26,1.71 2,3.6 1.66,4.2 -6.22,10.85 -16.64,24.65 -23.42,31.03 -10.91,10.26 -11.32,10.44 -13.17,5.8zM215.97,426.48c-4,-3.59 -12.07,-12.48 -17.94,-19.75 -10.65,-13.2 -10.66,-13.23 -9.1,-15.97 0.86,-1.51 2.06,-2.93 2.67,-3.16 0.61,-0.23 5.56,5.25 11,12.18 5.44,6.93 12.82,15.72 16.4,19.53 3.58,3.81 6.46,7.55 6.42,8.3 -0.27,4.39 -0.5,5.38 -1.29,5.38 -0.48,0 -4.15,-2.94 -8.15,-6.52zM309.5,269.6c-7.23,-3.3 -7.89,-4.43 -7.22,-12.38 0.64,-7.54 4.64,-19.61 7.4,-22.31 2,-1.96 11.74,-4.08 13.01,-2.84 0.44,0.43 1.47,0.93 2.28,1.1 1.64,0.36 4.07,3.08 5.98,6.7 0.74,1.4 1.21,5.66 1.14,10.31 -0.13,9.18 -1.99,13.75 -7.57,18.65 -4.35,3.82 -7.92,4 -15.02,0.76zM318.98,265.51c2.76,-1.43 4.84,-5.02 3.62,-6.25 -0.32,-0.32 -1.41,0.16 -2.42,1.08 -4.05,3.67 -10.24,1.21 -10.39,-4.12C309.72,253.79 309.24,253 307.85,253 306.32,253 306,253.79 306,257.55c0,7.89 6,11.57 12.98,7.97zM217.41,262.06c-1.7,-0.41 -3.62,-1.07 -4.26,-1.47 -2.09,-1.29 -5.07,-8.25 -5.96,-13.95 -0.73,-4.66 -0.54,-6.32 1.18,-10.35 2.42,-5.67 3.39,-6.58 10.63,-10.06 7.74,-3.72 13.49,-3.42 17.39,0.91l2.89,3.2 -0.64,11.57c-0.92,16.57 -1.46,19.05 -4.35,20.15 -2.8,1.07 -12.38,1.05 -16.88,-0.02zM227.25,256.02c2.85,-2.25 4.16,-6.14 3.13,-9.35 -0.63,-1.96 -0.81,-1.83 -1.88,1.34 -0.65,1.94 -2.37,4.17 -3.85,5 -2.37,1.34 -2.96,1.33 -5.42,-0.03 -1.51,-0.84 -3.2,-2.88 -3.75,-4.52 -0.69,-2.06 -1.55,-2.88 -2.75,-2.64 -2.95,0.58 -2.11,5.03 1.65,8.8 4.02,4.02 8.86,4.55 12.85,1.42zM342.5,258c0.34,-0.55 0.82,-1 1.06,-1 0.24,0 0.44,0.45 0.44,1 0,0.55 -0.48,1 -1.06,1 -0.58,0 -0.78,-0.45 -0.44,-1zM315.81,193.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6zM320.81,192.32c0.72,-0.29 1.58,-0.25 1.92,0.08 0.33,0.33 -0.26,0.57 -1.31,0.53 -1.16,-0.05 -1.4,-0.28 -0.6,-0.6z" /> + android:fillColor="#161616" + android:pathData="m209.58,486.75c-2.82,-2.7 -4.32,-6.02 -8.9,-19.75 -3.03,-9.07 -7.78,-22.93 -10.57,-30.79 -2.78,-7.86 -4.62,-14.02 -4.09,-13.69 0.54,0.33 0.97,1.08 0.97,1.67 0,0.59 1.11,3.6 2.46,6.69 5.57,12.73 14.88,34.98 16.79,40.13 3.64,9.8 4.25,10.06 6.57,2.82 11.98,-37.38 19.74,-59.92 20.01,-58.06 0.18,1.24 -0.16,8.44 -0.74,16 -1.51,19.58 -6.6,39.88 -13.76,54.99 -1.98,4.16 -4.39,4.17 -8.74,0zM281.8,477.79C278.77,474.55 270.22,457.54 266.06,446.5c-1.24,-3.3 -3.15,-8.2 -4.24,-10.88 -1.72,-4.24 -1.89,-6.13 -1.25,-14.5l0.73,-9.62 2.94,6.5c1.62,3.58 6.45,15.27 10.75,26 4.29,10.73 8.81,21.75 10.04,24.5l2.23,5 1.28,-3c0.71,-1.65 2.95,-6.6 4.99,-10.99 2.04,-4.4 4.52,-10.02 5.53,-12.5 3.37,-8.31 8.72,-20.11 9.43,-20.81 0.98,-0.97 -0.22,5.16 -4.02,20.47 -3.61,14.55 -9.62,27.61 -14.65,31.84 -3.8,3.2 -4.4,3.14 -8,-0.72zM280.08,314.6c0.23,-0.22 2.1,-0.86 4.17,-1.42 3.05,-0.83 3.75,-0.76 3.75,0.4 0,1 -1.23,1.42 -4.17,1.42 -2.29,0 -3.98,-0.18 -3.75,-0.4zM252.5,310.5c-0.55,-0.19 -2.64,-0.61 -4.65,-0.95 -3.02,-0.5 -3.56,-0.95 -3.13,-2.58 0.28,-1.09 0.7,-1.97 0.93,-1.97 0.23,0 2.77,1.35 5.63,2.99 4.38,2.51 5,3.8 1.21,2.51zM339.02,255.43c-0.05,-13.2 -8.28,-22.66 -21.62,-24.84 -3.55,-0.58 -6.84,-0.04 -11.65,1.91 -3.22,1.31 -1.93,-2.36 1.44,-4.1 2.35,-1.22 4.81,-1.52 9.39,-1.15 14.02,1.13 24.73,11.58 26.17,25.53 0.45,4.31 0.29,5.36 -0.7,4.74 -0.74,-0.46 -1.63,-0.21 -2.14,0.59 -0.58,0.92 -0.88,0.02 -0.89,-2.69zM198.53,242.04c-1.16,-1.88 2.28,-7.7 7.02,-11.87 7.71,-6.78 16.43,-10.45 26.09,-10.97 5.87,-0.32 9.52,0.02 13.11,1.21 4.44,1.47 4.92,1.92 4.5,4.12 -0.26,1.36 -0.55,2.47 -0.64,2.47 -0.09,0 -1.94,-0.9 -4.09,-2 -5.36,-2.73 -15.55,-2.7 -22.96,0.07 -7.42,2.77 -15.38,9.2 -17.41,14.04 -1.55,3.7 -4.25,5.11 -5.6,2.93zM344.33,204.17c-1.74,-1.47 -4.22,-3.48 -5.5,-4.48 -2.25,-1.75 -2.22,-1.75 0.93,-0.03 4.33,2.36 8.86,6.03 8.23,6.66 -0.28,0.28 -1.93,-0.69 -3.67,-2.16zM320.52,193.89c-8.62,-1.25 -9.18,-1.97 -1.02,-1.29 3.85,0.32 7.43,0.99 7.95,1.5 0.6,0.58 0.6,0.87 0,0.79 -0.52,-0.07 -3.64,-0.51 -6.93,-0.99zM233.76,182.29c1.24,-0.24 3.04,-0.23 4,0.02 0.96,0.25 -0.06,0.45 -2.26,0.43 -2.2,-0.01 -2.98,-0.22 -1.74,-0.46z" /> diff --git a/app/src/main/res/drawable/ic_empty_history.xml b/app/src/main/res/drawable/ic_empty_history.xml index da5592f54..f0c6fdee1 100644 --- a/app/src/main/res/drawable/ic_empty_history.xml +++ b/app/src/main/res/drawable/ic_empty_history.xml @@ -1,44 +1,31 @@ - - - - - - + android:width="192dp" + android:height="192dp" + android:viewportWidth="512" + android:viewportHeight="512"> + android:fillColor="#efefef" + android:pathData="M49.12,507.76C51.6,502.9 54.68,491.06 55.55,483c0.66,-6.03 1.23,-10.16 1.95,-14 0.26,-1.38 0.71,-3.97 1,-5.77 2.68,-16.38 15.14,-37.09 25.2,-41.86 7.19,-3.42 7.96,-3.52 24.3,-3.41l14.5,0.1 6.98,1.78 6.98,1.78 0.73,4.25c1.19,6.95 1.37,30.86 0.45,59.38l-0.86,26.75L91.87,512 46.96,512l2.17,-4.24zM162.44,505.25c1.48,-14.05 2.5,-37.5 2.53,-58.29L165,425.42l6.36,-4.18c10.36,-6.81 16.21,-16.58 17.31,-28.9 0.71,-7.96 2.07,-11.11 6.37,-14.73 5.53,-4.66 6.96,-3.33 6.96,6.49v5.95l5.25,4.88c9.06,8.43 13.01,11.23 21.5,15.25l8.25,3.91v1.92c0,1.06 2.03,4.2 4.5,6.99 2.47,2.79 4.5,5.76 4.5,6.62 0,2.48 2.47,0.97 2.77,-1.7 1.14,-10.23 2.98,-12.79 14.67,-20.42C275.1,399.89 277,396.92 277,386.29c0,-2.98 0.47,-6.3 1.05,-7.38l1.05,-1.96 3.89,2.78C287.78,383.14 289,385.65 289,392.08 289,409.77 305.94,433 318.84,433h2.45l-0.62,6.25c-0.34,3.44 -1.35,13 -2.24,21.25l-1.62,15 0.35,18.27 0.35,18.27 -20.31,-0.27L276.89,511.5 265.59,485.25C259.37,470.81 254.67,459 255.14,459c1.59,0 4.8,3.93 5.48,6.72 0.37,1.53 2,5.14 3.62,8.03l2.94,5.25h1.77c0.97,0 2.95,-1.19 4.4,-2.64L276,473.73v-4.23c0,-2.41 -1.29,-7.35 -3,-11.49 -4.19,-10.15 -5.13,-14.1 -6.02,-25.28C266.12,421.76 265.2,420 260.41,420h-3.34l-0.92,1.75C255.03,423.88 253,438.68 253,444.68v4.37l-2.75,1.08c-1.51,0.59 -4.35,1.3 -6.32,1.58l-3.57,0.51 -5.18,-2.23c-5.89,-2.53 -5.66,-2.03 -3.59,-7.98l1.57,-4.5 -1.21,-5.69c-0.72,-3.4 -0.93,-7.92 -0.51,-11.25L232.14,415h-2.14c-5.38,0 -7.71,3.29 -10.49,14.82l-1.76,7.32 -3.79,2.39c-4.96,3.13 -5.45,4.08 -3.55,6.97l1.58,2.41 -0.92,2.43c-0.51,1.33 -2.98,5.73 -5.49,9.77 -5.66,9.1 -5.66,10.19 -0.05,15.57 6.57,6.29 6.64,6.25 13.69,-7.7l5.29,-10.46 1.75,-0.33c3.72,-0.7 2.49,3.03 -6.7,20.37 -5.23,9.87 -10.92,21.43 -12.64,25.7l-3.14,7.75h-21.03,-21.03l0.71,-6.75zM350.27,484.75 L350.77,457.5 352.43,448.01c1.85,-10.56 3.54,-14.02 6.85,-13.99 3.58,0.02 4.16,1.01 6.04,10.37 1.86,9.26 4.5,15.42 10.48,24.46l3.53,5.35 -1.28,5 -1.28,5 1.87,2.31c14.05,17.39 17.57,20.98 23.08,23.55L405.5,511.84 377.64,511.92 349.77,512ZM432.84,509.25c1.48,-1.51 3.35,-3.79 4.17,-5.07L438.5,501.87 438.15,506.93 437.81,512h-3.83,-3.83z" /> + + + + android:fillColor="#a8a8a8" + android:pathData="m426.81,499.75c-0.19,-6.74 -0.27,-12.4 -0.17,-12.58 0.45,-0.81 8.64,7.48 10,10.11 2.34,4.53 -3.27,14.72 -8.11,14.72h-1.37zM380.83,489.25c-3.4,-3.68 -3.9,-5.44 -2.73,-9.68 1.81,-6.54 5.31,-2.77 6.2,6.68 0.62,6.49 0.13,6.91 -3.48,3zM206.65,477.86c-2.09,-1.72 -4.23,-4.25 -4.75,-5.61l-0.94,-2.48 1.39,-2.64c0.77,-1.45 3.38,-6.06 5.81,-10.25l4.42,-7.61 7.24,4.24 7.24,4.24 -1.49,0.57c-0.82,0.32 -3.58,4.76 -6.14,9.87 -7.02,14.05 -7.24,14.22 -12.77,9.66zM264.59,474.38c-1.42,-2.54 -3.12,-6.43 -3.78,-8.64l-1.2,-4.02 -3.05,-1.86c-1.68,-1.02 -2.51,-1.86 -1.84,-1.86 0.67,-0 3.63,-0.91 6.59,-2.01l5.37,-2.01 1.34,-2.5 1.34,-2.5 3.33,8.61c1.83,4.73 3.33,10.1 3.33,11.93v3.33l-2.59,3.08C271.99,477.62 270,479 269,479h-1.83zM253.69,445.92c-0.68,-2.59 1.06,-20.13 2.33,-23.46L256.95,420l2.77,0.02c1.53,0.01 3.4,0.41 4.17,0.9 2.27,1.44 4.34,19.09 2.24,19.09 -0.48,0 -3.13,1.8 -5.89,4 -5.84,4.65 -5.83,4.65 -6.55,1.92zM223.5,442.78c-6.29,-5.03 -6.1,-4.22 -3.38,-14.55l2.3,-8.73 2.24,-2.25c1.23,-1.24 3.27,-2.25 4.54,-2.25l2.3,0v14.46,14.46l-1.5,1.43 -1.5,1.43z" /> + + + android:fillColor="#1c1c1c" + android:pathData="M230.44,330.11C224.89,328.11 217,323.25 217,321.84c0,-1.94 3.21,-1.99 5.3,-0.09 1.21,1.1 4.79,3.7 7.95,5.77 7.71,5.05 7.73,5.3 0.19,2.59zM253,330.73c2.47,-0.72 7.16,-2.75 10.42,-4.51 6.06,-3.28 7.26,-3.44 6.25,-0.81 -1,2.61 -8.72,5.79 -15.1,6.21L248.5,332.03ZM326.67,267.49c-4.85,-2.97 -11.28,-6.32 -14.29,-7.44L306.92,258h-6.03,-6.03l-4.92,2.11C284.53,262.45 282,262.11 282,259.05v-2.01l5.19,-2.96c2.9,-1.65 8.22,-3.5 12.05,-4.19l6.86,-1.23 6.7,1.05c8.47,1.33 16.79,5.26 21.66,10.25l3.79,3.88 -0.69,4.58c-0.84,5.58 -0.16,5.63 -10.89,-0.93zM214,253.41c-1.92,-1.37 -6.65,-3.46 -10.5,-4.65l-7,-2.17 -7.5,0.03c-8.83,0.04 -14.32,1.43 -19.48,4.93 -2.06,1.4 -3.82,2.41 -3.91,2.25 -0.08,-0.16 -0.72,-1.87 -1.4,-3.8l-1.25,-3.5 2.06,-1.69c1.13,-0.93 4.63,-2.67 7.77,-3.87l5.71,-2.18 9.84,-0.51 9.84,-0.51 5.66,1.26c11.26,2.5 16.59,6.88 15.98,13.12 -0.41,4.14 -1.49,4.38 -5.82,1.31zM325.39,197.86c-5.74,-4.63 -15.43,-8.88 -22.54,-9.89l-6.27,-0.89 -7.04,2.36c-6.8,2.28 -8.92,2.47 -6.97,0.62 2.12,-2.02 11.39,-4.23 17.98,-4.3l7.05,-0.07 6.45,3.13c3.55,1.72 8.7,5.15 11.45,7.61 5.74,5.14 5.67,6.08 -0.11,1.42zM171,184c14,-12.59 33.55,-14.13 47.4,-3.73 3.94,2.96 -0.21,2.47 -4.89,-0.57l-4.88,-3.18 -7.53,-0.38c-4.14,-0.21 -10.57,0.16 -14.28,0.83l-6.75,1.21 -6.78,4.94 -6.78,4.94z" /> diff --git a/app/src/main/res/drawable/ic_empty_local.xml b/app/src/main/res/drawable/ic_empty_local.xml index 2b40767fe..f578a6ce8 100644 --- a/app/src/main/res/drawable/ic_empty_local.xml +++ b/app/src/main/res/drawable/ic_empty_local.xml @@ -1,53 +1,71 @@ + android:width="192dp" + android:height="192dp" + android:viewportWidth="682.67" + android:viewportHeight="682.67"> + android:fillColor="#ededed" + android:pathData="m462.38,677c-0.95,-9.58 11.4,-56.66 18.63,-71 8.03,-15.92 33.35,-48.08 35.88,-45.56 0.5,0.5 33.92,111.28 35.96,119.22l0.77,3L508.28,682.67 462.95,682.67ZM156,615.11c-14.7,-2.58 -20.07,-6.87 -22.71,-18.12 -0.9,-3.84 -2.23,-7.73 -2.96,-8.65 -1,-1.26 -0.24,-1.67 3.17,-1.68 18.87,-0.07 27.32,-9.45 20.46,-22.72 -1.94,-3.75 -4.63,-5.32 -4.63,-2.7 0,3.72 -7.11,9.21 -14.7,11.34 -4.46,1.25 -8.46,3.17 -8.88,4.26 -0.81,2.12 -7.17,-7.05 -12.51,-18.06 -6.56,-13.54 7.02,-42.16 18,-37.95 3.42,1.31 2.47,-2.82 -2.39,-10.46 -8.16,-12.83 -28.7,-50.96 -29.64,-55.03 -0.51,-2.2 -1.51,-4.75 -2.24,-5.67 -0.97,-1.24 -0.48,-1.67 1.92,-1.67 4.61,0 15.79,12.85 25.74,29.6 0.74,1.25 3.84,6.35 6.89,11.35 3.04,4.99 7.48,12.67 9.86,17.06 7.67,14.18 10.37,14.67 7.19,1.32 -8.92,-37.42 -13.67,-67.33 -10.68,-67.33 4.83,0 10.19,12.2 21.09,48 4.66,15.32 11.84,29.67 21.54,43.1l9.47,13.1 0.83,-18.53c1.61,-35.75 13.16,-48.09 15.99,-17.09 6.58,71.99 -13.06,113.17 -50.83,106.53zM444.67,484.14c-11,-3.61 -21.62,-6.93 -23.61,-7.37 -11.74,-2.6 -2.05,-51.46 10.6,-53.43 2.76,-0.43 7.36,-1.41 10.23,-2.18 8.95,-2.4 9.82,-2.37 12.33,0.41 4.22,4.66 9.57,6.7 15.11,5.77 5.94,-1 13.1,2.14 22.44,9.84l5.85,4.83 -0.73,10.67c-2.41,35.09 -16.1,43.34 -52.22,31.48zM120.32,385.93c-4.48,-6.83 -9.91,-9.22 -14.98,-6.6 -2.2,1.14 -5.44,2.61 -7.21,3.29l-3.22,1.22 3.97,-11.85c3.76,-11.21 3.86,-12.09 1.93,-16.25 -13.15,-28.36 -15.66,-51.74 -5.55,-51.74 1.81,0 11.34,9.83 14.53,15 4.19,6.77 8.76,5.11 14.21,-5.15 13.53,-25.51 21.63,-25.57 21.27,-0.15 -0.14,10.24 0.51,16.12 2.6,23.61 1.54,5.49 2.79,12.41 2.79,15.4 0,2.98 1.77,11.08 3.94,17.98 4.57,14.57 4.55,14.65 -2.14,10.1 -6.92,-4.7 -9.15,-4.36 -13.37,2 -4.44,6.7 -4.5,6.57 -7.8,-15.93 -2.19,-14.94 -4.07,-20.2 -7.19,-20.2 -2.85,0 -2.48,36.4 0.42,41.09 2.35,3.8 -1.65,2.06 -4.2,-1.83zM426.03,382.7c-1.31,-1.45 -3,-2.26 -3.75,-1.79 -0.78,0.48 -0.95,0.18 -0.41,-0.7 0.57,-0.92 0.09,-1.54 -1.21,-1.54 -1.19,0 -1.79,-0.6 -1.33,-1.33 0.45,-0.73 -0.01,-1.33 -1.04,-1.33 -2.93,0 -19.63,-18.04 -19.63,-21.2 0,-2.86 1.64,-3.13 19.33,-3.13 1.83,0 4.11,0.07 5.05,0.17 1.12,0.11 2.31,4.25 3.39,11.83 0.92,6.42 2,13.92 2.4,16.67 0.83,5.73 0.49,6.01 -2.81,2.36zM323.98,346.33c-9.34,-7.29 -11.16,-11.36 -7.58,-17.02l2.93,-4.64 0.74,6c1.4,11.4 6.13,17.3 15.05,18.75l5.55,0.91 -5.56,0.17c-4.4,0.13 -6.73,-0.74 -11.12,-4.17zM345.12,345.75c3.16,-5.92 0.24,-24.53 -4.45,-28.42 -4.28,-3.55 0.4,-5.66 5.95,-2.69 4.94,2.64 7.68,23.44 3.72,28.17 -3.61,4.3 -6.94,6.18 -5.22,2.94zM107.7,286.93c-3.42,-7.84 -6.94,-18.02 -7.81,-22.63 -1.94,-10.27 1.26,-8.93 -27.89,-11.69 -13.57,-1.28 -25.12,-2.73 -25.66,-3.21 -1.05,-0.92 12.85,-12.51 21.74,-18.12 6.08,-3.84 5.85,-2.01 3.19,-26.16 -1.14,-10.32 -1.87,-18.96 -1.63,-19.2 0.24,-0.24 3.62,5.21 7.51,12.13 11.47,20.39 20.28,33.81 31.11,47.41l10.19,12.79 -1.84,21.05c-1.01,11.58 -2.03,21.24 -2.26,21.47 -0.23,0.23 -3.22,-6 -6.65,-13.84zM366,198.69c-13.11,-2.37 -20.67,-4.84 -20.67,-6.76 0,-0.53 1.22,-6.89 2.71,-14.12 4.86,-23.54 7.95,-44.94 7.95,-55.04 0,-5.44 0.23,-9.64 0.51,-9.33 0.38,0.42 1.7,10.12 2.13,15.64 0.04,0.52 6.95,-0.66 14.03,-2.39 2.93,-0.71 6.16,-1.31 7.17,-1.33 1.01,-0.02 -4.24,6.28 -11.67,13.99 -15.62,16.22 -16.29,18.25 -8.84,26.95 2.57,3 4.67,5.82 4.67,6.27 0,0.45 1.41,3.05 3.13,5.79 3.82,6.06 13.17,23.02 12.61,22.86 -0.22,-0.06 -6.41,-1.2 -13.74,-2.53z" + android:strokeWidth="1.33333" /> + android:fillColor="#d7d7d7" + android:pathData="m366.82,652.33c0.09,-16.68 0.37,-29.43 0.64,-28.33 0.7,2.88 5.87,51.79 5.87,55.53 0,2.46 -0.72,3.14 -3.33,3.14h-3.33zM286,451.86c-22.24,-3.2 -46.33,-12.87 -57.7,-23.14 -11.14,-10.07 -11.32,-11.41 -1.25,-9.25 9.93,2.13 18.29,2.38 18.29,0.54 0,-0.73 -0.55,-1.33 -1.22,-1.33 -4.8,0 -29.91,-27.74 -35.49,-39.21 -7.92,-16.29 -8.12,-17.92 -3.99,-32.12 5.82,-19.96 5.79,-32.65 -0.12,-62.44 -2.68,-13.5 -2.83,-13.51 9.26,0.7l10.34,12.14 -3.56,3.72c-7.47,7.79 -1.42,13.63 13.56,13.09 7.21,-0.26 9.2,0.27 16.76,4.5 31.78,17.77 42.62,16.6 47.91,-5.18 0.87,-3.58 2.66,-7.71 3.97,-9.18 1.31,-1.47 3.43,-6.42 4.7,-11 1.27,-4.58 3.06,-8.33 3.97,-8.33 2.44,0 8.57,-7.77 8.57,-10.87 0,-2.36 0.37,-2.23 3.18,1.1 1.75,2.07 9.86,9.75 18.04,17.06 20.77,18.58 26.54,43.03 19.59,83.04 -2.83,16.31 -2.85,16.21 2.53,11 5.77,-5.59 5.79,-4.92 0.22,7.14 -8.03,17.39 -26.79,42.18 -40.94,54.11l-6.42,5.42 -11.09,-0.16c-6.1,-0.09 -14.69,-0.68 -19.09,-1.32zM315.51,435.34c6.25,-4.1 8.97,-12.9 7.97,-25.85 -0.95,-12.33 -1.25,-13.22 -3.78,-11.16 -0.93,0.76 -4.78,1.79 -8.54,2.29 -5.37,0.72 -7.79,1.9 -11.26,5.48 -3.86,3.98 -5.26,4.56 -11,4.56h-6.58l0.9,5.63c2.8,17.54 19.53,27.4 32.28,19.05zM223.98,367.15c6,-2.73 23.77,-3.96 29.29,-2.04 4.01,1.4 4.18,1.32 3.33,-1.49 -1.35,-4.46 -2.19,-4.96 -10.03,-5.86 -3.98,-0.46 -7.63,-1.44 -8.11,-2.18 -0.54,-0.83 -1.72,-0.5 -3.1,0.87 -1.22,1.22 -3.22,2.22 -4.45,2.23 -4.45,0.03 -16.28,6.54 -17.44,9.6 -1.62,4.29 -0.38,5.57 2.99,3.09 1.58,-1.16 4.96,-3.07 7.52,-4.23zM340,349.11c0,-0.86 0.39,-1.16 0.87,-0.69 0.48,0.48 1.72,-0.17 2.77,-1.44 1.04,-1.27 1.44,-1.44 0.89,-0.38 -0.63,1.21 0.44,0.9 2.9,-0.85 2.15,-1.53 4.51,-2.83 5.24,-2.88 0.73,-0.05 1.79,-2.97 2.34,-6.48 2.81,-17.79 -4.73,-30.41 -17.65,-29.51 -5.95,0.41 -6.7,0.17 -6.71,-2.2 -0.05,-6.19 -2.39,-6.5 -4.12,-0.55 -1.05,3.59 -4.48,9.01 -8.78,13.89 -7.19,8.15 -9.09,14 -5.28,16.27 0.98,0.59 2.91,2.8 4.28,4.93 5.47,8.48 23.26,16.06 23.26,9.92zM409.63,443.64 L405.33,439.21 405.31,419.94c-0.01,-11.96 -1.02,-26.34 -2.67,-37.9 -1.46,-10.25 -2.65,-19.53 -2.65,-20.62 0,-1.12 3.39,1.36 7.74,5.66 4.26,4.21 8.25,7.19 8.88,6.62 0.63,-0.57 0.71,-0.29 0.18,0.63 -0.6,1.04 -0.15,1.67 1.2,1.67 1.19,0 1.79,0.6 1.33,1.33 -0.45,0.73 -0.01,1.33 0.99,1.33 3.65,0 9.85,8.43 10.23,13.91 0.2,2.88 0.59,6.42 0.87,7.87 0.28,1.45 0.9,6.95 1.38,12.24l0.87,9.61 -4.21,1.47c-4.47,1.56 -7.08,6.11 -10.07,17.57 -1.94,7.45 -4.24,8 -9.75,2.31z" + android:strokeWidth="1.33333" /> + android:fillColor="#afafaf" + android:pathData="m135.96,681c-0.02,-0.92 -0.34,-6.17 -0.71,-11.67l-0.68,-10 3.95,7.33c2.17,4.03 5.76,9.28 7.97,11.67l4.03,4.33h-7.26c-4.9,0 -7.27,-0.54 -7.3,-1.67zM224.73,677.86c-0.73,-3.89 -0.26,-5.73 2.5,-9.71 4.81,-6.94 7.84,-16.63 7.03,-22.51 -8.37,-61.15 -12.36,-73.12 -24.62,-74.01 -3.46,-0.25 -3.85,-0.64 -2.74,-2.73 1.88,-3.52 2.81,-34.06 1.49,-49.05l-1.13,-12.82 4.03,-1.57 4.03,-1.57 -4.33,-1.87c-2.78,-1.2 -4.33,-2.81 -4.33,-4.5 0,-7.77 -4.25,-8.99 -9.42,-2.7l-3.42,4.16 -4.31,-4.57c-2.37,-2.52 -5.22,-6.32 -6.33,-8.46 -1.11,-2.13 -3.8,-6.29 -5.98,-9.24 -8.2,-11.09 -16.77,-22.9 -17.63,-24.3 -1.06,-1.72 1.91,-4.9 20.34,-21.72 8.3,-7.57 14.1,-11.98 14.44,-10.96 4.04,12.11 24.92,48.52 39.86,69.49l10.35,14.53 9.19,0.52 9.19,0.52 6.77,7.28c16,17.2 38.49,39.58 43.49,43.28 7.57,5.6 5.8,8.08 -4.56,6.4 -9.39,-1.52 -16.91,12.64 -10.02,18.88 4.51,4.08 13.8,4.3 16.87,0.4 1.53,-1.94 4.67,-3.67 7.7,-4.24l5.13,-0.96 -0.57,4.42c-0.31,2.43 -3.83,26.47 -7.82,53.42l-7.25,49 -43.52,0 -43.52,0 -0.9,-4.8zM283.33,540.48c-10.27,-5.57 -24.95,-13.63 -32.63,-17.9 -16.57,-9.22 -16.28,-9.3 -9.95,2.74 8.16,15.53 9.53,16.52 26.58,19.19 8.07,1.26 18.27,3.1 22.67,4.09 13.2,2.96 12.76,2.44 -6.67,-8.11zM364,681.33c0,-7.26 -9.96,-85.3 -12.76,-100 -1.05,-5.5 -1.91,-10.09 -1.91,-10.2 -0,-0.34 8.33,-3.13 9.36,-3.13 1.18,0 1.93,4.15 5.69,31.33 3.16,22.82 3.81,83.33 0.9,83.33 -0.71,0 -1.29,-0.6 -1.29,-1.34zM445.58,673.67c-6.74,-11.66 -60.24,-111.93 -60.24,-112.92 0,-0.41 2.4,-0.75 5.33,-0.75 11.75,0 14.79,-13.58 4.84,-21.64 -3.53,-2.86 -7.8,-2.97 -12.91,-0.33 -5.05,2.61 -5.07,4.95 0.27,-40.7 5.03,-43.05 2.99,-36.97 13.3,-39.74l8.49,-2.29 0.67,-7.78 0.67,-7.78 3.19,3.41c1.76,1.88 3.8,3.04 4.54,2.58 0.76,-0.47 0.93,-0.17 0.39,0.71 -0.54,0.88 -0.17,1.54 0.87,1.54 2.58,0 1.88,6.38 -1.4,12.82 -1.49,2.92 -3.15,7.67 -3.69,10.56 -0.58,3.12 -3.52,8.56 -7.21,13.38 -5.85,7.63 -24.11,48.93 -22.4,50.64 0.6,0.6 17.15,-18.13 25.99,-29.4 2.69,-3.43 4.51,-7.59 5.1,-11.67 1.41,-9.76 4.53,-8.16 5.36,2.76 0.26,3.43 2,12.16 3.86,19.4 2.38,9.25 3.33,16.24 3.19,23.52 -0.29,15.19 6.64,23.91 23.64,29.77 4.96,1.71 5.25,2.12 5.48,7.68 0.14,3.23 0.92,26.57 1.74,51.87 0.82,25.3 1.81,47.65 2.19,49.67 1.54,8.05 -5.43,4.75 -11.27,-5.33zM553.96,681c-0.34,-0.92 -8.66,-28.92 -18.49,-62.23l-17.87,-60.56 3.48,-5.44c1.91,-2.99 3.64,-5.68 3.84,-5.96 0.2,-0.29 1.39,0.31 2.64,1.33 1.96,1.59 2.13,1.59 1.16,-0.02 -0.77,-1.27 -0.63,-1.57 0.41,-0.92 0.85,0.52 1.54,1.76 1.54,2.76 0,0.99 5.77,13.3 12.83,27.35 15.67,31.2 14.88,27.74 15.97,70.35l0.9,35h-2.89c-1.59,0 -3.17,-0.75 -3.51,-1.67zM338.17,541.74c-1.9,-4.99 -6.76,-17.94 -10.81,-28.78 -15.23,-40.76 -18.34,-45.25 -29.06,-41.95 -11.17,3.44 -19.73,4.32 -21.07,2.16 -0.76,-1.24 -0.39,-2.14 1.15,-2.75 1.34,-0.53 -0.29,-0.95 -3.92,-1l-6.2,-0.09 -0.96,-8.33c-0.53,-4.58 -1.44,-9.59 -2.02,-11.12l-1.06,-2.79 10.22,2.49c5.62,1.37 17.25,2.86 25.84,3.32l15.62,0.84 8.55,-7.51 8.55,-7.51 0.83,4.14c1.52,7.62 1.06,8.85 -3.93,10.5 -2.62,0.86 -4.51,1.82 -4.21,2.12 0.3,0.3 2.99,-0.31 5.98,-1.37 5.2,-1.84 6.98,-1.21 6.98,2.45 0,0.19 -2.4,1.14 -5.33,2.1 -5.55,1.83 -5.74,2.23 -5.38,11.33 0.45,11.56 8.84,40.33 19.25,66 4.95,12.21 4.91,12.61 -1.38,13.93 -4.15,0.87 -4.22,0.79 -7.64,-8.19zM182.76,533.33c-3.25,-4.77 -6.43,-8.88 -7.07,-9.13 -0.64,-0.26 2.32,-2.12 6.57,-4.15 4.25,-2.03 7.75,-3.62 7.76,-3.53 0.02,0.08 -0.28,5.85 -0.67,12.82l-0.69,12.67zM109.29,300.42c-2.55,-4.14 -5.55,-9.07 -6.67,-10.97 -1.12,-1.9 -3.67,-5.99 -5.67,-9.09 -1.99,-3.1 -3.62,-6.22 -3.62,-6.92 0,-1.94 -1.57,-2.58 -11.54,-4.67 -10.66,-2.24 -37.8,-15.52 -37.8,-18.5 0,-0.6 0.49,-0.79 1.09,-0.42 0.6,0.37 10.35,1.6 21.67,2.74 11.32,1.13 23.07,2.39 26.12,2.8l5.54,0.74 0.95,8.27c0.93,8.1 12.74,37.74 15.89,39.88 0.94,0.64 0.95,1.38 0.03,2.31 -0.93,0.93 -2.84,-1.05 -5.99,-6.16zM426.77,295.35c-11.15,-5.92 -12.35,-7.36 -16.73,-20.02 -6.48,-18.74 -16.36,-35.57 -29.03,-49.43l-6.76,-7.4 6.54,-6.83c3.6,-3.76 7.25,-7.62 8.11,-8.59 3.42,-3.83 5.88,-0.11 7.66,11.57 1.32,8.7 3.25,13.36 7.7,18.56 8.64,10.09 13.05,22.32 8.97,24.84 -2.51,1.55 4.29,7.29 13.77,11.6 9.93,4.52 10.18,4.86 6.33,8.33 -3.5,3.16 -3.51,11.09 -0.02,17.29 1.45,2.59 2.5,4.68 2.33,4.66 -0.17,-0.03 -4.16,-2.1 -8.87,-4.6zM116.87,284.33c0.62,-7.88 1.13,-17.43 1.13,-21.22 0,-7.74 0.76,-7.64 6.68,0.9 2.81,4.05 3.32,5.71 2.17,7.09 -0.82,0.99 -1.1,2.2 -0.62,2.68 0.48,0.48 0.11,0.88 -0.82,0.88 -0.96,0 -1.37,0.87 -0.93,2 0.42,1.1 0.12,2 -0.67,2 -0.79,0 -1.09,0.9 -0.67,2 0.42,1.1 0.12,2 -0.67,2 -0.79,0 -1.09,0.9 -0.67,2 0.42,1.1 0.12,2 -0.67,2 -0.79,0 -1.09,0.9 -0.67,2 0.42,1.1 0.12,2 -0.67,2 -0.79,0 -1.09,0.9 -0.67,2 0.42,1.1 0.12,2 -0.67,2 -0.79,0 -1.09,0.9 -0.67,2 0.42,1.1 0.13,2 -0.65,2 -0.99,0 -1.08,-4.38 -0.3,-14.33zM356.95,162.79c-3.72,-6.3 -2.81,-8.11 11.81,-23.41 15.61,-16.34 16.08,-15.7 4.67,6.41 -11.77,22.8 -12.56,23.61 -16.47,17z" + android:strokeWidth="1.33333" /> + android:fillColor="#777777" + android:pathData="m302.67,583.09c-7.71,-2.96 -9.05,-11.58 -2.77,-17.85 6.7,-6.7 14.71,-4.18 17.62,5.53 2.7,9.02 -5.57,15.88 -14.85,12.32zM339.35,569.82c-5.5,-5.5 -4.34,-14.72 2.29,-18.15 12.59,-6.51 21.23,-2.69 21.57,9.53 0.18,6.68 -18.9,13.57 -23.86,8.61zM381,557c-7.86,-8.09 -2.53,-21.08 8.6,-20.97 8.55,0.08 15.06,13.58 9.93,20.6 -3.38,4.63 -14.19,4.84 -18.53,0.37zM432,496.14c-22.95,-7.47 -28.2,-17.13 -18.68,-34.39 2.42,-4.39 2.7,-4.58 2.04,-1.38 -3,14.56 4.02,25.66 20.56,32.5 14.59,6.03 11.77,8.38 -3.92,3.27zM158.43,430.32c-26.78,-28.97 -32.81,-37.6 -34.51,-49.38 -1.53,-10.58 -1.56,-29.06 -0.05,-31.45 2.44,-3.85 3.92,0.44 6.88,19.82 3.38,22.16 3.42,22.23 8.42,14.68 4.3,-6.49 6.99,-6.72 14.92,-1.27 3.25,2.24 5.91,3.81 5.91,3.5 0,-0.31 -1.85,-6.49 -4.11,-13.73 -2.26,-7.24 -4.34,-16.62 -4.62,-20.84 -0.28,-4.22 -1.51,-10.82 -2.74,-14.67 -1.54,-4.81 -2.28,-12.03 -2.38,-23.12 -0.24,-27.24 -8.2,-27.72 -22.14,-1.32 -3.63,6.87 -5.26,8.76 -7.33,8.48 -2.87,-0.39 -4.01,-10.11 -1.68,-14.36 0.6,-1.1 3.77,-10.06 7.05,-19.92 6.71,-20.2 6.87,-18.73 -3.32,-29.97 -9.71,-10.71 -18.29,-23.17 -34.05,-49.45l-16,-26.67 -0.43,6.67c-0.24,3.67 0.6,14.06 1.86,23.09 2.1,15.09 2.11,16.6 0.14,18.57 -1.8,1.8 -2.24,1.84 -2.75,0.24 -3.48,-10.84 -6.21,-20.5 -7.49,-26.57 -0.86,-4.03 -2.05,-9.43 -2.64,-12 -2,-8.58 -4.5,-26.63 -3.79,-27.33 0.38,-0.38 3.55,0.56 7.04,2.11 5.8,2.57 21.58,7.42 28.07,8.64 1.47,0.28 8.37,1.39 15.33,2.47 23.59,3.68 40.18,8.83 59.11,18.35 8.16,4.1 8.35,4.08 14.09,-1.34 13.08,-12.38 35.87,-22.27 68.14,-29.56l12.67,-2.86 4.64,-6.9c4.69,-6.98 10.19,-14 18.7,-23.89 6.25,-7.26 20.26,-18.32 30.04,-23.72 10.39,-5.73 14.36,-9.46 21.27,-19.96 9.53,-14.48 16.95,-18.36 18.61,-9.73 5.47,28.41 2.18,84.19 -6.72,114.01 -0.77,2.57 5.65,5.85 14.09,7.2 4.45,0.71 5.03,1.31 6.47,6.71 1.19,4.44 4.18,8.68 11.97,16.95 18.44,19.57 28.01,36.56 36.91,65.53 5.12,16.66 12.3,30.62 20.3,39.48l4.62,5.11 -9.45,-3.22c-5.2,-1.77 -9.45,-2.93 -9.45,-2.58 0,0.35 -1.2,-0.01 -2.67,-0.79 -2.36,-1.26 -2.68,-1 -2.77,2.29 -0.06,2.04 -0.32,4.85 -0.58,6.25 -0.26,1.39 -0.62,5.29 -0.8,8.67 -0.18,3.37 -0.57,6.86 -0.88,7.75 -0.61,1.77 -14.94,1.81 -14.96,0.05 -0.01,-0.55 -1.18,-1 -2.6,-1 -1.42,0 -2.92,-0.54 -3.34,-1.21 -2.98,-4.82 -14.35,5.89 -13.51,12.72 0.59,4.81 0.02,6.46 -4.49,12.89 -2.84,4.06 -4.92,7.73 -4.62,8.15 0.3,0.43 0.14,0.46 -0.36,0.08 -0.5,-0.38 -2.99,1.27 -5.53,3.67 -3.43,3.24 -4.37,3.67 -3.63,1.69 10.83,-29.21 3.94,-76.21 -13.39,-91.33 -5.46,-4.77 -13.34,-12.06 -17.5,-16.21l-7.57,-7.54 -2.29,5.12c-1.27,2.85 -4,6 -6.15,7.11 -3.06,1.58 -4.19,3.45 -5.48,9.05 -0.9,3.88 -2.73,8.27 -4.07,9.76 -1.34,1.49 -3.15,5.64 -4.02,9.22 -5.43,22.34 -16.03,23.81 -46.75,6.47 -7.89,-4.45 -9.65,-4.91 -18.89,-4.95 -14.97,-0.06 -19.88,-5.16 -12.04,-12.5l3.77,-3.53 -11.98,-13.94c-8.43,-9.81 -11.72,-12.88 -11.09,-10.37 8.08,32.18 7.84,64.19 -0.61,82.74 -4.53,9.94 12.96,37.58 36.8,58.16l6.67,5.75 -10.67,-0.92c-5.87,-0.5 -11.97,-1.03 -13.56,-1.17 -1.59,-0.14 -2.67,-0.47 -2.41,-0.73 0.26,-0.26 -0.1,-1.16 -0.79,-2 -0.7,-0.84 -2.54,-3.21 -4.09,-5.28 -2.49,-3.32 -3.4,-3.66 -7.85,-2.91 -3.05,0.51 -5.38,0.29 -5.91,-0.58 -1.73,-2.81 -3.49,-1.43 -1.92,1.51 1.34,2.51 1.15,3.21 -1.29,4.83 -1.57,1.04 -2.86,2.59 -2.86,3.45 0,1.42 -4.66,6.18 -19.38,19.79l-5.38,4.97 -10.15,-10.99zM298,433.58c-2.93,-1.37 -6.15,-3.48 -7.15,-4.69 -1,-1.21 -2.34,-1.87 -2.98,-1.48 -0.64,0.4 -0.77,-0.31 -0.29,-1.57 0.59,-1.54 0.37,-1.98 -0.67,-1.33 -1.01,0.62 -1.27,0.24 -0.75,-1.11 0.44,-1.14 0.28,-2.07 -0.35,-2.07 -0.63,-0 -1.27,-1.8 -1.42,-4 -0.32,-4.54 -0.2,-4.58 5.86,-2.39 4.32,1.56 4.62,1.43 10.81,-4.67 7.87,-7.75 18.42,-6.18 19,2.83 0.03,0.49 0.55,3.46 1.16,6.59 0.62,3.22 0.59,6.01 -0.08,6.43 -0.65,0.4 -0.84,1.29 -0.41,1.97 0.42,0.68 0.13,1.24 -0.64,1.24 -0.78,0 -1.41,1 -1.41,2.22 0,1.22 -0.39,1.83 -0.88,1.34 -0.48,-0.48 -1.56,-0.31 -2.4,0.39 -3.24,2.69 -11.94,2.84 -17.39,0.29z" + android:strokeWidth="1.33333" /> + android:fillColor="#5a5a5a" + android:pathData="m457.25,681.33c-0.07,-1.1 -1.13,-26.32 -2.35,-56.05l-2.23,-54.05 6.62,1.72c14.2,3.69 28.88,1.58 45.38,-6.5 4.95,-2.42 6.94,-3 5.24,-1.52 -23.22,20.19 -41.19,60.08 -49.16,109.1 -0.98,6.01 -3.27,10.82 -3.49,7.31zM147.18,620.22c-2.28,-1.44 -5.46,-4.46 -7.05,-6.7l-2.91,-4.08 6.39,2.94c18.12,8.33 47.05,-0.67 54.65,-17.01l1.87,-4.02 -0.63,6c-0.65,6.15 -10.27,17.33 -14.92,17.33 -1.01,0 -5.16,1.52 -9.23,3.39 -10.29,4.71 -22.64,5.66 -28.16,2.17zM126.93,583.73c-4.38,-4.38 -1.44,-7.83 9.31,-10.92 6.99,-2 11.71,-5.69 13.5,-10.55 1.01,-2.74 3.69,0.9 5.02,6.84 2.64,11.74 -19.22,23.23 -27.83,14.63zM277.33,545.94c-8.43,-1.67 -17.13,-3.1 -19.33,-3.19 -6.07,-0.23 -22,-19.73 -22,-26.93 0,-0.85 59.11,30.83 60.93,32.66 1.3,1.3 -2.93,0.76 -19.6,-2.54zM380.52,534.09c7.68,-21.64 16.57,-41.4 21.8,-48.47l6.15,-8.31 1.79,5.02c4.15,11.65 1.16,18.23 -18.67,41.02 -11.84,13.61 -12.19,13.95 -11.06,10.75zM124.52,511.34c-3.5,-4.76 -6.51,-9.65 -6.68,-10.87 -0.18,-1.21 -1.11,-2.7 -2.08,-3.32 -0.97,-0.61 -1.3,-1.12 -0.75,-1.13 0.56,-0.01 -2.59,-7.21 -7,-16 -13.42,-26.76 -10.91,-23.59 8.41,10.65 5.59,9.9 11.71,20.55 13.61,23.67 5.32,8.72 1.62,6.71 -5.51,-3zM146.09,512.62c-2.2,-3.36 -4.85,-13.56 -7.43,-28.62 -2.56,-14.95 -3,-18.11 -3.2,-22.92 -0.11,-2.71 -0.55,-5.27 -0.97,-5.7 -0.81,-0.81 0.08,-16.97 0.99,-17.93 0.29,-0.31 0.53,2.81 0.53,6.93 0,7.96 3.09,25.41 9.46,53.5 4.15,18.29 4.23,20.24 0.63,14.75zM456,499.33c-4.03,-0.98 -7.63,-1.73 -8,-1.67 -2.43,0.36 -22,-9.22 -26.33,-12.89 -10.92,-9.25 -4.69,-9.26 22.68,-0.03 33.86,11.41 49.59,3.77 52.62,-25.54 1.12,-10.87 3.04,-10.08 3.04,1.25 0,30.84 -16.6,45.51 -44,38.89zM321.6,421.09c-1.18,-16.01 -10.35,-20.86 -20.5,-10.86l-6.33,6.23 -5.39,-1.78c-7.01,-2.31 -6.82,-3.35 0.63,-3.35 5.25,0 6.6,-0.64 10.6,-5 3.87,-4.23 5.43,-5 10.07,-5 3.02,0 6.63,-0.61 8.04,-1.36 3.39,-1.82 4.22,1.57 3.61,14.71 -0.26,5.49 -0.59,8.37 -0.73,6.4z" + android:strokeWidth="1.33333" /> + android:fillColor="#333333" + android:pathData="m168.3,669.31c-10.12,-1.48 -21.12,-7.25 -25.01,-13.13 -9.96,-15.05 -17.41,-44.19 -14.8,-57.85 1.25,-6.52 2.59,-6.32 4.49,0.67 5.29,19.49 16.83,26.48 35.74,21.63 19.61,-5.04 29.41,-13.01 31.27,-25.43 4.9,-32.86 18.91,-30.93 25.76,3.56 8.76,44.09 6.83,50.86 -18.08,63.41 -14.63,7.37 -24.96,9.25 -39.37,7.15zM460.67,560.88c-17.4,-5.76 -36.62,-22.28 -36.69,-31.53 -0.01,-1.85 -1.5,-9.06 -3.31,-16.02 -1.8,-6.97 -3.29,-14.88 -3.31,-17.59l-0.03,-4.93 7.12,3.26c8.51,3.9 18.05,5.87 21.76,4.5 1.48,-0.55 6.23,-0.16 10.57,0.86 25.84,6.08 43.22,-8.5 43.22,-36.24 0,-8.3 0.67,-8.65 6.22,-3.22 3.09,3.02 4.25,5.58 4.86,10.66 1.27,10.68 4.86,24.12 8.51,31.81 13.66,28.77 -26.94,69.04 -58.93,58.44zM172.82,518.57c-1.33,-2.25 -2.21,-4.27 -1.95,-4.49 5.85,-5.07 22.7,-15.29 21.93,-13.31 -0.55,1.41 -1.32,5.32 -1.73,8.7 -0.7,5.87 -1.04,6.29 -7.93,9.67 -8.7,4.27 -7.43,4.33 -10.32,-0.56zM264.48,506c-0.58,-1.5 -3,-2 -9.76,-2h-8.99l-10.75,-15c-13.46,-18.77 -25.36,-38.71 -34.44,-57.67l-7.02,-14.67 2.74,-2.93c1.51,-1.61 2.44,-3.71 2.07,-4.67 -0.49,-1.28 1.09,-1.73 6.08,-1.73h6.74l4.39,7.52c7.88,13.48 21.59,23.87 39.68,30.08 9.32,3.2 10.1,3.75 10.1,7.16 0,2.03 0.41,4.77 0.92,6.08 0.81,2.1 0.3,2.02 -4.21,-0.63 -4.41,-2.6 -5.65,-2.8 -8.91,-1.45 -2.08,0.86 -5.29,1.28 -7.13,0.93 -1.84,-0.35 -3.34,-0.48 -3.34,-0.29 0,0.92 9.91,17.53 19.05,31.94 9.85,15.52 10.05,16.01 6.86,16.42 -1.81,0.23 -2.94,0.98 -2.52,1.67 0.42,0.68 0.41,1.24 -0.02,1.24 -0.43,0 -1.13,-0.9 -1.55,-2zM386.92,455c-1.54,-4.03 -4.21,-28.77 -4.35,-40.33 -0.16,-13 -1.81,-13.42 -4.54,-1.17 -1.37,6.14 -2.99,12.57 -3.6,14.28l-1.11,3.11 -4.09,-3.44c-5.25,-4.41 -5.45,-4.38 -8.59,1.47 -8.12,15.13 -11.41,17.32 -15.53,10.34l-2.8,-4.75 -3.82,3.66c-6.03,5.78 -4.99,0.07 1.22,-6.69 11.41,-12.41 26.36,-37.15 29.08,-48.12 0.54,-2.18 3.06,-6.86 5.6,-10.4 4.11,-5.72 4.53,-7.07 3.81,-12.3 -0.92,-6.75 0.75,-9.26 7.11,-10.66 2.21,-0.48 4.01,-1.39 4.01,-2.01 0,-0.62 1.72,-0.27 3.82,0.78 6.09,3.04 10.78,36.92 10.82,78.17l0.03,27.62 -6.91,2.05c-8.86,2.63 -8.53,2.68 -10.17,-1.62zM326.88,344.01c-6.07,-6.07 -8.16,-20.99 -3.71,-26.55 8.42,-10.53 19.69,-2.24 22.79,16.77 1.08,6.6 -1.38,13.13 -3.51,9.33 -3.35,-5.98 -11.72,-4.33 -9.74,1.91 1.18,3.7 -1.28,3.09 -5.83,-1.47z" + android:strokeWidth="1.33333" /> + android:fillColor="#0a0a0a" + android:pathData="m353.53,330.29c-0.71,-22.64 -21.79,-25.82 -35.68,-5.39 -4.41,6.49 -5.74,7.7 -6.26,5.71 -0.45,-1.71 1.8,-5.46 7.02,-11.66 4.43,-5.27 8.26,-11.36 9.06,-14.38l1.38,-5.23 0.14,4.78 0.14,4.78 7.3,-0.61c12.08,-1 19.85,9.75 17.68,24.46 -0.4,2.71 -0.64,1.96 -0.78,-2.45z" + android:strokeWidth="1.33333" /> + android:fillColor="#0a0a0a" + android:pathData="m213.33,370.85c0,-2.97 11.8,-10.84 16.28,-10.86 0.95,-0 3.37,-0.87 5.39,-1.92 2.02,-1.05 3.67,-1.35 3.67,-0.66 0,0.69 3.32,1.25 7.38,1.25 6.51,0 11.03,2.42 8.46,4.52 -1.42,1.16 -18.37,-0.98 -17.61,-2.22 0.55,-0.9 -0.28,-1.08 -2.35,-0.53 -1.76,0.47 -4.11,0.94 -5.21,1.03 -1.1,0.1 -0.2,0.63 2,1.19l4,1.02 -4.33,0.36c-2.38,0.2 -5.08,0.42 -6,0.49 -0.92,0.07 -1.97,0.46 -2.33,0.86 -2.17,2.38 -9.33,6.57 -9.33,5.46z" + android:strokeWidth="1.33333" /> + android:fillColor="?colorPrimary" + android:pathData="m457.6,571.97c-23.02,-6.03 -32.58,-14.58 -33.34,-29.82l-0.47,-9.48 3.43,4.91c26.73,38.3 87.45,27.04 95.19,-17.65l1.03,-5.93 1.6,6c9.25,34.78 -26.84,62.6 -67.43,51.97z" + android:strokeWidth="1.33333" /> + android:fillColor="?colorPrimary" + android:pathData="m374.67,680.77c0,-9.44 -9.93,-90.09 -12.78,-103.85 -2.29,-11.06 -2.3,-10.25 0.11,-10.25 2.63,0 2.67,-7.81 0.07,-14.04 -1.56,-3.73 -2.66,-4.62 -5.71,-4.62 -4.28,0 -3.86,0.86 -15.13,-30.96 -7.45,-21.03 -11.35,-35.59 -12.62,-47.13l-0.89,-8.1 6.07,-2.32c5.77,-2.2 7.03,-4.89 3.51,-7.49 -3.2,-2.36 -2.52,-10.27 1.19,-13.82l3.83,-3.67 2.8,4.75c4.12,6.98 7.41,4.79 15.53,-10.34 3.14,-5.85 3.34,-5.88 8.59,-1.47l4.09,3.44 1.11,-3.11c0.61,-1.71 2.23,-8.14 3.6,-14.28 2.77,-12.45 4.38,-11.81 4.56,1.84 0.06,4.4 1.01,16.1 2.12,26l2.02,18 -4.78,38.59c-2.63,21.23 -4.5,39.68 -4.15,41.01 0.35,1.33 -0.02,4.14 -0.81,6.24 -1.7,4.5 0.61,11.6 4.16,12.81 1.2,0.41 3.1,2.68 4.22,5.04 2.64,5.61 57.58,108.35 61.35,114.74l2.89,4.9h-37.47c-31.53,0 -37.47,-0.3 -37.47,-1.9z" + android:strokeWidth="1.33333" /> + android:fillColor="?colorPrimary" + android:pathData="m313.95,678.33c0.99,-5.78 15.38,-102.42 15.38,-103.28 0,-0.69 -4.7,0.28 -8.54,1.75 -1.63,0.63 -2.13,-0.07 -2.17,-2.99 -0.03,-2.1 -0.88,-5.31 -1.9,-7.14 -1.68,-3.02 -1.59,-3.47 0.91,-4.82 2.59,-1.4 2.33,-1.81 -4.44,-7.11 -16.17,-12.66 -48.89,-48.63 -44.93,-49.39l3.6,-0.68 -10.15,-16c-9.14,-14.41 -19.05,-31.02 -19.05,-31.94 0,-0.19 1.5,-0.07 3.34,0.29 1.84,0.35 5.05,-0.07 7.13,-0.93 5.04,-2.09 13.89,2.63 13.31,7.09 -0.54,4.2 1.24,6.16 5.63,6.16 2.16,0 3.85,0.45 3.76,1 -0.64,4.08 0.35,4.55 8.49,4.03 4.58,-0.29 11.16,-1.36 14.61,-2.37 10.14,-2.98 12.86,1.48 31.5,51.55l10.5,28.21 -3.3,1.51c-6.95,3.17 -1.03,19.28 6.79,18.5l4.24,-0.42 2.9,17.33c1.6,9.53 4.77,33.23 7.04,52.67 2.28,19.43 4.43,36.68 4.79,38.33l0.65,3H338.63,313.21Z" + android:strokeWidth="1.33333" /> + android:fillColor="?colorPrimary" + android:pathData="m146.58,677.69c-8.21,-9.35 -13.75,-23.51 -14.23,-36.36l-0.42,-11.33 4.42,11.39c7.61,19.63 14.84,25.5 34.63,28.15 22.05,2.95 53.63,-11.8 58.98,-27.54l1.81,-5.33 0.89,4c0.49,2.2 1.16,4.84 1.49,5.87 2.31,7.29 -9.43,26.59 -19.91,32.73 -5.74,3.37 -6.14,3.4 -34.55,3.4h-28.74z" + android:strokeWidth="1.33333" /> + + + + diff --git a/app/src/main/res/drawable/ic_empty_search.xml b/app/src/main/res/drawable/ic_empty_search.xml new file mode 100644 index 000000000..37fa1c487 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_search.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_empty_suggestions.xml b/app/src/main/res/drawable/ic_empty_suggestions.xml new file mode 100644 index 000000000..84025ea16 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_suggestions.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_error_placeholder.xml b/app/src/main/res/drawable/ic_error_placeholder.xml deleted file mode 100644 index 5900c0e9e..000000000 --- a/app/src/main/res/drawable/ic_error_placeholder.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_error_small.xml b/app/src/main/res/drawable/ic_error_small.xml deleted file mode 100644 index 964da43cc..000000000 --- a/app/src/main/res/drawable/ic_error_small.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml deleted file mode 100644 index e529f2e16..000000000 --- a/app/src/main/res/drawable/ic_expand.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_explore_checked.xml b/app/src/main/res/drawable/ic_explore_checked.xml deleted file mode 100644 index 3a5192021..000000000 --- a/app/src/main/res/drawable/ic_explore_checked.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore_normal.xml b/app/src/main/res/drawable/ic_explore_normal.xml deleted file mode 100644 index ed2e382ed..000000000 --- a/app/src/main/res/drawable/ic_explore_normal.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_explore_selector.xml b/app/src/main/res/drawable/ic_explore_selector.xml deleted file mode 100644 index 88c2b9c6f..000000000 --- a/app/src/main/res/drawable/ic_explore_selector.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eye_check.xml b/app/src/main/res/drawable/ic_eye_check.xml deleted file mode 100644 index 0e603e83b..000000000 --- a/app/src/main/res/drawable/ic_eye_check.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_favicon_fallback.xml b/app/src/main/res/drawable/ic_favicon_fallback.xml new file mode 100644 index 000000000..24996b554 --- /dev/null +++ b/app/src/main/res/drawable/ic_favicon_fallback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_favourites_selector.xml b/app/src/main/res/drawable/ic_favourites_selector.xml deleted file mode 100644 index 95cb4f86b..000000000 --- a/app/src/main/res/drawable/ic_favourites_selector.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_feed_selector.xml b/app/src/main/res/drawable/ic_feed_selector.xml deleted file mode 100644 index 537064084..000000000 --- a/app/src/main/res/drawable/ic_feed_selector.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_file_zip.xml b/app/src/main/res/drawable/ic_file_zip.xml deleted file mode 100644 index 817e9a11f..000000000 --- a/app/src/main/res/drawable/ic_file_zip.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_folder_file.xml b/app/src/main/res/drawable/ic_folder_file.xml deleted file mode 100644 index e36512cdc..000000000 --- a/app/src/main/res/drawable/ic_folder_file.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_eye_off.xml b/app/src/main/res/drawable/ic_hidden.xml similarity index 90% rename from app/src/main/res/drawable/ic_eye_off.xml rename to app/src/main/res/drawable/ic_hidden.xml index 8a53bd92c..82816e502 100644 --- a/app/src/main/res/drawable/ic_eye_off.xml +++ b/app/src/main/res/drawable/ic_hidden.xml @@ -3,10 +3,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_history_selector.xml b/app/src/main/res/drawable/ic_history_selector.xml deleted file mode 100644 index 9b91bb31c..000000000 --- a/app/src/main/res/drawable/ic_history_selector.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_incognito.xml b/app/src/main/res/drawable/ic_incognito.xml deleted file mode 100644 index 5c98eb428..000000000 --- a/app/src/main/res/drawable/ic_incognito.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..1048bdfff --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_list_create.xml similarity index 70% rename from app/src/main/res/drawable/ic_download.xml rename to app/src/main/res/drawable/ic_list_create.xml index 8bd663095..dce66429c 100644 --- a/app/src/main/res/drawable/ic_download.xml +++ b/app/src/main/res/drawable/ic_list_create.xml @@ -8,5 +8,5 @@ android:viewportHeight="24"> + android:pathData="M3 16H10V14H3M18 14V10H16V14H12V16H16V20H18V16H22V14M14 6H3V8H14M14 10H3V12H14V10Z" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_list_end.xml b/app/src/main/res/drawable/ic_list_end.xml deleted file mode 100644 index 2e7f20ad1..000000000 --- a/app/src/main/res/drawable/ic_list_end.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_list_group.xml b/app/src/main/res/drawable/ic_list_group.xml deleted file mode 100644 index 6d53cab51..000000000 --- a/app/src/main/res/drawable/ic_list_group.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_list_next.xml b/app/src/main/res/drawable/ic_list_next.xml deleted file mode 100644 index eb82e2891..000000000 --- a/app/src/main/res/drawable/ic_list_next.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_list_start.xml b/app/src/main/res/drawable/ic_list_start.xml deleted file mode 100644 index ca31a1d05..000000000 --- a/app/src/main/res/drawable/ic_list_start.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_loading.xml b/app/src/main/res/drawable/ic_loading.xml new file mode 100644 index 000000000..901cbecd1 --- /dev/null +++ b/app/src/main/res/drawable/ic_loading.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_mal.xml b/app/src/main/res/drawable/ic_mal.xml deleted file mode 100644 index 7e849bfb5..000000000 --- a/app/src/main/res/drawable/ic_mal.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_new.xml b/app/src/main/res/drawable/ic_new.xml index f0e81e12d..f51c265cf 100644 --- a/app/src/main/res/drawable/ic_new.xml +++ b/app/src/main/res/drawable/ic_new.xml @@ -2,7 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?colorError" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> - - diff --git a/app/src/main/res/drawable/ic_placeholder.xml b/app/src/main/res/drawable/ic_placeholder.xml index ba8dd489f..2bbc4f567 100644 --- a/app/src/main/res/drawable/ic_placeholder.xml +++ b/app/src/main/res/drawable/ic_placeholder.xml @@ -7,5 +7,8 @@ android:viewportHeight="60"> + android:pathData="M20 37.891C20 39.056 20.948 40 22.109 40H37.89c1.166 0 2.11-0.948 2.11-2.109V22.11c0-1.166-0.948-2.11-2.109-2.11H22.11C20.944 20 20 20.948 20 22.109V37.89zM37.891 39H22.11C21.499 38.999 21 38.5 21 37.89v-2.636l3.793-3.792 3.24 3.24c0.196 0.197 0.511 0.197 0.707 0l5.852-5.851L39 33.257v4.634c0 0.61-0.499 1.108-1.108 1.108zM22.11 21h15.78c0.61 0 1.108 0.499 1.108 1.108v9.73l-4.054-4.05c-0.196-0.196-0.51-0.196-0.707 0l-5.852 5.852-3.24-3.24c-0.197-0.197-0.512-0.197-0.708 0L21 33.836V22.11c0-0.61 0.499-1.108 1.108-1.108z" /> + diff --git a/app/src/main/res/drawable/ic_plug_large.xml b/app/src/main/res/drawable/ic_plug_large.xml deleted file mode 100644 index 8930996e4..000000000 --- a/app/src/main/res/drawable/ic_plug_large.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_reorder.xml b/app/src/main/res/drawable/ic_read_reversed.xml similarity index 57% rename from app/src/main/res/drawable/ic_reorder.xml rename to app/src/main/res/drawable/ic_read_reversed.xml index 3ebd02732..61833df96 100644 --- a/app/src/main/res/drawable/ic_reorder.xml +++ b/app/src/main/res/drawable/ic_read_reversed.xml @@ -1,4 +1,3 @@ - + android:pathData="M4 21h15a2 2 0 0 0 2-2v-6h-4v2l-4-3 4-3v2h4V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2m0-6h4v2H4v-2m0-4h7v2H4v-2m0-4h7v2H4V7m17 4h3v2h-3v-2z" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_reader_ltr.xml b/app/src/main/res/drawable/ic_reader_ltr.xml deleted file mode 100644 index 12b736427..000000000 --- a/app/src/main/res/drawable/ic_reader_ltr.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_reader_rtl.xml b/app/src/main/res/drawable/ic_reader_rtl.xml deleted file mode 100644 index cae026996..000000000 --- a/app/src/main/res/drawable/ic_reader_rtl.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_reddit.xml b/app/src/main/res/drawable/ic_reddit.xml new file mode 100644 index 000000000..047683dbc --- /dev/null +++ b/app/src/main/res/drawable/ic_reddit.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_rotation_lock.xml b/app/src/main/res/drawable/ic_screen_rotation_lock.xml deleted file mode 100644 index eec657730..000000000 --- a/app/src/main/res/drawable/ic_screen_rotation_lock.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_select_group.xml b/app/src/main/res/drawable/ic_select_group.xml deleted file mode 100644 index 9705aa565..000000000 --- a/app/src/main/res/drawable/ic_select_group.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_services.xml b/app/src/main/res/drawable/ic_services.xml deleted file mode 100644 index 593c9666c..000000000 --- a/app/src/main/res/drawable/ic_services.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_shown.xml similarity index 76% rename from app/src/main/res/drawable/ic_eye.xml rename to app/src/main/res/drawable/ic_shown.xml index 17453a301..ee4887a82 100644 --- a/app/src/main/res/drawable/ic_eye.xml +++ b/app/src/main/res/drawable/ic_shown.xml @@ -1,12 +1,12 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shown_hidden.xml b/app/src/main/res/drawable/ic_shown_hidden.xml new file mode 100644 index 000000000..5405a4747 --- /dev/null +++ b/app/src/main/res/drawable/ic_shown_hidden.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_size_large.xml b/app/src/main/res/drawable/ic_size_large.xml deleted file mode 100644 index feb4a5b73..000000000 --- a/app/src/main/res/drawable/ic_size_large.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_size_small.xml b/app/src/main/res/drawable/ic_size_small.xml deleted file mode 100644 index 4d4a819be..000000000 --- a/app/src/main/res/drawable/ic_size_small.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_star.xml similarity index 58% rename from app/src/main/res/drawable/ic_arrow_forward.xml rename to app/src/main/res/drawable/ic_star.xml index d9a7ec3f8..a1b2a8843 100644 --- a/app/src/main/res/drawable/ic_arrow_forward.xml +++ b/app/src/main/res/drawable/ic_star.xml @@ -1,12 +1,11 @@ + android:pathData="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-0.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /> diff --git a/app/src/main/res/drawable/ic_state_abandoned.xml b/app/src/main/res/drawable/ic_star_manga_info.xml similarity index 55% rename from app/src/main/res/drawable/ic_state_abandoned.xml rename to app/src/main/res/drawable/ic_star_manga_info.xml index 6c9fca311..803311090 100644 --- a/app/src/main/res/drawable/ic_state_abandoned.xml +++ b/app/src/main/res/drawable/ic_star_manga_info.xml @@ -1,11 +1,11 @@ + android:pathData="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-0.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /> diff --git a/app/src/main/res/drawable/ic_state_finished.xml b/app/src/main/res/drawable/ic_state_finished.xml index 1ba124216..7538f9a93 100644 --- a/app/src/main/res/drawable/ic_state_finished.xml +++ b/app/src/main/res/drawable/ic_state_finished.xml @@ -1,11 +1,11 @@ + android:pathData="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> diff --git a/app/src/main/res/drawable/ic_state_ongoing.xml b/app/src/main/res/drawable/ic_state_ongoing.xml index a993eb2c4..353aa32d1 100644 --- a/app/src/main/res/drawable/ic_state_ongoing.xml +++ b/app/src/main/res/drawable/ic_state_ongoing.xml @@ -1,11 +1,14 @@ + android:pathData="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" /> + diff --git a/app/src/main/res/drawable/ic_storage_checked.xml b/app/src/main/res/drawable/ic_storage_checked.xml deleted file mode 100644 index 58a0d9ddc..000000000 --- a/app/src/main/res/drawable/ic_storage_checked.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_storage_selector.xml b/app/src/main/res/drawable/ic_storage_selector.xml deleted file mode 100644 index 2f97ef9cf..000000000 --- a/app/src/main/res/drawable/ic_storage_selector.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_suggestion.xml b/app/src/main/res/drawable/ic_suggestion.xml index d018a746e..877ae301f 100644 --- a/app/src/main/res/drawable/ic_suggestion.xml +++ b/app/src/main/res/drawable/ic_suggestion.xml @@ -9,4 +9,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_suggestion_checked.xml b/app/src/main/res/drawable/ic_suggestion_checked.xml deleted file mode 100644 index 4712a5070..000000000 --- a/app/src/main/res/drawable/ic_suggestion_checked.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_suggestion_selector.xml b/app/src/main/res/drawable/ic_suggestion_selector.xml deleted file mode 100644 index 7dd48994c..000000000 --- a/app/src/main/res/drawable/ic_suggestion_selector.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml deleted file mode 100644 index ad631f02e..000000000 --- a/app/src/main/res/drawable/ic_sync.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_tap.xml b/app/src/main/res/drawable/ic_tap.xml deleted file mode 100644 index d0bdf143c..000000000 --- a/app/src/main/res/drawable/ic_tap.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_tap_reorder.xml b/app/src/main/res/drawable/ic_tap_reorder.xml deleted file mode 100644 index 81a180b71..000000000 --- a/app/src/main/res/drawable/ic_tap_reorder.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_telegram.xml b/app/src/main/res/drawable/ic_telegram.xml deleted file mode 100644 index c18d3a442..000000000 --- a/app/src/main/res/drawable/ic_telegram.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml deleted file mode 100644 index e6b135831..000000000 --- a/app/src/main/res/drawable/ic_timer.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_totoro.xml b/app/src/main/res/drawable/ic_totoro.xml new file mode 100644 index 000000000..fba48661f --- /dev/null +++ b/app/src/main/res/drawable/ic_totoro.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twitter.xml b/app/src/main/res/drawable/ic_twitter.xml new file mode 100644 index 000000000..3e51fdce1 --- /dev/null +++ b/app/src/main/res/drawable/ic_twitter.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_welcome.xml b/app/src/main/res/drawable/ic_welcome.xml deleted file mode 100644 index 7063392d7..000000000 --- a/app/src/main/res/drawable/ic_welcome.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_zoom_in.xml b/app/src/main/res/drawable/ic_zoom_in.xml deleted file mode 100644 index ea3011fd6..000000000 --- a/app/src/main/res/drawable/ic_zoom_in.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_zoom_out.xml b/app/src/main/res/drawable/ic_zoom_out.xml deleted file mode 100644 index be2ee0fbb..000000000 --- a/app/src/main/res/drawable/ic_zoom_out.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/list_selector.xml b/app/src/main/res/drawable/list_selector.xml index 5142ab10e..b1763b045 100644 --- a/app/src/main/res/drawable/list_selector.xml +++ b/app/src/main/res/drawable/list_selector.xml @@ -5,43 +5,32 @@ - - - - - - + android:bottom="2dp" + android:left="2dp"> + android:bottom="2dp" + android:left="2dp"> + android:bottom="2dp" + android:left="2dp"> @@ -49,15 +38,4 @@ - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_thumb.xml b/app/src/main/res/drawable/list_thumb.xml new file mode 100644 index 000000000..5d9290f87 --- /dev/null +++ b/app/src/main/res/drawable/list_thumb.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_track.xml b/app/src/main/res/drawable/list_track.xml new file mode 100644 index 000000000..599a4728d --- /dev/null +++ b/app/src/main/res/drawable/list_track.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/m3_popup_background.xml b/app/src/main/res/drawable/m3_popup_background.xml deleted file mode 100644 index 98f60a8be..000000000 --- a/app/src/main/res/drawable/m3_popup_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/m3_spinner_popup_background.xml b/app/src/main/res/drawable/m3_spinner_popup_background.xml deleted file mode 100644 index 9cb3a631a..000000000 --- a/app/src/main/res/drawable/m3_spinner_popup_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/tabs_background.xml b/app/src/main/res/drawable/tabs_background.xml deleted file mode 100644 index b16a0e6d7..000000000 --- a/app/src/main/res/drawable/tabs_background.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/thumb.xml b/app/src/main/res/drawable/thumb.xml new file mode 100644 index 000000000..72ace3cdb --- /dev/null +++ b/app/src/main/res/drawable/thumb.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_activated.xml b/app/src/main/res/drawable/thumb_activated.xml new file mode 100644 index 000000000..48f91fca7 --- /dev/null +++ b/app/src/main/res/drawable/thumb_activated.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_bar_background.xml b/app/src/main/res/drawable/toolbar_background.xml similarity index 73% rename from app/src/main/res/drawable/search_bar_background.xml rename to app/src/main/res/drawable/toolbar_background.xml index 32b28bd70..301d6bc22 100644 --- a/app/src/main/res/drawable/search_bar_background.xml +++ b/app/src/main/res/drawable/toolbar_background.xml @@ -2,6 +2,6 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shikimori.xml b/app/src/main/res/drawable/track.xml similarity index 52% rename from app/src/main/res/drawable/ic_shikimori.xml rename to app/src/main/res/drawable/track.xml index 2f1eddcf1..00bd82d2e 100644 --- a/app/src/main/res/drawable/ic_shikimori.xml +++ b/app/src/main/res/drawable/track.xml @@ -1,5 +1,4 @@ - + android:shape="rectangle" /> \ No newline at end of file diff --git a/app/src/main/res/layout-land/dialog_list_mode.xml b/app/src/main/res/layout-land/dialog_list_mode.xml new file mode 100644 index 000000000..f103a0ac3 --- /dev/null +++ b/app/src/main/res/layout-land/dialog_list_mode.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/item_empty_state.xml b/app/src/main/res/layout-land/item_empty_state.xml deleted file mode 100644 index f039c9cbc..000000000 --- a/app/src/main/res/layout-land/item_empty_state.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - -