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.
-   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
+   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](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
[](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