diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml
index 97aa936c3..e0a417b3f 100644
--- a/.github/ISSUE_TEMPLATE/report_issue.yml
+++ b/.github/ISSUE_TEMPLATE/report_issue.yml
@@ -44,7 +44,7 @@ body:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
- Example: "3.2.2"
+ Example: "3.3"
validations:
required: true
@@ -87,7 +87,7 @@ body:
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- - label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
+ - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/request_feature.yml b/.github/ISSUE_TEMPLATE/request_feature.yml
index 2077efe27..d4b373203 100644
--- a/.github/ISSUE_TEMPLATE/request_feature.yml
+++ b/.github/ISSUE_TEMPLATE/request_feature.yml
@@ -33,7 +33,7 @@ body:
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- - label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
+ - label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3ba4daee9..9b63e14e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,11 +6,13 @@
/.idea/dictionaries
/.idea/modules.xml
/.idea/misc.xml
+/.idea/discord.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
+/.idea/androidTestResultsUserPreferences.xml
.DS_Store
/build
/captures
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 6e5389ed9..a0de2a152 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/icon.svg b/.idea/icon.svg
new file mode 100644
index 000000000..61006f3ad
--- /dev/null
+++ b/.idea/icon.svg
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index a6fb1fbe4..2bcd23609 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -4,6 +4,9 @@
+
+
+
diff --git a/README.md b/README.md
index cea436ae3..6714250e3 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# Kotatsu
+# Kotatsu
Kotatsu is a free and open source manga reader for Android.
-   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
+   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
### Download
@@ -12,14 +12,13 @@ height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK from Github Releases:
-- [Latest release](https://github.com/nv95/Kotatsu/releases/latest)
-- [Legacy build](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy) (with Android 4.1+ support)
+- [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
### Main Features
* Online manga catalogues
-* Search manga by name and genre
-* Reading history
+* 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 design UI
@@ -30,12 +29,12 @@ Download APK from Github Releases:
### Screenshots
-|  |  |  |
-|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
-|  |  |  |
+|  |  |  |
+|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
-|  |  |
-|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
+|  |  |
+|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
### Localization
@@ -43,16 +42,18 @@ Download APK from Github Releases:
-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
+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
### License
-[](http://www.gnu.org/licenses/gpl-3.0.en.html)
+
+[](http://www.gnu.org/licenses/gpl-3.0.en.html)
Kotatsu is Free Software: You can use, study share and improve it at your
will. Specifically you can redistribute and/or modify it under the terms of the
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
published by the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
+(at your option) any later version.
### Disclaimer
diff --git a/app/build.gradle b/app/build.gradle
index ddd401a15..bf66187c7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
- versionCode 406
- versionName '3.2.2'
+ versionCode 410
+ versionName '3.3.1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -49,72 +49,88 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
- '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
- '-opt-in=kotlinx.coroutines.FlowPreview',
- '-opt-in=kotlin.contracts.ExperimentalContracts',
- '-opt-in=coil.annotation.ExperimentalCoilApi',
+ '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
+ '-opt-in=kotlinx.coroutines.FlowPreview',
+ '-opt-in=kotlin.contracts.ExperimentalContracts',
+ '-opt-in=coil.annotation.ExperimentalCoilApi',
]
}
lint {
abortOnError false
- disable 'MissingTranslation', 'PrivateResource'
+ disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
}
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = false
}
}
+afterEvaluate {
+ compileDebugKotlin {
+ kotlinOptions {
+ freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
+ }
+ }
+}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
- implementation('com.github.nv95:kotatsu-parsers:44e6842025') {
+ implementation('com.github.nv95:kotatsu-parsers:8a3b6df91d') {
exclude group: 'org.json', module: 'json'
}
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2'
- implementation 'androidx.core:core-ktx:1.7.0'
- implementation 'androidx.activity:activity-ktx:1.4.0'
- implementation 'androidx.fragment:fragment-ktx:1.4.1'
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+ implementation 'androidx.core:core-ktx:1.8.0'
+ implementation 'androidx.activity:activity-ktx:1.5.0-rc01'
+ implementation 'androidx.fragment:fragment-ktx:1.5.0-rc01'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-rc01'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc01'
+ implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc01'
+ implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc01'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.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 'com.google.android.material:material:1.7.0-alpha01'
+ implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
+ implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8
- kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
+ kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc01'
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.9.3'
- implementation 'com.squareup.okio:okio:3.0.0'
+ implementation 'com.squareup.okhttp3:okhttp:4.10.0'
+ implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
+ implementation 'com.squareup.okio:okio:3.1.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
- implementation 'io.insert-koin:koin-android:3.1.6'
- implementation 'io.coil-kt:coil-base:2.0.0-rc03'
+ 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 'ch.acra:acra-mail:5.9.3'
+ implementation 'ch.acra:acra-dialog:5.9.3'
+
+ debugImplementation 'org.jsoup:jsoup:1.15.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
- testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
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.6.2'
+ 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.4.2'
+ androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/bad_ids.json b/app/src/androidTest/assets/manga/bad_ids.json
new file mode 100644
index 000000000..d0f9001a0
--- /dev/null
+++ b/app/src/androidTest/assets/manga/bad_ids.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 1552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/empty.json b/app/src/androidTest/assets/manga/empty.json
new file mode 100644
index 000000000..369f0e237
--- /dev/null
+++ b/app/src/androidTest/assets/manga/empty.json
@@ -0,0 +1,36 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/first_chapters.json b/app/src/androidTest/assets/manga/first_chapters.json
new file mode 100644
index 000000000..697dec9c8
--- /dev/null
+++ b/app/src/androidTest/assets/manga/first_chapters.json
@@ -0,0 +1,136 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/full.json b/app/src/androidTest/assets/manga/full.json
new file mode 100644
index 000000000..9667baa9c
--- /dev/null
+++ b/app/src/androidTest/assets/manga/full.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/without_middle_chapter.json b/app/src/androidTest/assets/manga/without_middle_chapter.json
new file mode 100644
index 000000000..97d797b53
--- /dev/null
+++ b/app/src/androidTest/assets/manga/without_middle_chapter.json
@@ -0,0 +1,154 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
index f0f37c2a1..54141f3e6 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
@@ -1,14 +1,13 @@
package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
-import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
+import java.io.IOException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.core.db.migrations.*
-import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@@ -16,8 +15,7 @@ class MangaDatabaseTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
- MangaDatabase::class.java.canonicalName,
- FrameworkSQLiteOpenHelperFactory()
+ MangaDatabase::class.java,
)
@Test
@@ -37,7 +35,6 @@ class MangaDatabaseTest {
}
}
-
private companion object {
const val TEST_DB = "test-db"
@@ -50,6 +47,9 @@ class MangaDatabaseTest {
Migration5To6(),
Migration6To7(),
Migration7To8(),
+ Migration8To9(),
+ Migration9To10(),
+ Migration10To11(),
)
}
}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
new file mode 100644
index 000000000..3b63f429e
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
@@ -0,0 +1,188 @@
+package org.koitharu.kotatsu.tracker.domain
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.coroutines.test.runTest
+import okio.buffer
+import okio.source
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.test.KoinTest
+import org.koin.test.inject
+import org.koitharu.kotatsu.base.domain.MangaDataRepository
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+
+@RunWith(AndroidJUnit4::class)
+class TrackerTest : KoinTest {
+
+ private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
+ private val mangaAdapter = moshi.adapter(Manga::class.java)
+ private val historyRegistry by inject()
+ private val repository by inject()
+ private val dataRepository by inject()
+ private val tracker by inject()
+
+ @Test
+ fun noUpdates() = runTest {
+ val manga = loadManga("full.json")
+ tracker.deleteTrack(manga.id)
+
+ tracker.checkUpdates(manga, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(manga.id))
+ tracker.checkUpdates(manga, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(manga.id))
+ }
+
+ @Test
+ fun hasUpdates() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaFull = loadManga("full.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun badIds() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaBad = loadManga("bad_ids.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaBad, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun badIds2() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaBad = loadManga("bad_ids.json")
+ val mangaFull = loadManga("full.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaBad, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun fullReset() = runTest {
+ val mangaFull = loadManga("full.json")
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaEmpty = loadManga("empty.json")
+ tracker.deleteTrack(mangaFull.id)
+
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaEmpty, commit = true).apply {
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaEmpty, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ }
+
+ @Test
+ fun syncWithHistory() = runTest {
+ val mangaFull = loadManga("full.json")
+ val mangaFirst = loadManga("first_chapters.json")
+ tracker.deleteTrack(mangaFull.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+
+ val chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
+ repository.syncWithHistory(mangaFull, chapter.id)
+
+ assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ private suspend fun loadManga(name: String): Manga {
+ val assets = InstrumentationRegistry.getInstrumentation().context.assets
+ val manga = assets.open("manga/$name").use {
+ mangaAdapter.fromJson(it.source().buffer())
+ } ?: throw RuntimeException("Cannot read manga from json \"$name\"")
+ dataRepository.storeManga(manga)
+ return manga
+ }
+}
\ No newline at end of file
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
index 4323e3a5f..9ebcba9f4 100644
--- a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
+++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
@@ -25,7 +25,7 @@ class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaS
offset: Int,
query: String?,
tags: Set?,
- sortOrder: SortOrder?
+ sortOrder: SortOrder,
): List {
TODO("Not yet implemented")
}
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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 760ae8af9..22bad7dd9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
-
().theme)
registerActivityLifecycleCallbacks(get())
+ registerActivityLifecycleCallbacks(get())
val widgetUpdater = WidgetUpdater(applicationContext)
widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get())
@@ -69,10 +76,41 @@ class KotatsuApp : Application() {
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.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 enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
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
index 03b0dd53b..b3b32dc1f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
@@ -9,55 +9,50 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
-import org.koitharu.kotatsu.BuildConfig
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 org.koitharu.kotatsu.parsers.util.medianOrNull
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? {
- try {
- val page = pages.medianOrNull() ?: return null
- val url = MangaRepository(page.source).getPageUrl(page)
- val uri = Uri.parse(url)
- val size = if (uri.scheme == "cbz") {
+ 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) {
- 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())
- }
+ getBitmapSize(it.body?.byteStream())
}
}
- return size.width * 2 < size.height
- } catch (e: Exception) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
- return null
}
+ return size.width * MIN_WEBTOON_RATIO < size.height
}
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
@@ -78,4 +73,4 @@ object MangaUtils : KoinComponent {
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/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index 2fcfeac76..bd172d695 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -12,7 +12,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar
-import androidx.core.app.ActivityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -43,9 +42,13 @@ abstract class BaseActivity :
override fun onCreate(savedInstanceState: Bundle?) {
val settings = get()
+ val isAmoled = settings.isAmoledTheme
+ val isDynamic = settings.isDynamicTheme
+ // TODO support DialogWhenLarge theme
when {
- settings.isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
- settings.isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
+ 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)
@@ -79,8 +82,9 @@ abstract class BaseActivity :
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
- ActivityCompat.recreate(this)
- return true
+ // ActivityCompat.recreate(this)
+ throw RuntimeException("Test crash")
+ // return true
}
return super.onKeyDown(keyCode, event)
}
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
index 647cd93ae..c8c4051b3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -2,11 +2,11 @@ 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.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -14,6 +14,7 @@ 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() {
@@ -33,6 +34,20 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
): 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
}
@@ -42,11 +57,7 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return if (resources.getBoolean(R.bool.is_tablet)) {
- AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
- } else {
- AppBottomSheetDialog(requireContext(), theme)
- }
+ return AppBottomSheetDialog(requireContext(), theme)
}
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
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
index 64317e4a7..e43ca8877 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt
@@ -7,10 +7,12 @@ 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
@@ -18,7 +20,8 @@ private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
-abstract class BaseFullscreenActivity : BaseActivity(),
+abstract class BaseFullscreenActivity :
+ BaseActivity(),
View.OnSystemUiVisibilityChangeListener {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity : BaseActivity(),
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
}
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
index 39233e28f..f17e3aa9f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
@@ -1,18 +1,25 @@
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.BuildConfig
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() {
- val onError = SingleLiveEvent()
- val isLoading = CountedBooleanLiveData()
+ protected val loadingCounter = CountedBooleanLiveData()
+ protected val errorEvent = SingleLiveEvent()
+
+ val onError: LiveData
+ get() = errorEvent
+
+ val isLoading: LiveData
+ get() = loadingCounter
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
@@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() {
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
- isLoading.postValue(true)
+ loadingCounter.increment()
try {
block()
} finally {
- isLoading.postValue(false)
+ loadingCounter.decrement()
}
}
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
- if (BuildConfig.DEBUG) {
- throwable.printStackTrace()
- }
+ throwable.printStackTraceDebug()
if (throwable !is CancellationException) {
- onError.postCall(throwable)
+ errorEvent.postCall(throwable)
}
}
}
\ 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
index d3b911ace..8b6da8d3d 100644
--- 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
@@ -21,7 +21,7 @@ class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(con
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 intial system UI visibility:
+ // Fix super-class's window flag bug by respecting the initial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt
new file mode 100644
index 000000000..650e816c5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt
@@ -0,0 +1,20 @@
+package org.koitharu.kotatsu.base.ui.list
+
+import android.view.View
+import android.view.View.OnClickListener
+import android.view.View.OnLongClickListener
+import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
+
+class AdapterDelegateClickListenerAdapter(
+ private val adapterDelegate: AdapterDelegateViewBindingViewHolder,
+ private val clickListener: OnListItemClickListener,
+) : OnClickListener, OnLongClickListener {
+
+ override fun onClick(v: View) {
+ clickListener.onItemClick(adapterDelegate.item, v)
+ }
+
+ override fun onLongClick(v: View): Boolean {
+ return clickListener.onItemLongClick(adapterDelegate.item, v)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
new file mode 100644
index 000000000..f072da2fa
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
@@ -0,0 +1,34 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.app.Activity
+import android.app.Application.ActivityLifecycleCallbacks
+import android.os.Bundle
+import java.util.*
+
+class ActivityRecreationHandle : ActivityLifecycleCallbacks {
+
+ private val activities = WeakHashMap()
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ activities[activity] = Unit
+ }
+
+ override fun onActivityStarted(activity: Activity) = Unit
+
+ override fun onActivityResumed(activity: Activity) = Unit
+
+ override fun onActivityPaused(activity: Activity) = Unit
+
+ override fun onActivityStopped(activity: Activity) = Unit
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
+
+ override fun onActivityDestroyed(activity: Activity) {
+ 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
index cb54ef7db..d654e541d 100644
--- 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
@@ -1,20 +1,31 @@
package org.koitharu.kotatsu.base.ui.util
-import androidx.lifecycle.MutableLiveData
+import androidx.annotation.AnyThread
+import androidx.lifecycle.LiveData
+import java.util.concurrent.atomic.AtomicInteger
-class CountedBooleanLiveData : MutableLiveData(false) {
+class CountedBooleanLiveData : LiveData(false) {
- private var counter = 0
+ private val counter = AtomicInteger(0)
- override fun setValue(value: Boolean) {
- if (value) {
- counter++
- } else {
- counter--
+ @AnyThread
+ fun increment() {
+ if (counter.getAndIncrement() == 0) {
+ postValue(true)
}
- val newValue = counter > 0
- if (newValue != this.value) {
- super.setValue(newValue)
+ }
+
+ @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/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
index 5f1f0cd5e..c0d9c8c53 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
@@ -16,10 +16,7 @@ class WindowInsetsDelegate(
private var lastInsets: Insets? = null
- override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat?): WindowInsetsCompat? {
- if (insets == null) {
- return null
- }
+ override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
val newInsets = if (handleImeInsets) {
Insets.max(
@@ -49,7 +46,7 @@ class WindowInsetsDelegate(
) {
view.removeOnLayoutChangeListener(this)
if (lastInsets == null) { // Listener may not be called
- onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view))
+ onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
index 18d7262dc..bdaf8f476 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
@@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor(
init {
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
- val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor)
- ?: getRippleColorFallback(context)
+ val itemRippleColor = getRippleColor(context)
val shape = createShapeDrawable(this)
background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
@@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor(
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
).build()
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
- shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint)
+ shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor)
return InsetDrawable(
shapeDrawable,
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
@@ -118,7 +117,7 @@ class ListItemTextView @JvmOverloads constructor(
)
}
- private fun getRippleColorFallback(context: Context): ColorStateList {
+ private fun getRippleColor(context: Context): ColorStateList {
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
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/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
new file mode 100644
index 000000000..0959b3362
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
@@ -0,0 +1,28 @@
+package org.koitharu.kotatsu.bookmarks.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+
+@Entity(
+ tableName = "bookmarks",
+ primaryKeys = ["manga_id", "page_id"],
+ foreignKeys = [
+ ForeignKey(
+ entity = MangaEntity::class,
+ parentColumns = ["manga_id"],
+ childColumns = ["manga_id"],
+ onDelete = ForeignKey.CASCADE
+ ),
+ ]
+)
+class BookmarkEntity(
+ @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
+ @ColumnInfo(name = "page_id", index = true) val pageId: Long,
+ @ColumnInfo(name = "chapter_id") val chapterId: Long,
+ @ColumnInfo(name = "page") val page: Int,
+ @ColumnInfo(name = "scroll") val scroll: Int,
+ @ColumnInfo(name = "image") val imageUrl: String,
+ @ColumnInfo(name = "created_at") val createdAt: Long,
+)
\ No newline at end of file
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/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
new file mode 100644
index 000000000..981aa05ea
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
@@ -0,0 +1,31 @@
+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.util.*
+
+fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
+ manga.toManga(tags.toMangaTags())
+)
+
+fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
+ manga = manga,
+ pageId = pageId,
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll,
+ imageUrl = imageUrl,
+ createdAt = Date(createdAt),
+)
+
+fun Bookmark.toEntity() = BookmarkEntity(
+ mangaId = manga.id,
+ pageId = pageId,
+ chapterId = chapterId,
+ page = page,
+ scroll = scroll,
+ imageUrl = imageUrl,
+ createdAt = createdAt.time,
+)
\ 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..0b76c6537
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
@@ -0,0 +1,43 @@
+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,
+) {
+
+ 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
+
+ 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()
+ 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/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt
new file mode 100644
index 000000000..f8aa0e638
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt
@@ -0,0 +1,51 @@
+package org.koitharu.kotatsu.bookmarks.ui
+
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import coil.request.Disposable
+import coil.size.Scale
+import coil.util.CoilUtils
+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.databinding.ItemBookmarkBinding
+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) }
+) {
+
+ var imageRequest: Disposable? = null
+ val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
+
+ binding.root.setOnClickListener(listener)
+ binding.root.setOnLongClickListener(listener)
+
+ bind {
+ imageRequest?.dispose()
+ imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
+ .referer(item.manga.publicUrl)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .allowRgb565(true)
+ .scale(Scale.FILL)
+ .lifecycle(lifecycleOwner)
+ .enqueueWith(coil)
+ }
+
+ onViewRecycled {
+ imageRequest?.dispose()
+ imageRequest = null
+ CoilUtils.dispose(binding.imageViewThumb)
+ binding.imageViewThumb.setImageDrawable(null)
+ }
+}
\ 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/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index 436455014..2c74455f8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
+import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
+import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.*
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.db.migrations.*
@@ -15,14 +17,17 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
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
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
- TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
+ TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
],
- version = 10
+ version = 11,
)
abstract class MangaDatabase : RoomDatabase() {
@@ -43,6 +48,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao
+
+ abstract val bookmarksDao: BookmarksDao
}
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@@ -59,6 +66,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration7To8(),
Migration8To9(),
Migration9To10(),
+ Migration10To11(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
).build()
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
index 8ddc723d5..ade35613b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
-import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
-import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga
+import org.koitharu.kotatsu.tracker.data.TrackLogEntity
+import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao
interface TrackLogsDao {
@@ -21,7 +21,7 @@ interface TrackLogsDao {
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
- suspend fun cleanup()
+ suspend fun gc()
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
index 51cd77b2e..af938a813 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.core.db.entity
-import java.util.*
-import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.*
-import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.koitharu.kotatsu.utils.ext.longHashCode
// Entity to model
@@ -35,13 +33,6 @@ fun MangaEntity.toManga(tags: Set) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
-fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
- id = trackLog.id,
- chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
- manga = manga.toManga(tags.toMangaTags()),
- createdAt = Date(trackLog.createdAt)
-)
-
// Model to entity
fun Manga.toEntity() = MangaEntity(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
new file mode 100644
index 000000000..5d80708fe
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
@@ -0,0 +1,26 @@
+package org.koitharu.kotatsu.core.db.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration10To11 : Migration(10, 11) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `bookmarks` (
+ `manga_id` INTEGER NOT NULL,
+ `page_id` INTEGER NOT NULL,
+ `chapter_id` INTEGER NOT NULL,
+ `page` INTEGER NOT NULL,
+ `scroll` INTEGER NOT NULL,
+ `image` TEXT NOT NULL,
+ `created_at` INTEGER NOT NULL,
+ PRIMARY KEY(`manga_id`, `page_id`),
+ FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
+ """.trimIndent()
+ )
+ 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/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/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
index 7da9e309f..58d8d22c6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
@@ -4,7 +4,5 @@ import org.koin.dsl.module
val githubModule
get() = module {
- factory {
- GithubRepository(get())
- }
+ factory { GithubRepository(get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
index 09557cb47..88304755b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
@@ -54,27 +54,23 @@ class VersionId(
return result
}
- companion object {
-
- private fun variantWeight(variantType: String) =
- when (variantType.lowercase(Locale.ROOT)) {
- "a", "alpha" -> 1
- "b", "beta" -> 2
- "rc" -> 4
- "" -> 8
- else -> 0
- }
-
- fun parse(versionName: String): VersionId {
- val parts = versionName.substringBeforeLast('-').split('.')
- val variant = versionName.substringAfterLast('-', "")
- return VersionId(
- major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
- minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
- build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
- variantType = variant.filter(Char::isLetter),
- variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0
- )
- }
+ private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
+ "a", "alpha" -> 1
+ "b", "beta" -> 2
+ "rc" -> 4
+ "" -> 8
+ else -> 0
}
+}
+
+fun VersionId(versionName: String): VersionId {
+ val parts = versionName.substringBeforeLast('-').split('.')
+ val variant = versionName.substringAfterLast('-', "")
+ return VersionId(
+ major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
+ minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
+ build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
+ 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/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/java/org/koitharu/kotatsu/core/model/MangaTracking.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
deleted file mode 100644
index 77fdc5925..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.koitharu.kotatsu.core.model
-
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-import org.koitharu.kotatsu.parsers.model.Manga
-import java.util.*
-
-data class MangaTracking(
- val manga: Manga,
- val knownChaptersCount: Int,
- val lastChapterId: Long,
- val lastNotifiedChapterId: Long,
- val lastCheck: Date?
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt
new file mode 100644
index 000000000..7c3c2db6e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt
@@ -0,0 +1,84 @@
+package org.koitharu.kotatsu.core.network
+
+import okhttp3.Cache
+import okhttp3.Dns
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.dnsoverhttps.DnsOverHttps
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import java.net.InetAddress
+import java.net.UnknownHostException
+
+class DoHManager(
+ cache: Cache,
+ private val settings: AppSettings,
+) : Dns {
+
+ private val bootstrapClient = OkHttpClient.Builder().cache(cache).build()
+
+ private var cachedDelegate: Dns? = null
+ private var cachedProvider: DoHProvider? = null
+
+ override fun lookup(hostname: String): List {
+ return getDelegate().lookup(hostname)
+ }
+
+ @Synchronized
+ private fun getDelegate(): Dns {
+ var delegate = cachedDelegate
+ val provider = settings.dnsOverHttps
+ if (delegate == null || provider != cachedProvider) {
+ delegate = createDelegate(provider)
+ cachedDelegate = delegate
+ cachedProvider = provider
+ }
+ return delegate
+ }
+
+ private fun createDelegate(provider: DoHProvider): Dns = when (provider) {
+ DoHProvider.NONE -> Dns.SYSTEM
+ DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
+ .url("https://dns.google/dns-query".toHttpUrl())
+ .bootstrapDnsHosts(
+ listOfNotNull(
+ tryGetByIp("8.8.4.4"),
+ 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())
+ .bootstrapDnsHosts(
+ listOfNotNull(
+ tryGetByIp("162.159.36.1"),
+ tryGetByIp("162.159.46.1"),
+ tryGetByIp("1.1.1.1"),
+ tryGetByIp("1.0.0.1"),
+ tryGetByIp("162.159.132.53"),
+ tryGetByIp("2606:4700:4700::1111"),
+ 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())
+ .bootstrapDnsHosts(
+ listOfNotNull(
+ tryGetByIp("94.140.14.140"),
+ tryGetByIp("94.140.14.141"),
+ tryGetByIp("2a10:50c0::1:ff"),
+ tryGetByIp("2a10:50c0::2:ff"),
+ )
+ ).build()
+ }
+
+ private fun tryGetByIp(ip: String): InetAddress? = try {
+ InetAddress.getByName(ip)
+ } catch (e: UnknownHostException) {
+ e.printStackTraceDebug()
+ null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt
new file mode 100644
index 000000000..e17db70a7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.core.network
+
+enum class DoHProvider {
+
+ NONE, GOOGLE, CLOUDFLARE, ADGUARD
+}
\ No newline at end of file
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
index 48b009a33..2af4c215e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.network
-import java.util.concurrent.TimeUnit
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koin.dsl.bind
@@ -8,17 +7,20 @@ 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())
- cache(get().createHttpCache())
+ dns(DoHManager(cache, get()))
+ cache(cache)
addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor())
}.build()
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
index ca4cc495b..90c84d5a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -13,12 +13,9 @@ interface MangaRepository {
val sortOrders: Set
- suspend fun getList(
- offset: Int,
- query: String? = null,
- tags: Set? = null,
- sortOrder: SortOrder? = null,
- ): List
+ suspend fun getList(offset: Int, query: String): List
+
+ suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List
suspend fun getDetails(manga: Manga): Manga
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
index 14a113e24..999ecb09b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
@@ -20,12 +20,13 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
getConfig().defaultSortOrder = value
}
- override suspend fun getList(
- offset: Int,
- query: String?,
- tags: Set?,
- sortOrder: SortOrder?,
- ): List = parser.getList(offset, query, tags, sortOrder)
+ 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)
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
index a3ebc7f7a..547e1635c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
-import android.os.Build
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
@@ -20,6 +19,7 @@ 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
@@ -52,7 +52,7 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val isDynamicTheme: Boolean
- get() = prefs.getBoolean(KEY_DYNAMIC_THEME, false)
+ get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@@ -99,8 +99,11 @@ class AppSettings(context: Context) {
val readerAnimation: Boolean
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
- val isPreferRtlReader: Boolean
- get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false)
+ val defaultReaderMode: ReaderMode
+ get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
+
+ val isReaderModeDetectionEnabled: Boolean
+ get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
var historyGrouping: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
@@ -149,7 +152,7 @@ class AppSettings(context: Context) {
}
fun markKnownSources(sources: Collection) {
- sourcesOrder = sourcesOrder + sources.map { it.name }
+ sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
val isPagesNumbersEnabled: Boolean
@@ -189,6 +192,9 @@ class AppSettings(context: Context) {
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
@@ -276,7 +282,8 @@ class AppSettings(context: Context) {
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_PREFER_RTL = "reader_prefer_rtl"
+ 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_APP_VERSION = "app_version"
@@ -297,6 +304,7 @@ class AppSettings(context: Context) {
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"
@@ -309,12 +317,5 @@ class AppSettings(context: Context) {
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
-
- val isDynamicColorAvailable: Boolean
- get() = DynamicColors.isDynamicColorAvailable() ||
- (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
-
- private val isSamsung
- get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
}
\ 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/ReaderMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
index bfc8b7b83..9ec51d479 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
@@ -10,4 +10,4 @@ enum class ReaderMode(val id: Int) {
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt
deleted file mode 100644
index 20a7bf0c3..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.koitharu.kotatsu.core.ui
-
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-import kotlin.system.exitProcess
-
-class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
-
- override fun uncaughtException(t: Thread, e: Throwable) {
- val intent = CrashActivity.newIntent(applicationContext, e)
- intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
- try {
- applicationContext.startActivity(intent)
- } catch (t: Throwable) {
- t.printStackTrace()
- }
- Log.e("CRASH", e.message, e)
- exitProcess(1)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt
deleted file mode 100644
index 7d4d31878..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package org.koitharu.kotatsu.core.ui
-
-import android.app.Activity
-import android.content.ActivityNotFoundException
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.databinding.ActivityCrashBinding
-import org.koitharu.kotatsu.main.ui.MainActivity
-import org.koitharu.kotatsu.parsers.util.ellipsize
-import org.koitharu.kotatsu.utils.ShareHelper
-
-class CrashActivity : Activity(), View.OnClickListener {
-
- private lateinit var binding: ActivityCrashBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityCrashBinding.inflate(layoutInflater)
- setContentView(binding.root)
- binding.textView.text = intent.getStringExtra(Intent.EXTRA_TEXT)
- binding.buttonClose.setOnClickListener(this)
- binding.buttonRestart.setOnClickListener(this)
- binding.buttonReport.setOnClickListener(this)
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.opt_crash, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- R.id.action_share -> {
- ShareHelper(this).shareText(binding.textView.text.toString())
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- override fun onClick(v: View) {
- when (v.id) {
- R.id.button_close -> {
- finish()
- }
- R.id.button_restart -> {
- val intent = Intent(applicationContext, MainActivity::class.java)
- intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
- startActivity(intent)
- finish()
- }
- R.id.button_report -> {
- val intent = Intent(Intent.ACTION_VIEW)
- intent.data = Uri.parse("https://github.com/nv95/Kotatsu/issues")
- try {
- startActivity(Intent.createChooser(intent, getString(R.string.report_github)))
- } catch (_: ActivityNotFoundException) {
- }
- }
- }
- }
-
- companion object {
-
- private const val MAX_TRACE_SIZE = 131071
-
- fun newIntent(context: Context, error: Throwable): Intent {
- val crashInfo = error
- .stackTraceToString()
- .trimIndent()
- .ellipsize(MAX_TRACE_SIZE)
- val intent = Intent(context, CrashActivity::class.java)
- intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
- return intent
- }
- }
-}
\ 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
index 91698a76b..283a141d3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
@@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity
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
@@ -27,6 +28,7 @@ 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 :
@@ -43,11 +45,6 @@ class ChaptersFragment :
private var actionMode: ActionMode? = null
private var selectionDecoration: ChaptersSelectionDecoration? = null
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
@@ -72,6 +69,7 @@ class ChaptersFragment :
binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu()
}
+ addMenuProvider(ChaptersMenuProvider())
}
override fun onDestroyView() {
@@ -81,31 +79,6 @@ class ChaptersFragment :
super.onDestroyView()
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_chapters, menu)
- val searchMenuItem = menu.findItem(R.id.action_search)
- searchMenuItem.setOnActionExpandListener(this)
- val searchView = searchMenuItem.actionView as SearchView
- searchView.setOnQueryTextListener(this)
- searchView.setIconifiedByDefault(false)
- searchView.queryHint = searchMenuItem.title
- }
-
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(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 onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_reversed -> {
- viewModel.setChaptersReversed(!item.isChecked)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.chapter.id)
@@ -121,13 +94,7 @@ class ChaptersFragment :
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return
}
- val options = ActivityOptions.makeScaleUpAnimation(
- view,
- 0,
- 0,
- view.measuredWidth,
- view.measuredHeight
- )
+ val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
startActivity(
ReaderActivity.newIntent(
context = view.context,
@@ -274,4 +241,30 @@ class ChaptersFragment :
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
index 04363de41..46a6aff01 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -15,8 +15,6 @@ import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets
-import androidx.core.net.toFile
-import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
@@ -44,9 +42,8 @@ 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.search.ui.global.GlobalSearchActivity
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.shikimori.ui.selector.ShikimoriSelectorBottomSheet
-import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity :
@@ -84,6 +81,9 @@ class DetailsActivity :
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), longDuration = false)
+ }
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
}
@@ -161,16 +161,6 @@ class DetailsActivity :
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_share -> {
- viewModel.manga.value?.let {
- if (it.source == MangaSource.LOCAL) {
- ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
- } else {
- ShareHelper(this).shareMangaLink(it)
- }
- }
- true
- }
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this)
@@ -203,7 +193,7 @@ class DetailsActivity :
}
R.id.action_related -> {
viewModel.manga.value?.let {
- startActivity(GlobalSearchActivity.newIntent(this, it.title))
+ startActivity(MultiSearchActivity.newIntent(this, it.title))
}
true
}
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
index 086dc75ce..ab29930dc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -8,8 +8,11 @@ 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
@@ -21,7 +24,11 @@ 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.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
@@ -36,22 +43,19 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
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 {
+ ChipsView.OnChipClickListener,
+ OnListItemClickListener {
private val viewModel by sharedViewModel()
private val coil by inject(mode = LazyThreadSafetyMode.NONE)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -70,11 +74,26 @@ class DetailsFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
+ viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
+ addMenuProvider(DetailsMenuProvider())
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_details_info, menu)
+ 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) {
@@ -177,6 +196,20 @@ class DetailsFragment :
}
}
+ 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))
+ }
+ }
+
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
@@ -207,13 +240,9 @@ class DetailsFragment :
)
}
R.id.imageView_cover -> {
- val options = ActivityOptions.makeSceneTransitionAnimation(
- requireActivity(),
- binding.imageViewCover,
- binding.imageViewCover.transitionName,
- )
+ val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity(
- ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
+ ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
options.toBundle()
)
}
@@ -279,20 +308,42 @@ class DetailsFragment :
}
private fun loadCover(manga: Manga) {
- val currentCover = binding.imageViewCover.drawable
+ 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)
- if (currentCover != null) {
- request.data(manga.largeCoverUrl ?: return)
- .placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey)
- .fallback(currentCover)
- } else {
- request.crossfade(true)
- .data(manga.coverUrl)
- .fallback(R.drawable.ic_placeholder)
- }
- request.referer(manga.publicUrl)
+ .data(imageUrl)
+ .crossfade(true)
+ .referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
- .enqueueWith(coil)
+ 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
index d5ab2e2c2..05b9537b6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -1,125 +1,117 @@
package org.koitharu.kotatsu.details.ui
-import androidx.core.os.LocaleListCompat
+import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
+import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
-import org.koitharu.kotatsu.BuildConfig
+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.core.exceptions.MangaNotFoundException
-import org.koitharu.kotatsu.core.parser.MangaRepository
+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.details.ui.model.toListItem
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.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.shikimori.data.ShikimoriRepository
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.iterator
-import java.io.IOException
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class DetailsViewModel(
- private val intent: MangaIntent,
+ intent: MangaIntent,
private val historyRepository: HistoryRepository,
- private val favouritesRepository: FavouritesRepository,
+ favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
- private val trackingRepository: TrackingRepository,
- private val mangaDataRepository: MangaDataRepository,
+ trackingRepository: TrackingRepository,
+ mangaDataRepository: MangaDataRepository,
+ private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val shikimoriRepository: ShikimoriRepository,
) : BaseViewModel() {
+ private val delegate = MangaDetailsDelegate(
+ intent = intent,
+ settings = settings,
+ mangaDataRepository = mangaDataRepository,
+ historyRepository = historyRepository,
+ localMangaRepository = localMangaRepository,
+ )
+
private var loadingJob: Job
- private val mangaData = MutableStateFlow(intent.manga)
- private val selectedBranch = MutableStateFlow(null)
- private val history = mangaData.mapNotNull { it?.id }
- .distinctUntilChanged()
- .flatMapLatest { mangaId ->
- historyRepository.observeOne(mangaId)
- }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+ val onShowToast = SingleLiveEvent()
- private val favourite = mangaData.mapNotNull { it?.id }
- .distinctUntilChanged()
- .flatMapLatest { mangaId ->
- favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() }
- }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
+ private val history = historyRepository.observeOne(delegate.mangaId)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
- private val newChapters = mangaData.mapNotNull { it?.id }
- .distinctUntilChanged()
- .mapLatest { mangaId ->
- trackingRepository.getNewChaptersCount(mangaId)
- }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
-
- // Remote manga for saved and saved for remote
- private val relatedManga = MutableStateFlow(null)
- private val chaptersQuery = MutableStateFlow("")
-
- private val chaptersReversed = settings.observe()
- .filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
- .map { settings.chaptersReverse }
- .onStart { emit(settings.chaptersReverse) }
+ private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
- val manga = mangaData.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)
+ 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)
val onMangaRemoved = SingleLiveEvent()
val isShikimoriAvailable: Boolean
get() = shikimoriRepository.isAuthorized
- val branches = mangaData.map {
- it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
+ 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(),
- selectedBranch
+ delegate.selectedBranch
) { branches, selected ->
branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
- val isChaptersEmpty = mangaData.mapNotNull { m ->
- m?.run { chapters.isNullOrEmpty() }
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
+ val isChaptersEmpty: LiveData = combine(
+ delegate.manga,
+ isLoading.asFlow(),
+ ) { m, loading ->
+ m != null && m.chapters.isNullOrEmpty() && !loading
+ }.asLiveDataDistinct(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
- mangaData.map { it?.chapters.orEmpty() },
- relatedManga,
- history.map { it?.chapterId },
+ delegate.manga,
+ delegate.relatedManga,
+ history,
+ delegate.selectedBranch,
newChapters,
- selectedBranch
- ) { chapters, related, currentId, newCount, branch ->
- val relatedChapters = related?.chapters
- if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
- mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
- } else {
- mapChapters(chapters, relatedChapters, currentId, newCount, branch)
- }
+ ) { manga, related, history, branch, news ->
+ delegate.mapChapters(manga, related, history, news, branch)
},
chaptersReversed,
chaptersQuery,
@@ -128,7 +120,7 @@ class DetailsViewModel(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchValue: String?
- get() = selectedBranch.value
+ get() = delegate.selectedBranch.value
init {
loadingJob = doLoad()
@@ -140,7 +132,11 @@ class DetailsViewModel(
}
fun deleteLocal() {
- val m = mangaData.value ?: return
+ 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}" }
@@ -153,16 +149,23 @@ class DetailsViewModel(
}
}
+ 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?) {
- selectedBranch.value = branch
+ delegate.selectedBranch.value = branch
}
fun getRemoteManga(): Manga? {
- return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
+ return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
}
fun performChapterSearch(query: String?) {
@@ -170,7 +173,7 @@ class DetailsViewModel(
}
fun onDownloadComplete(downloadedManga: Manga) {
- val currentManga = mangaData.value ?: return
+ val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) {
return
}
@@ -181,142 +184,16 @@ class DetailsViewModel(
runCatching {
localMangaRepository.getDetails(downloadedManga)
}.onSuccess {
- relatedManga.value = it
+ delegate.relatedManga.value = it
}.onFailure {
- if (BuildConfig.DEBUG) {
- it.printStackTrace()
- }
+ it.printStackTraceDebug()
}
}
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
- var manga = mangaDataRepository.resolveIntent(intent)
- ?: throw MangaNotFoundException("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 ->
- if (BuildConfig.DEBUG) error.printStackTrace()
- }.getOrNull()
- }
-
- 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
+ delegate.doLoad()
}
private fun List.filterSearch(query: String): List {
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..07f03dbda
--- /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.exceptions.MangaNotFoundException
+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.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 MangaNotFoundException("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/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
index 9a423b2e2..f65951d47 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.details.ui.adapter
-import android.view.View
import androidx.core.view.isVisible
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.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -21,11 +21,7 @@ fun chapterListItemAD(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) {
- val eventListener = object : View.OnClickListener, View.OnLongClickListener {
- override fun onClick(v: View) = clickListener.onItemClick(item, v)
- override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
- }
-
+ val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
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
index 58335ed31..d079eb51f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -12,7 +12,6 @@ import kotlinx.coroutines.sync.Semaphore
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.IOException
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -24,6 +23,7 @@ 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
@@ -156,9 +156,7 @@ class DownloadManager(
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
+ e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e)
} finally {
withContext(NonCancellable) {
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
index e249e4dc5..a0c6c63dd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.download.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
-import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
-import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest
@@ -17,7 +15,7 @@ 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.LifecycleAwareServiceConnection
+import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
class DownloadsActivity : BaseActivity() {
@@ -28,11 +26,10 @@ class DownloadsActivity : BaseActivity() {
val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
- LifecycleAwareServiceConnection.bindService(
- this,
- this,
- Intent(this, DownloadService::class.java),
- 0
+ bindServiceWithLifecycle(
+ owner = this,
+ service = Intent(this, DownloadService::class.java),
+ flags = 0,
).service.flatMapLatest { binder ->
(binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
}.onEach {
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
index 528908bfb..a8f0744bd 100644
--- 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
@@ -11,8 +11,8 @@ 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.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
@@ -20,7 +20,6 @@ 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
-import com.google.android.material.R as materialR
class DownloadNotification(private val context: Context, startId: Int) {
@@ -59,6 +58,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
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)
@@ -85,14 +91,6 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setOngoing(false)
- builder.setContentIntent(
- PendingIntent.getActivity(
- context,
- state.manga.hashCode(),
- CrashActivity.newIntent(context, state.error),
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
- )
- )
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
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
index 05c6df6bd..91c742e73 100644
--- 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
@@ -99,39 +99,42 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob) {
lifecycleScope.launch {
val startId = job.progressValue.startId
- val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId)
- 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()
+ 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))
+ )
}
- .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()
- (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)
}
- notificationSwitcher.detach(
- startId,
- if (job.isCancelled) {
- null
- } else {
- notification.create(job.progressValue, -1L)
- }
- )
- stopSelf(startId)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
index 8e9d945a0..22939d9d9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
@@ -19,12 +21,12 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.main.ui.AppBarOwner
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
@@ -43,11 +45,6 @@ class FavouritesContainerFragment :
private var pagerAdapter: FavouritesPagerAdapter? = null
private var stubBinding: ItemEmptyStateBinding? = null
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
@@ -61,6 +58,7 @@ class FavouritesContainerFragment :
pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner)
+ addMenuProvider(FavouritesContainerMenuProvider(view.context))
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -115,21 +113,6 @@ class FavouritesContainerFragment :
}
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.opt_favourites, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_categories -> {
- context?.let {
- startActivity(CategoriesActivity.newIntent(it))
- }
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
private fun onError(e: Throwable) {
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt
new file mode 100644
index 000000000..1b07f535d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerMenuProvider.kt
@@ -0,0 +1,28 @@
+package org.koitharu.kotatsu.favourites.ui
+
+import android.content.Context
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
+
+class FavouritesContainerMenuProvider(
+ private val context: Context,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_favourites, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.action_categories -> {
+ context.startActivity(CategoriesActivity.newIntent(context))
+ true
+ }
+ else -> false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
index 46dc79586..1e24d033f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
@@ -3,10 +3,11 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -70,9 +71,7 @@ class FavouritesCategoriesViewModel(
return result
}
- private fun observeAllCategoriesVisible() = settings.observe()
- .filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE }
- .map { settings.isAllFavouritesVisible }
- .onStart { emit(settings.isAllFavouritesVisible) }
- .distinctUntilChanged()
+ private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) {
+ isAllFavouritesVisible
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
index d840b783f..e64e36e5a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
@@ -16,7 +16,7 @@ fun categoryAD(
clickListener.onItemClick(item.category, it)
}
@Suppress("ClickableViewAccessibility")
- binding.imageViewHandle.setOnTouchListener { v, event ->
+ binding.imageViewHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item.category, itemView)
} else {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
index 27239c0dc..b38dfec26 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -6,10 +6,12 @@ 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.ArrayAdapter
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -84,9 +86,9 @@ class FavouritesCategoryEditActivity : BaseActivity
right = insets.right,
bottom = insets.bottom,
)
- binding.toolbar.updatePadding(
- top = insets.top,
- )
+ binding.toolbar.updateLayoutParams {
+ topMargin = insets.top
+ }
}
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
index 9eb9b8d04..aa2bacbbe 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
@@ -28,7 +28,7 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet(),
OnListItemClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
- Toolbar.OnMenuItemClickListener {
+ Toolbar.OnMenuItemClickListener, View.OnClickListener {
private val viewModel by viewModel {
parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga })
@@ -46,6 +46,7 @@ class FavouriteCategoriesBottomSheet :
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
binding.toolbar.setOnMenuItemClickListener(this)
+ binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -58,14 +59,20 @@ class FavouriteCategoriesBottomSheet :
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
- R.id.action_create -> {
- startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
+ R.id.action_done -> {
+ dismiss()
true
}
else -> false
}
}
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
+ }
+ }
+
override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCaegoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt
similarity index 100%
rename from app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCaegoryAD.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
index 8d4b9e419..4d55137a2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
@@ -2,18 +2,15 @@ package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu
-import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
-import androidx.core.view.iterator
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.ui.titleRes
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() {
@@ -30,47 +27,14 @@ class FavouritesListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
+
+ if (categoryId != NO_ID) {
+ addMenuProvider(FavouritesListMenuProvider(viewModel))
+ }
}
override fun onScrolledToEnd() = Unit
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- if (categoryId != NO_ID) {
- inflater.inflate(R.menu.opt_favourites_list, menu)
- menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
- for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
- val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
- menuItem.isCheckable = true
- }
- submenu.setGroupCheckable(R.id.group_order, true, true)
- }
- }
- }
-
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(menu)
- menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
- val selectedOrder = viewModel.sortOrder.value
- for (item in submenu) {
- val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
- item.isChecked = order == selectedOrder
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when {
- item.itemId == R.id.action_order -> false
- item.groupId == R.id.group_order -> {
- val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
- viewModel.setSortOrder(order)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu)
return super.onCreateActionMode(mode, menu)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt
new file mode 100644
index 000000000..2a1b08876
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListMenuProvider.kt
@@ -0,0 +1,48 @@
+package org.koitharu.kotatsu.favourites.ui.list
+
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import androidx.core.view.iterator
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.ui.titleRes
+import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
+
+class FavouritesListMenuProvider(
+ private val viewModel: FavouritesListViewModel,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_favourites_list, menu)
+ menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
+ for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
+ val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
+ menuItem.isCheckable = true
+ }
+ submenu.setGroupCheckable(R.id.group_order, true, true)
+ }
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
+ val selectedOrder = viewModel.sortOrder.value
+ for (item in submenu) {
+ val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
+ item.isChecked = order == selectedOrder
+ }
+ }
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when {
+ menuItem.itemId == R.id.action_order -> false
+ menuItem.groupId == R.id.group_order -> {
+ val order = CategoriesActivity.SORT_ORDERS.getOrNull(menuItem.order) ?: return false
+ viewModel.setSortOrder(order)
+ true
+ }
+ else -> false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
index 062e8d898..f52c5db6d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
@@ -15,6 +15,10 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List
+ @Transaction
+ @Query("SELECT * FROM history WHERE manga_id IN (:ids)")
+ abstract suspend fun findAll(ids: Collection): List
+
@Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC")
abstract fun observeAll(): Flow>
@@ -69,4 +73,13 @@ abstract class HistoryDao {
true
} else false
}
+
+ @Transaction
+ open suspend fun upsert(entities: Iterable) {
+ for (e in entities) {
+ if (update(e) == 0) {
+ insert(e)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index a88c8a82d..4519b60e4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -4,6 +4,7 @@ import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.MangaHistory
@@ -76,7 +77,7 @@ class HistoryRepository(
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
)
)
- trackingRepository.upsert(manga)
+ trackingRepository.syncWithHistory(manga, chapterId)
}
}
@@ -100,6 +101,19 @@ class HistoryRepository(
}
}
+ suspend fun deleteReversible(ids: Collection): ReversibleHandle {
+ val entities = db.withTransaction {
+ val entities = db.historyDao.findAll(ids.toList()).filterNotNull()
+ for (id in ids) {
+ db.historyDao.delete(id)
+ }
+ entities
+ }
+ return ReversibleHandle {
+ db.historyDao.upsert(entities)
+ }
+ }
+
/**
* Try to replace one manga with another one
* Useful for replacing saved manga on deleting it with remove source
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
index 6980b80ee..b68f247aa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
@@ -2,15 +2,17 @@ package org.koitharu.kotatsu.history.ui
import android.os.Bundle
import android.view.Menu
-import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
+import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
class HistoryListFragment : MangaListFragment() {
@@ -19,44 +21,15 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ addMenuProvider(HistoryListMenuProvider(view.context, viewModel))
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
+ viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
}
override fun onScrolledToEnd() = Unit
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.opt_history, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(menu)
- menu.findItem(R.id.action_history_grouping)?.isChecked =
- viewModel.isGroupingEnabled.value == true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_clear_history -> {
- MaterialAlertDialogBuilder(context ?: return false)
- .setTitle(R.string.clear_history)
- .setMessage(R.string.text_clear_history_prompt)
- .setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(R.string.clear) { _, _ ->
- viewModel.clearHistory()
- }.show()
- true
- }
- R.id.action_history_grouping -> {
- viewModel.setGrouping(!item.isChecked)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(mode, menu)
@@ -80,6 +53,12 @@ class HistoryListFragment : MangaListFragment() {
}
}
+ private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
+ Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG)
+ .setAction(R.string.undo) { reversibleHandle.reverseAsync() }
+ .show()
+ }
+
companion object {
fun newInstance() = HistoryListFragment()
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
new file mode 100644
index 000000000..b27629ce6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
@@ -0,0 +1,41 @@
+package org.koitharu.kotatsu.history.ui
+
+import android.content.Context
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koitharu.kotatsu.R
+
+class HistoryListMenuProvider(
+ private val context: Context,
+ private val viewModel: HistoryListViewModel,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_history, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_clear_history -> {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.clear_history)
+ .setMessage(R.string.text_clear_history_prompt)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.clear) { _, _ ->
+ viewModel.clearHistory()
+ }.show()
+ true
+ }
+ R.id.action_history_grouping -> {
+ viewModel.setGrouping(!menuItem.isChecked)
+ true
+ }
+ else -> false
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_history_grouping).isChecked = viewModel.isGroupingEnabled.value == true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 42dd81e95..1768c2a5f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -5,17 +5,24 @@ import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
+import org.koitharu.kotatsu.base.domain.plus
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
+import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
@@ -28,12 +35,9 @@ class HistoryListViewModel(
) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData()
+ val onItemsRemoved = SingleLiveEvent()
- private val historyGrouping = settings.observe()
- .filter { it == AppSettings.KEY_HISTORY_GROUPING }
- .map { settings.historyGrouping }
- .onStart { emit(settings.historyGrouping) }
- .distinctUntilChanged()
+ private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
.onEach { isGroupingEnabled.postValue(it) }
override val content = combine(
@@ -52,8 +56,10 @@ class HistoryListViewModel(
)
else -> mapList(list, grouped, mode)
}
+ }.onStart {
+ loadingCounter.increment()
}.onFirst {
- isLoading.postValue(false)
+ loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@@ -73,9 +79,12 @@ class HistoryListViewModel(
if (ids.isEmpty()) {
return
}
- launchJob {
- repository.delete(ids)
+ launchJob(Dispatchers.Default) {
+ val handle = repository.deleteReversible(ids) + ReversibleHandle {
+ shortcutsRepository.updateShortcuts()
+ }
shortcutsRepository.updateShortcuts()
+ onItemsRemoved.postCall(handle)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index ad7e3ab70..28556ac48 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -9,6 +9,7 @@ import androidx.collection.ArraySet
import androidx.core.graphics.Insets
import androidx.core.view.isNotEmpty
import androidx.core.view.updatePadding
+import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
@@ -67,11 +68,6 @@ abstract class MangaListFragment :
protected val selectedItems: Set
get() = collectSelectedItems()
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
@@ -98,6 +94,7 @@ abstract class MangaListFragment :
setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled
}
+ addMenuProvider(MangaListMenuProvider(childFragmentManager))
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -114,19 +111,6 @@ abstract class MangaListFragment :
super.onDestroyView()
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.opt_list, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_list_mode -> {
- ListModeSelectDialog.show(childFragmentManager)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt
new file mode 100644
index 000000000..5950cd546
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt
@@ -0,0 +1,25 @@
+package org.koitharu.kotatsu.list.ui
+
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.FragmentManager
+import org.koitharu.kotatsu.R
+
+class MangaListMenuProvider(
+ private val fragmentManager: FragmentManager,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_list, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_list_mode -> {
+ ListModeSelectDialog.show(fragmentManager)
+ true
+ }
+ else -> false
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
index 20f768c2f..6adc8c0d2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt
@@ -4,16 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
+import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel
-import org.koitharu.kotatsu.list.ui.model.MangaGridModel
-import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
-import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
abstract class MangaListViewModel(
private val settings: AppSettings,
@@ -21,20 +19,15 @@ abstract class MangaListViewModel(
abstract val content: LiveData>
val listMode = MutableLiveData()
- val gridScale = settings.observe()
- .filter { it == AppSettings.KEY_GRID_SIZE }
- .map { settings.gridSize / 100f }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO) {
- settings.gridSize / 100f
- }
+ val gridScale = settings.observeAsLiveData(
+ context = viewModelScope.coroutineContext + Dispatchers.Default,
+ key = AppSettings.KEY_GRID_SIZE,
+ valueProducer = { gridSize / 100f },
+ )
open fun onRemoveFilterTag(tag: MangaTag) = Unit
- protected fun createListModeFlow() = settings.observe()
- .filter { it == AppSettings.KEY_LIST_MODE }
- .map { settings.listMode }
- .onStart { emit(settings.listMode) }
- .distinctUntilChanged()
+ protected fun createListModeFlow() = settings.observeAsFlow(AppSettings.KEY_LIST_MODE) { listMode }
.onEach {
if (listMode.value != it) {
listMode.postValue(it)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt
index 86b72c738..c13fd3cfa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt
@@ -13,7 +13,7 @@ fun currentFilterAD(
val chipGroup = itemView as ChipsView
- chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data ->
+ chipGroup.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { _, data ->
listener.onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
index f1d6d3af4..e4ad38d3e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter
+import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
@@ -21,6 +23,7 @@ fun mangaGridItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener,
+ sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) {
@@ -34,6 +37,11 @@ fun mangaGridItemAD(
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
}
+ if (sizeResolver != null) {
+ itemView.updateLayoutParams {
+ width = sizeResolver.cellWidth
+ }
+ }
bind {
binding.textViewTitle.text = item.title
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
index 2b359a8a9..93f271c0c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt
@@ -18,7 +18,7 @@ class MangaListAdapter(
delegatesManager
.addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
- .addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener))
+ .addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener, null))
.addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD())
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt
index 81c79e1ae..7583b2e8c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterBottomSheet.kt
@@ -6,7 +6,6 @@ import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager
-import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
@@ -14,11 +13,14 @@ import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
-class FilterBottomSheet : BaseBottomSheet(), MenuItem.OnActionExpandListener,
- SearchView.OnQueryTextListener, DialogInterface.OnKeyListener {
+class FilterBottomSheet :
+ BaseBottomSheet(),
+ MenuItem.OnActionExpandListener,
+ SearchView.OnQueryTextListener,
+ DialogInterface.OnKeyListener {
private val viewModel by sharedViewModel(
- owner = { from(requireParentFragment(), requireParentFragment()) }
+ owner = { requireParentFragment() }
)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
index acba2466c..5ea361168 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt
@@ -1,17 +1,19 @@
package org.koitharu.kotatsu.list.ui.filter
import androidx.annotation.WorkerThread
+import androidx.lifecycle.LiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.*
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import java.text.Collator
import java.util.*
class FilterCoordinator(
@@ -27,7 +29,7 @@ class FilterCoordinator(
}
private var availableTagsDeferred = loadTagsAsync()
- val items = getItemsFlow()
+ val items: LiveData> = getItemsFlow()
.asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default)
init {
@@ -104,7 +106,7 @@ class FilterCoordinator(
query: String,
): List {
val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
- val tags = mergeTags(state.tags, allTags.tags).sortedBy { it.title }
+ val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList(tags.size + sortOrders.size + 3)
if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) {
@@ -113,7 +115,7 @@ class FilterCoordinator(
FilterItem.Sort(it, isSelected = it == state.sortOrder)
}
}
- if(allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
+ if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(FilterItem.Header(R.string.genres, state.tags.size))
tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags)
@@ -153,14 +155,12 @@ class FilterCoordinator(
runCatching {
repository.getTags()
}.onFailure { error ->
- if (BuildConfig.DEBUG) {
- error.printStackTrace()
- }
+ error.printStackTraceDebug()
}.getOrNull()
}
private fun mergeTags(primary: Set, secondary: Set): Set {
- val result = TreeSet(TagTitleComparator())
+ val result = TreeSet(TagTitleComparator(repository.source.locale))
result.addAll(secondary)
result.addAll(primary)
return result
@@ -193,11 +193,14 @@ class FilterCoordinator(
}
}
- private class TagTitleComparator : Comparator {
+ private class TagTitleComparator(lc: String?) : Comparator {
- override fun compare(o1: MangaTag, o2: MangaTag) = compareValues(
- o1.title.lowercase(),
- o2.title.lowercase(),
- )
+ private val collator = lc?.let { Collator.getInstance(Locale(it)) }
+
+ override fun compare(o1: MangaTag, o2: MangaTag): Int {
+ val t1 = o1.title.lowercase()
+ val t2 = o2.title.lowercase()
+ return collator?.compare(t1, t2) ?: compareValues(t1, t2)
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
index 0d9de41f3..cda127e73 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt
@@ -40,7 +40,7 @@ fun Manga.toGridModel(counter: Int) = MangaGridModel(
suspend fun List.toUi(
mode: ListMode,
countersProvider: CountersProvider,
-): List = when (mode) {
+): List = when (mode) {
ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) }
@@ -58,7 +58,7 @@ suspend fun > List.toUi(
fun List.toUi(
mode: ListMode,
-): List = when (mode) {
+): List = when (mode) {
ListMode.LIST -> map { it.toListModel(0) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) }
ListMode.GRID -> map { it.toGridModel(0) }
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
index 73dd83bb4..7aee467a5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data
import android.content.Context
import com.tomclaw.cache.DiskLruCache
-import java.io.File
-import java.io.InputStream
import kotlinx.coroutines.flow.MutableStateFlow
-import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.utils.FileSize
+import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
+import java.io.File
+import java.io.InputStream
class PagesCache(context: Context) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
index e034d0672..0fb9b63d3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
@@ -7,6 +7,12 @@ import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
+import java.io.File
+import java.io.IOException
+import java.util.*
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -15,19 +21,13 @@ import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.*
-import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait
+import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName
-import java.io.File
-import java.io.IOException
-import java.util.*
-import java.util.zip.ZipEntry
-import java.util.zip.ZipFile
-import kotlin.coroutines.CoroutineContext
private const val MAX_PARALLELISM = 4
@@ -37,28 +37,25 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
private val filenameFilter = CbzFilter()
private val locks = CompositeMutex()
- override suspend fun getList(
- offset: Int,
- query: String?,
- tags: Set?,
- sortOrder: SortOrder?
- ): List {
+ override suspend fun getList(offset: Int, query: String): List {
if (offset > 0) {
return emptyList()
}
- val files = getAllFiles()
- val list = coroutineScope {
- val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
- files.map { file ->
- getFromFileAsync(file, dispatcher)
- }.awaitAll()
- }.filterNotNullTo(ArrayList(files.size))
- if (!query.isNullOrEmpty()) {
+ val list = getRawList()
+ if (query.isNotEmpty()) {
list.retainAll { x ->
x.title.contains(query, ignoreCase = true) ||
x.altTitle?.contains(query, ignoreCase = true) == true
}
}
+ return list
+ }
+
+ override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List {
+ if (offset > 0) {
+ return emptyList()
+ }
+ val list = getRawList()
if (!tags.isNullOrEmpty()) {
list.retainAll { x ->
x.tags.containsAll(tags)
@@ -244,7 +241,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
- override val sortOrders = emptySet()
+ override val sortOrders = setOf(SortOrder.ALPHABETICAL)
override suspend fun getPageUrl(page: MangaPage) = page.url
@@ -295,6 +292,16 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
locks.unlock(id)
}
+ private suspend fun getRawList(): ArrayList {
+ val files = getAllFiles()
+ return coroutineScope {
+ val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
+ files.map { file ->
+ getFromFileAsync(file, dispatcher)
+ }.awaitAll()
+ }.filterNotNullTo(ArrayList(files.size))
+ }
+
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
index 4e5115ac8..4fff18ac6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
@@ -4,7 +4,6 @@ import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.Menu
-import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultCallback
@@ -15,11 +14,12 @@ import androidx.core.net.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.Progress
class LocalListFragment : MangaListFragment(), ActivityResultCallback> {
@@ -48,6 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback {
- onEmptyActionClick()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onActivityResult(result: List<@JvmSuppressWildcards Uri>) {
if (result.isEmpty()) return
viewModel.importFiles(result)
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt
new file mode 100644
index 000000000..ce9941293
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt
@@ -0,0 +1,26 @@
+package org.koitharu.kotatsu.local.ui
+
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import org.koitharu.kotatsu.R
+
+class LocalListMenuProvider(
+ private val onImportClick: Function0,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_local, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.action_import -> {
+ onImportClick()
+ true
+ }
+ else -> false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index 07dea24b5..375e03997 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -22,6 +21,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException
@@ -115,7 +115,7 @@ class LocalListViewModel(
private suspend fun doRefresh() {
try {
listError.value = null
- mangaList.value = repository.getList(0)
+ mangaList.value = repository.getList(0, null, null)
} catch (e: Throwable) {
listError.value = e
}
@@ -127,9 +127,7 @@ class LocalListViewModel(
runCatching {
repository.cleanup()
}.onFailure { error ->
- if (BuildConfig.DEBUG) {
- error.printStackTrace()
- }
+ error.printStackTraceDebug()
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
index c6e11107b..7bcca45d6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.main
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
+import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.main.ui.MainViewModel
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
@@ -11,6 +12,7 @@ import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel
val mainModule
get() = module {
single { AppProtectHelper(get()) }
+ single { ActivityRecreationHandle() }
factory { ShortcutsRepository(androidContext(), get(), get(), get()) }
viewModel { MainViewModel(get(), get()) }
viewModel { ProtectViewModel(get(), get()) }
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 687fb8015..55cd36559 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.main.ui
import android.app.ActivityOptions
import android.content.res.Configuration
-import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
@@ -20,7 +19,6 @@ import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager
-import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -47,7 +45,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
-import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
@@ -61,6 +59,7 @@ import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.*
+import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search"
@@ -141,6 +140,7 @@ class MainActivity :
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.remoteSources.observe(this, this::updateSideMenu)
viewModel.isSuggestionsEnabled.observe(this, this::setSuggestionsEnabled)
+ viewModel.isTrackerEnabled.observe(this, this::setTrackerEnabled)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -233,6 +233,8 @@ class MainActivity :
}
binding.toolbarCard.updateLayoutParams {
topMargin = insets.top + bottomMargin
+ leftMargin = insets.left
+ rightMargin = insets.right
}
binding.root.updatePadding(
left = insets.left,
@@ -268,7 +270,7 @@ class MainActivity :
if (source != null) {
startActivity(SearchActivity.newIntent(this, source, query))
} else {
- startActivity(GlobalSearchActivity.newIntent(this, query))
+ startActivity(MultiSearchActivity.newIntent(this, query))
}
searchSuggestionViewModel.saveQuery(query)
}
@@ -317,15 +319,7 @@ class MainActivity :
}
private fun onOpenReader(manga: Manga) {
- val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- ActivityOptions.makeClipRevealAnimation(
- binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
- )
- } else {
- ActivityOptions.makeScaleUpAnimation(
- binding.fab, 0, 0, binding.fab.measuredWidth, binding.fab.measuredHeight
- )
- }
+ val options = ActivityOptions.makeScaleUpAnimation(binding.fab, 0, 0, binding.fab.width, binding.fab.height)
startActivity(ReaderActivity.newIntent(this, manga), options?.toBundle())
}
@@ -359,6 +353,14 @@ class MainActivity :
item.isVisible = isEnabled
}
+ private fun setTrackerEnabled(isEnabled: Boolean) {
+ val item = binding.navigationView.menu.findItem(R.id.nav_feed) ?: return
+ if (!isEnabled && item.isChecked) {
+ binding.navigationView.setCheckedItem(R.id.nav_history)
+ }
+ item.isVisible = isEnabled
+ }
+
private fun openDefaultSection() {
when (viewModel.defaultSection) {
AppSection.LOCAL -> {
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
index f2b98d7e0..c3a681343 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt
@@ -7,7 +7,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
+import org.koitharu.kotatsu.core.prefs.AppSection
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
@@ -15,17 +17,27 @@ import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class MainViewModel(
private val historyRepository: HistoryRepository,
- settings: AppSettings
+ private val settings: AppSettings,
) : BaseViewModel() {
val onOpenReader = SingleLiveEvent()
- var defaultSection by settings::defaultSection
+ var defaultSection: AppSection
+ get() = settings.defaultSection
+ set(value) {
+ settings.defaultSection = value
+ }
- val isSuggestionsEnabled = settings.observe()
- .filter { it == AppSettings.KEY_SUGGESTIONS }
- .onStart { emit("") }
- .map { settings.isSuggestionsEnabled }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ val isSuggestionsEnabled = settings.observeAsLiveData(
+ context = viewModelScope.coroutineContext + Dispatchers.Default,
+ key = AppSettings.KEY_SUGGESTIONS,
+ valueProducer = { isSuggestionsEnabled },
+ )
+
+ val isTrackerEnabled = settings.observeAsLiveData(
+ context = viewModelScope.coroutineContext + Dispatchers.Default,
+ key = AppSettings.KEY_TRACKER_ENABLED,
+ valueProducer = { isTrackerEnabled },
+ )
val isResumeEnabled = historyRepository
.observeHasItems()
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
index bf3c865eb..953991baa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt
@@ -10,6 +10,11 @@ import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.TextView
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
+import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
+import androidx.biometric.BiometricPrompt
+import androidx.biometric.BiometricPrompt.AuthenticationCallback
import androidx.core.graphics.Insets
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
@@ -17,8 +22,11 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityProtectBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener,
- TextWatcher, View.OnClickListener {
+class ProtectActivity :
+ BaseActivity(),
+ TextView.OnEditorActionListener,
+ TextWatcher,
+ View.OnClickListener {
private val viewModel by viewModel()
@@ -39,7 +47,9 @@ class ProtectActivity : BaseActivity(), TextView.OnEdito
finishAfterTransition()
}
- binding.editPassword.requestFocus()
+ if (!useFingerprint()) {
+ binding.editPassword.requestFocus()
+ }
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -85,6 +95,28 @@ class ProtectActivity : BaseActivity(), TextView.OnEdito
binding.layoutPassword.isEnabled = !isLoading
}
+ private fun useFingerprint(): Boolean {
+ if (BiometricManager.from(this).canAuthenticate(BIOMETRIC_WEAK) != BIOMETRIC_SUCCESS) {
+ return false
+ }
+ val prompt = BiometricPrompt(this, BiometricCallback())
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(BIOMETRIC_WEAK)
+ .setTitle(getString(R.string.app_name))
+ .setConfirmationRequired(false)
+ .setNegativeButtonText(getString(android.R.string.cancel))
+ .build()
+ prompt.authenticate(promptInfo)
+ return true
+ }
+
+ private inner class BiometricCallback : AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ viewModel.unlock()
+ }
+ }
+
companion object {
private const val EXTRA_INTENT = "src_intent"
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
index 07646482b..69e671c01 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectViewModel.kt
@@ -27,12 +27,16 @@ class ProtectViewModel(
val passwordHash = password.md5()
val appPasswordHash = settings.appPassword
if (passwordHash == appPasswordHash) {
- protectHelper.unlock()
- onUnlockSuccess.call(Unit)
+ unlock()
} else {
delay(PASSWORD_COMPARE_DELAY)
throw WrongPasswordException()
}
}
}
+
+ fun unlock() {
+ protectHelper.unlock()
+ onUnlockSuccess.call(Unit)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
index c83ad608b..a27fb9e8d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
@@ -26,6 +26,7 @@ val readerModule
shortcutsRepository = get(),
settings = get(),
pageSaveHelper = get(),
+ bookmarksRepository = get(),
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt b/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt
new file mode 100644
index 000000000..2cd9ffcb3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/data/ModelMapping.kt
@@ -0,0 +1,27 @@
+package org.koitharu.kotatsu.reader.data
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+
+fun Manga.filterChapters(branch: String?): Manga {
+ if (chapters.isNullOrEmpty()) return this
+ return withChapters(chapters = chapters?.filter { it.branch == branch })
+}
+
+private fun Manga.withChapters(chapters: List?) = Manga(
+ id = id,
+ title = title,
+ altTitle = altTitle,
+ url = url,
+ publicUrl = publicUrl,
+ rating = rating,
+ isNsfw = isNsfw,
+ coverUrl = coverUrl,
+ tags = tags,
+ state = state,
+ author = author,
+ largeCoverUrl = largeCoverUrl,
+ description = description,
+ chapters = chapters,
+ source = source,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt
index 556a03cac..406fbbd63 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt
@@ -9,7 +9,7 @@ import android.webkit.MimeTypeMap
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
-class PageSaveContract : ActivityResultContracts.CreateDocument() {
+class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
index ca9228207..e4a29b948 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
@@ -6,11 +6,13 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.*
-import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.core.graphics.Insets
-import androidx.core.view.*
-import androidx.fragment.app.commit
+import androidx.core.view.OnApplyWindowInsetsListener
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.transition.Slide
import androidx.transition.TransitionManager
@@ -29,6 +31,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseFullscreenActivity
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.ReaderMode
@@ -36,11 +39,7 @@ import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.reader.ui.pager.BaseReader
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
-import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
-import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
-import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
@@ -50,6 +49,8 @@ import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasGlobalPoint
import org.koitharu.kotatsu.utils.ext.observeWithPrevious
+import org.koitharu.kotatsu.utils.ext.postDelayed
+import java.util.concurrent.TimeUnit
class ReaderActivity :
BaseFullscreenActivity(),
@@ -74,13 +75,13 @@ class ReaderActivity :
private lateinit var controlDelegate: ReaderControlDelegate
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private var gestureInsets: Insets = Insets.NONE
-
- private val reader
- get() = supportFragmentManager.findFragmentById(R.id.container) as? BaseReader<*>
+ private lateinit var readerManager: ReaderManager
+ private val hideUiRunnable = Runnable { setUiIsVisible(false) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
+ readerManager = ReaderManager(supportFragmentManager, R.id.container)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this)
orientationHelper = ScreenOrientationHelper(this)
@@ -90,6 +91,7 @@ class ReaderActivity :
insetsDelegate.interceptingWindowInsetsListener = this
orientationHelper.observeAutoOrientation()
+ .flowWithLifecycle(lifecycle)
.onEach {
binding.toolbarBottom.menu.findItem(R.id.action_screen_rotate).isVisible = !it
}.launchIn(lifecycleScope)
@@ -103,36 +105,29 @@ class ReaderActivity :
onLoadingStateChanged(viewModel.isLoading.value == true)
}
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
+ viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged)
+ viewModel.onShowToast.observe(this) { msgId ->
+ Snackbar.make(binding.container, msgId, Snackbar.LENGTH_SHORT)
+ .setAnchorView(binding.appbarBottom)
+ .show()
+ }
}
private fun onInitReader(mode: ReaderMode) {
- val currentReader = reader
- when (mode) {
- ReaderMode.WEBTOON -> if (currentReader !is WebtoonReaderFragment) {
- supportFragmentManager.commit {
- replace(R.id.container, WebtoonReaderFragment())
- }
- }
- ReaderMode.REVERSED -> if (currentReader !is ReversedReaderFragment) {
- supportFragmentManager.commit {
- replace(R.id.container, ReversedReaderFragment())
- }
- }
- ReaderMode.STANDARD -> if (currentReader !is PagerReaderFragment) {
- supportFragmentManager.commit {
- replace(R.id.container, PagerReaderFragment())
- }
- }
+ if (readerManager.currentMode != mode) {
+ readerManager.replace(mode)
}
- binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).setIcon(
- when (mode) {
- ReaderMode.WEBTOON -> R.drawable.ic_script
- ReaderMode.REVERSED -> R.drawable.ic_read_reversed
- ReaderMode.STANDARD -> R.drawable.ic_book_page
- }
- )
- binding.appbarTop.postDelayed(1000) {
- setUiIsVisible(false)
+ val iconRes = when (mode) {
+ ReaderMode.WEBTOON -> R.drawable.ic_script
+ ReaderMode.REVERSED -> R.drawable.ic_read_reversed
+ ReaderMode.STANDARD -> R.drawable.ic_book_page
+ }
+ binding.toolbarBottom.menu.findItem(R.id.action_reader_mode).run {
+ setIcon(iconRes)
+ setVisible(true)
+ }
+ if (binding.appbarTop.isVisible) {
+ lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1))
}
}
@@ -144,18 +139,8 @@ class ReaderActivity :
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_reader_mode -> {
- ReaderConfigDialog.show(
- supportFragmentManager,
- when (reader) {
- is PagerReaderFragment -> ReaderMode.STANDARD
- is WebtoonReaderFragment -> ReaderMode.WEBTOON
- is ReversedReaderFragment -> ReaderMode.REVERSED
- else -> {
- showWaitWhileLoading()
- return false
- }
- }
- )
+ val currentMode = readerManager.currentMode ?: return false
+ ReaderConfigDialog.show(supportFragmentManager, currentMode)
}
R.id.action_settings -> {
startActivity(SettingsActivity.newReaderSettingsIntent(this))
@@ -177,17 +162,24 @@ class ReaderActivity :
supportFragmentManager,
pages,
title?.toString().orEmpty(),
- reader?.getCurrentState()?.page ?: -1
+ readerManager.currentReader?.getCurrentState()?.page ?: -1,
)
} else {
- showWaitWhileLoading()
+ return false
}
}
R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page ->
- viewModel.saveCurrentState(reader?.getCurrentState())
+ viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.saveCurrentPage(page, savePageRequest)
- } ?: showWaitWhileLoading()
+ } ?: return false
+ }
+ R.id.action_bookmark -> {
+ if (viewModel.isBookmarkAdded.value == true) {
+ viewModel.removeBookmark()
+ } else {
+ viewModel.addBookmark()
+ }
}
else -> return super.onOptionsItemSelected(item)
}
@@ -202,10 +194,14 @@ class ReaderActivity :
val hasPages = !viewModel.content.value?.pages.isNullOrEmpty()
binding.layoutLoading.isVisible = isLoading && !hasPages
if (isLoading && hasPages) {
- binding.toastView.show(R.string.loading_, true)
+ binding.toastView.show(R.string.loading_)
} else {
binding.toastView.hide()
}
+ val menu = binding.toolbarBottom.menu
+ menu.findItem(R.id.action_bookmark).isVisible = hasPages
+ menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
+ menu.findItem(R.id.action_save_page).isVisible = hasPages
}
private fun onError(e: Throwable) {
@@ -265,14 +261,14 @@ class ReaderActivity :
val index = pages.indexOfFirst { it.id == page.id }
if (index != -1) {
withContext(Dispatchers.Main) {
- reader?.switchPageTo(index, true)
+ readerManager.currentReader?.switchPageTo(index, true)
}
}
}
}
override fun onReaderModeChanged(mode: ReaderMode) {
- viewModel.saveCurrentState(reader?.getCurrentState())
+ viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.switchMode(mode)
}
@@ -290,12 +286,6 @@ class ReaderActivity :
}
}
- private fun showWaitWhileLoading() {
- Toast.makeText(this, R.string.wait_for_loading_finish, Toast.LENGTH_SHORT).apply {
- setGravity(Gravity.CENTER, 0, 0)
- }.show()
- }
-
private fun setWindowSecure(isSecure: Boolean) {
if (isSecure) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
@@ -309,8 +299,8 @@ class ReaderActivity :
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
.addTransition(Slide(Gravity.TOP).addTarget(binding.appbarTop))
- binding.appbarBottom?.let { botomBar ->
- transition.addTransition(Slide(Gravity.BOTTOM).addTarget(botomBar))
+ binding.appbarBottom?.let { bottomBar ->
+ transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar))
}
TransitionManager.beginDelayedTransition(binding.root, transition)
binding.appbarTop.isVisible = isUiVisible
@@ -344,13 +334,19 @@ class ReaderActivity :
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun switchPageBy(delta: Int) {
- reader?.switchPageBy(delta)
+ readerManager.currentReader?.switchPageBy(delta)
}
override fun toggleUiVisibility() {
setUiIsVisible(!binding.appbarTop.isVisible)
}
+ private fun onBookmarkStateChanged(isAdded: Boolean) {
+ val menuItem = binding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return
+ menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add)
+ menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
+ }
+
private fun onUiStateChanged(uiState: ReaderUiState, previous: ReaderUiState?) {
title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_)
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
@@ -419,6 +415,11 @@ class ReaderActivity :
.putExtra(EXTRA_STATE, state)
}
+ fun newIntent(context: Context, bookmark: Bookmark): Intent {
+ val state = ReaderState(bookmark.chapterId, bookmark.page, bookmark.scroll)
+ return newIntent(context, bookmark.manga, state)
+ }
+
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, ReaderActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
index f8c5d73c0..dbe853894 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt
@@ -5,14 +5,16 @@ import android.view.SoundEffectConstants
import android.view.View
import androidx.lifecycle.LifecycleCoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.utils.GridTouchHelper
-@Suppress("UNUSED_PARAMETER")
class ReaderControlDelegate(
- private val scope: LifecycleCoroutineScope,
- private val settings: AppSettings,
+ scope: LifecycleCoroutineScope,
+ settings: AppSettings,
private val listener: OnInteractionListener
) {
@@ -20,12 +22,8 @@ class ReaderControlDelegate(
private var isVolumeKeysSwitchEnabled: Boolean = false
init {
- settings.observe()
- .filter { it == AppSettings.KEY_READER_SWITCHERS }
- .map { settings.readerPageSwitch }
- .onStart { emit(settings.readerPageSwitch) }
- .distinctUntilChanged()
- .flowOn(Dispatchers.IO)
+ settings.observeAsFlow(AppSettings.KEY_READER_SWITCHERS) { readerPageSwitch }
+ .flowOn(Dispatchers.Default)
.onEach {
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in it
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in it
@@ -57,7 +55,7 @@ class ReaderControlDelegate(
}
}
- fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
+ fun onKeyDown(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean = when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> if (isVolumeKeysSwitchEnabled) {
listener.switchPageBy(-1)
true
@@ -92,9 +90,11 @@ class ReaderControlDelegate(
else -> false
}
- fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
- return (isVolumeKeysSwitchEnabled &&
- (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP))
+ fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean {
+ return (
+ isVolumeKeysSwitchEnabled &&
+ (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP)
+ )
}
interface OnInteractionListener {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt
new file mode 100644
index 000000000..c5497fe8a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderManager.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.reader.ui
+
+import androidx.annotation.IdRes
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.commit
+import org.koitharu.kotatsu.core.prefs.ReaderMode
+import org.koitharu.kotatsu.reader.ui.pager.BaseReader
+import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
+import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
+import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
+import java.util.*
+
+class ReaderManager(
+ private val fragmentManager: FragmentManager,
+ @IdRes private val containerResId: Int,
+) {
+
+ private val modeMap = EnumMap>>(ReaderMode::class.java)
+
+ init {
+ modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java
+ modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java
+ modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
+ }
+
+ val currentReader: BaseReader<*>?
+ get() = fragmentManager.findFragmentById(containerResId) as? BaseReader<*>
+
+ val currentMode: ReaderMode?
+ get() {
+ val readerClass = currentReader?.javaClass ?: return null
+ return modeMap.entries.find { it.value == readerClass }?.key
+ }
+
+ fun replace(newMode: ReaderMode) {
+ val readerClass = requireNotNull(modeMap[newMode])
+ fragmentManager.commit {
+ replace(containerResId, readerClass, null, null)
+ }
+ }
+
+ fun replace(reader: BaseReader<*>) {
+ fragmentManager.commit { replace(containerResId, reader) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt
index 0fe72b499..2eb7cc960 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderState.kt
@@ -9,23 +9,20 @@ import org.koitharu.kotatsu.parsers.model.Manga
data class ReaderState(
val chapterId: Long,
val page: Int,
- val scroll: Int
+ val scroll: Int,
) : Parcelable {
- companion object {
+ constructor(history: MangaHistory) : this(
+ chapterId = history.chapterId,
+ page = history.page,
+ scroll = history.scroll,
+ )
- fun from(history: MangaHistory) = ReaderState(
- chapterId = history.chapterId,
- page = history.page,
- scroll = history.scroll
- )
-
- fun initial(manga: Manga, branch: String?) = ReaderState(
- chapterId = manga.chapters?.firstOrNull {
- it.branch == branch
- }?.id ?: error("Cannot find first chapter"),
- page = 0,
- scroll = 0
- )
- }
+ constructor(manga: Manga, branch: String?) : this(
+ chapterId = manga.chapters?.firstOrNull {
+ it.branch == branch
+ }?.id ?: error("Cannot find first chapter"),
+ page = 0,
+ scroll = 0,
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt
index f9852f4c6..a2acc8df7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt
@@ -1,13 +1,11 @@
package org.koitharu.kotatsu.reader.ui
import android.content.Context
-import android.graphics.Color
import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.core.view.isVisible
-import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.transition.Fade
import androidx.transition.Slide
import androidx.transition.TransitionManager
@@ -15,26 +13,28 @@ import androidx.transition.TransitionSet
import com.google.android.material.textview.MaterialTextView
class ReaderToastView @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
) : MaterialTextView(context, attrs, defStyleAttr) {
private var hideRunnable = Runnable {
hide()
}
- fun show(message: CharSequence, isLoading: Boolean) {
+ fun show(message: CharSequence) {
removeCallbacks(hideRunnable)
text = message
setupTransition()
isVisible = true
}
- fun show(@StringRes messageId: Int, isLoading: Boolean) {
- show(context.getString(messageId), isLoading)
+ fun show(@StringRes messageId: Int) {
+ show(context.getString(messageId))
}
fun showTemporary(message: CharSequence, duration: Long) {
- show(message, false)
+ show(message)
postDelayed(hideRunnable, duration)
}
@@ -49,7 +49,7 @@ class ReaderToastView @JvmOverloads constructor(
super.onDetachedFromWindow()
}
- private fun setupTransition () {
+ private fun setupTransition() {
val parentView = parent as? ViewGroup ?: return
val transition = TransitionSet()
.setOrdering(TransitionSet.ORDERING_TOGETHER)
@@ -58,14 +58,4 @@ class ReaderToastView @JvmOverloads constructor(
.addTransition(Fade())
TransitionManager.beginDelayedTransition(parentView, transition)
}
-
- // FIXME use it as compound drawable
- private fun createProgressDrawable(): CircularProgressDrawable {
- val drawable = CircularProgressDrawable(context)
- drawable.setStyle(CircularProgressDrawable.DEFAULT)
- drawable.arrowEnabled = false
- drawable.setColorSchemeColors(Color.WHITE)
- drawable.centerRadius = lineHeight / 3f
- return drawable
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
index bfe5ac663..931461de0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
@@ -3,35 +3,40 @@ package org.koitharu.kotatsu.reader.ui
import android.net.Uri
import android.util.LongSparseArray
import androidx.activity.result.ActivityResultLauncher
+import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
+import java.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
-import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.domain.MangaUtils
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
-import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.prefs.ReaderMode
-import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
+import org.koitharu.kotatsu.core.prefs.*
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.reader.data.filterChapters
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.SingleLiveEvent
-import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
+private const val BOUNDS_PAGE_OFFSET = 2
+private const val PAGES_TRIM_THRESHOLD = 120
+private const val PREFETCH_LIMIT = 10
+
class ReaderViewModel(
private val intent: MangaIntent,
initialState: ReaderState?,
@@ -39,12 +44,14 @@ class ReaderViewModel(
private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
+ private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val pageSaveHelper: PageSaveHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
private var pageSaveJob: Job? = null
+ private var bookmarkJob: Job? = null
private val currentState = MutableStateFlow(initialState)
private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray()
@@ -53,6 +60,7 @@ class ReaderViewModel(
val readerMode = MutableLiveData()
val onPageSaved = SingleLiveEvent()
+ val onShowToast = SingleLiveEvent()
val uiState = combine(
mangaData,
currentState,
@@ -70,25 +78,32 @@ class ReaderViewModel(
val manga: Manga?
get() = mangaData.value
- val readerAnimation = settings.observe()
- .filter { it == AppSettings.KEY_READER_ANIMATION }
- .map { settings.readerAnimation }
- .onStart { emit(settings.readerAnimation) }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO)
+ val readerAnimation = settings.observeAsLiveData(
+ context = viewModelScope.coroutineContext + Dispatchers.Default,
+ key = AppSettings.KEY_READER_ANIMATION,
+ valueProducer = { readerAnimation }
+ )
val isScreenshotsBlockEnabled = combine(
mangaData,
- settings.observe()
- .filter { it == AppSettings.KEY_SCREENSHOTS_POLICY }
- .onStart { emit("") }
- .map { settings.screenshotsPolicy },
+ settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
) { manga, policy ->
policy == ScreenshotsPolicy.BLOCK_ALL ||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.IO)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val onZoomChanged = SingleLiveEvent()
+ val isBookmarkAdded: LiveData = currentState.flatMapLatest { state ->
+ val manga = mangaData.value
+ if (state == null || manga == null) {
+ flowOf(false)
+ } else {
+ bookmarksRepository.observeBookmark(manga, state.chapterId, state.page)
+ .map { it != null }
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+
init {
loadImpl()
subscribeToSettings()
@@ -124,7 +139,7 @@ class ReaderViewModel(
if (state != null) {
currentState.value = state
}
- saveState(
+ historyRepository.saveStateAsync(
mangaData.value ?: return,
state ?: currentState.value ?: return
)
@@ -151,9 +166,7 @@ class ReaderViewModel(
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
+ e.printStackTraceDebug()
onPageSaved.postCall(null)
}
}
@@ -187,10 +200,9 @@ class ReaderViewModel(
fun onCurrentPageChanged(position: Int) {
val pages = content.value?.pages ?: return
- pages.getOrNull(position)?.let {
- val currentValue = currentState.value
- if (currentValue != null && currentValue.chapterId != it.chapterId) {
- currentState.value = currentValue.copy(chapterId = it.chapterId)
+ pages.getOrNull(position)?.let { page ->
+ currentState.update { cs ->
+ cs?.copy(chapterId = page.chapterId, page = page.index)
}
}
if (pages.isEmpty() || loadingJob?.isActive == true) {
@@ -207,6 +219,41 @@ class ReaderViewModel(
}
}
+ fun addBookmark() {
+ if (bookmarkJob?.isActive == true) {
+ return
+ }
+ bookmarkJob = launchJob {
+ loadingJob?.join()
+ val state = checkNotNull(currentState.value)
+ val page = checkNotNull(getCurrentPage()) { "Page not found" }
+ val bookmark = Bookmark(
+ manga = checkNotNull(mangaData.value),
+ pageId = page.id,
+ chapterId = state.chapterId,
+ page = state.page,
+ scroll = state.scroll,
+ imageUrl = page.preview ?: pageLoader.getPageUrl(page),
+ createdAt = Date(),
+ )
+ bookmarksRepository.addBookmark(bookmark)
+ onShowToast.call(R.string.bookmark_added)
+ }
+ }
+
+ fun removeBookmark() {
+ if (bookmarkJob?.isActive == true) {
+ return
+ }
+ bookmarkJob = launchJob {
+ loadingJob?.join()
+ val manga = checkNotNull(mangaData.value)
+ val page = checkNotNull(getCurrentPage()) { "Page not found" }
+ bookmarksRepository.removeBookmark(manga.id, page.id)
+ onShowToast.call(R.string.bookmark_removed)
+ }
+ }
+
private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga")
@@ -217,24 +264,16 @@ class ReaderViewModel(
chapters.put(it.id, it)
}
// determine mode
- val mode = dataRepository.getReaderMode(manga.id) ?: manga.chapters?.randomOrNull()?.let {
- val pages = repo.getPages(it)
- val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
- val newMode = getReaderMode(isWebtoon)
- if (isWebtoon != null) {
- dataRepository.savePreferences(manga, newMode)
- }
- newMode
- } ?: error("There are no chapters in this manga")
+ val mode = detectReaderMode(manga, repo)
// obtain state
if (currentState.value == null) {
currentState.value = historyRepository.getOne(manga)?.let {
- ReaderState.from(it)
- } ?: ReaderState.initial(manga, preselectedBranch)
+ ReaderState(it)
+ } ?: ReaderState(manga, preselectedBranch)
}
val branch = chapters[currentState.value?.chapterId ?: 0L].branch
- mangaData.value = manga.copy(chapters = manga.chapters?.filter { it.branch == branch })
+ mangaData.value = manga.filterChapters(branch)
readerMode.postValue(mode)
val pages = loadChapter(requireNotNull(currentState.value).chapterId)
@@ -248,18 +287,12 @@ class ReaderViewModel(
}
}
- private fun getReaderMode(isWebtoon: Boolean?) = when {
- isWebtoon == true -> ReaderMode.WEBTOON
- settings.isPreferRtlReader -> ReaderMode.REVERSED
- else -> ReaderMode.STANDARD
- }
-
private suspend fun loadChapter(chapterId: Long): List {
val manga = checkNotNull(mangaData.value) { "Manga is null" }
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
val repo = MangaRepository(manga.source)
return repo.getPages(chapter).mapIndexed { index, page ->
- ReaderPage.from(page, index, chapterId)
+ ReaderPage(page, index, chapterId)
}
}
@@ -297,9 +330,9 @@ class ReaderViewModel(
private fun subscribeToSettings() {
settings.observe()
- .filter { it == AppSettings.KEY_ZOOM_MODE }
- .onEach { onZoomChanged.postCall(Unit) }
- .launchIn(viewModelScope + Dispatchers.IO)
+ .onEach { key ->
+ if (key == AppSettings.KEY_ZOOM_MODE) onZoomChanged.postCall(Unit)
+ }.launchIn(viewModelScope + Dispatchers.Default)
}
private fun List.trySublist(fromIndex: Int, toIndex: Int): List {
@@ -312,39 +345,42 @@ class ReaderViewModel(
}
}
- private fun Manga.copy(chapters: List?) = Manga(
- id = id,
- title = title,
- altTitle = altTitle,
- url = url,
- publicUrl = publicUrl,
- rating = rating,
- isNsfw = isNsfw,
- coverUrl = coverUrl,
- tags = tags,
- state = state,
- author = author,
- largeCoverUrl = largeCoverUrl,
- description = description,
- chapters = chapters,
- source = source,
- )
+ private suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode {
+ dataRepository.getReaderMode(manga.id)?.let { return it }
+ val defaultMode = settings.defaultReaderMode
+ if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
+ return defaultMode
+ }
+ val chapter = currentState.value?.chapterId?.let(chapters::get)
+ ?: manga.chapters?.randomOrNull()
+ ?: error("There are no chapters in this manga")
+ val pages = repo.getPages(chapter)
+ return runCatching {
+ val isWebtoon = MangaUtils.determineMangaIsWebtoon(pages)
+ if (isWebtoon) ReaderMode.WEBTOON else defaultMode
+ }.onSuccess {
+ dataRepository.savePreferences(manga, it)
+ }.onFailure {
+ it.printStackTraceDebug()
+ }.getOrDefault(defaultMode)
+ }
+}
- private companion object : KoinComponent {
-
- const val BOUNDS_PAGE_OFFSET = 2
- const val PAGES_TRIM_THRESHOLD = 120
- const val PREFETCH_LIMIT = 10
-
- fun saveState(manga: Manga, state: ReaderState) {
- processLifecycleScope.launch(Dispatchers.Default + IgnoreErrors) {
- get().addOrUpdate(
- manga = manga,
- chapterId = state.chapterId,
- page = state.page,
- scroll = state.scroll
- )
- }
+/**
+ * This function is not a member of the ReaderViewModel
+ * because it should work independently of the ViewModel's lifecycle.
+ */
+private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState): Job {
+ return processLifecycleScope.launch(Dispatchers.Default) {
+ runCatching {
+ addOrUpdate(
+ manga = manga,
+ chapterId = state.chapterId,
+ page = state.page,
+ scroll = state.scroll
+ )
+ }.onFailure {
+ it.printStackTraceDebug()
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt
index bc3f3220e..6773f5dc3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/ReaderPage.kt
@@ -13,27 +13,24 @@ data class ReaderPage(
val preview: String?,
val chapterId: Long,
val index: Int,
- val source: MangaSource
+ val source: MangaSource,
) : Parcelable {
+ constructor(page: MangaPage, index: Int, chapterId: Long) : this(
+ id = page.id,
+ url = page.url,
+ referer = page.referer,
+ preview = page.preview,
+ chapterId = chapterId,
+ index = index,
+ source = page.source,
+ )
+
fun toMangaPage() = MangaPage(
id = id,
url = url,
referer = referer,
preview = preview,
- source = source
+ source = source,
)
-
- companion object {
-
- fun from(page: MangaPage, index: Int, chapterId: Long) = ReaderPage(
- id = page.id,
- url = page.url,
- referer = page.referer,
- preview = page.preview,
- chapterId = chapterId,
- index = index,
- source = page.source
- )
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
index 16a3b16a6..6dcd26b0f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt
@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.remotelist.ui
+import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
+import android.view.View
import androidx.appcompat.view.ActionMode
+import androidx.core.view.MenuProvider
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
@@ -11,6 +14,7 @@ import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.SettingsActivity
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -22,31 +26,15 @@ class RemoteListFragment : MangaListFragment() {
private val source by serializableArgument(ARG_SOURCE)
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ addMenuProvider(RemoteListMenuProvider())
+ }
+
override fun onScrolledToEnd() {
viewModel.loadNextPage()
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_list_remote, menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_source_settings -> {
- startActivity(
- SettingsActivity.newSourceSettingsIntent(context ?: return false, source)
- )
- true
- }
- R.id.action_filter -> {
- onFilterClick()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
@@ -60,6 +48,25 @@ class RemoteListFragment : MangaListFragment() {
viewModel.resetFilter()
}
+ private inner class RemoteListMenuProvider: MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_list_remote, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_source_settings -> {
+ startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
+ true
+ }
+ R.id.action_filter -> {
+ onFilterClick()
+ true
+ }
+ else -> false
+ }
+ }
+
companion object {
private const val ARG_SOURCE = "provider"
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
index b2a540baa..b57f0b30b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
@@ -6,7 +6,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.*
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
@@ -21,6 +20,7 @@ import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
private const val FILTER_MIN_INTERVAL = 750L
@@ -133,12 +133,10 @@ class RemoteListViewModel(
}
hasNextPage.value = list.isNotEmpty()
} catch (e: Throwable) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
+ e.printStackTraceDebug()
listError.value = e
if (!mangaList.value.isNullOrEmpty()) {
- onError.postCall(e)
+ errorEvent.postCall(e)
}
}
}
@@ -158,4 +156,4 @@ class RemoteListViewModel(
textSecondary = 0,
actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,
)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
index 1d1fb43fc..b06e06bfd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.search.ui.SearchViewModel
-import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
val searchModule
@@ -16,11 +16,7 @@ val searchModule
factory { MangaSearchRepository(get(), get(), androidContext(), get()) }
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
- viewModel { params ->
- SearchViewModel(MangaRepository(params[0]), params[1], get())
- }
- viewModel { query ->
- GlobalSearchViewModel(query.get(), get(), get())
- }
+ viewModel { params -> SearchViewModel(MangaRepository(params[0]), params[1], get()) }
viewModel { SearchSuggestionViewModel(get(), get()) }
+ viewModel { params -> MultiSearchViewModel(params[0], get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
index 270238553..2c95fb632 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt
@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
@@ -35,7 +34,6 @@ class MangaSearchRepository(
MangaRepository(source).getList(
offset = 0,
query = query,
- sortOrder = SortOrder.POPULARITY
)
}.getOrElse {
emptyList()
@@ -141,4 +139,4 @@ class MangaSearchRepository(
return false
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
index 151bb2b33..4904ab34a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt
@@ -14,16 +14,16 @@ import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
-import org.koitharu.kotatsu.databinding.ActivitySearchGlobalBinding
+import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
-class MangaListActivity : BaseActivity() {
+class MangaListActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(ActivitySearchGlobalBinding.inflate(layoutInflater))
+ setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtra(EXTRA_TAGS)?.tags ?: run {
finishAfterTransition()
return
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt
deleted file mode 100644
index ad23f0b98..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package org.koitharu.kotatsu.search.ui.global
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.view.ViewGroup
-import androidx.core.graphics.Insets
-import androidx.core.view.updateLayoutParams
-import androidx.core.view.updatePadding
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.base.ui.BaseActivity
-import org.koitharu.kotatsu.databinding.ActivitySearchGlobalBinding
-
-class GlobalSearchActivity : BaseActivity() {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(ActivitySearchGlobalBinding.inflate(layoutInflater))
- val query = intent.getStringExtra(EXTRA_QUERY)
-
- if (query == null) {
- finishAfterTransition()
- return
- }
-
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title = query
- supportActionBar?.subtitle = getString(R.string.search_results)
- supportFragmentManager
- .beginTransaction()
- .replace(R.id.container, GlobalSearchFragment.newInstance(query))
- .commit()
- }
-
- override fun onWindowInsetsChanged(insets: Insets) {
- with(binding.toolbar) {
- updatePadding(
- left = insets.left,
- right = insets.right
- )
- updateLayoutParams {
- topMargin = insets.top
- }
- }
- }
-
- companion object {
-
- private const val EXTRA_QUERY = "query"
-
- fun newIntent(context: Context, query: String) =
- Intent(context, GlobalSearchActivity::class.java)
- .putExtra(EXTRA_QUERY, query)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt
deleted file mode 100644
index 185de3d25..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.koitharu.kotatsu.search.ui.global
-
-import android.view.Menu
-import androidx.appcompat.view.ActionMode
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import org.koin.core.parameter.parametersOf
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.list.ui.MangaListFragment
-import org.koitharu.kotatsu.utils.ext.stringArgument
-import org.koitharu.kotatsu.utils.ext.withArgs
-
-class GlobalSearchFragment : MangaListFragment() {
-
- override val viewModel by viewModel {
- parametersOf(query)
- }
-
- private val query by stringArgument(ARG_QUERY)
-
- override fun onScrolledToEnd() = Unit
-
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- mode.menuInflater.inflate(R.menu.mode_remote, menu)
- return super.onCreateActionMode(mode, menu)
- }
-
- companion object {
-
- private const val ARG_QUERY = "query"
-
- fun newInstance(query: String) = GlobalSearchFragment().withArgs(1) {
- putString(ARG_QUERY, query)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt
deleted file mode 100644
index b516a026a..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package org.koitharu.kotatsu.search.ui.global
-
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.plus
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.list.ui.MangaListViewModel
-import org.koitharu.kotatsu.list.ui.model.*
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.search.domain.MangaSearchRepository
-import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
-import org.koitharu.kotatsu.utils.ext.onFirst
-
-class GlobalSearchViewModel(
- private val query: String,
- private val repository: MangaSearchRepository,
- settings: AppSettings
-) : MangaListViewModel(settings) {
-
- private val mangaList = MutableStateFlow?>(null)
- private val hasNextPage = MutableStateFlow(false)
- private val listError = MutableStateFlow(null)
- private var searchJob: Job? = null
-
- override val content = combine(
- mangaList,
- createListModeFlow(),
- listError,
- hasNextPage
- ) { list, mode, error, hasNext ->
- when {
- list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
- list == null -> listOf(LoadingState)
- list.isEmpty() -> listOf(
- EmptyState(
- icon = R.drawable.ic_book_search,
- textPrimary = R.string.nothing_found,
- textSecondary = R.string.text_search_holder_secondary,
- actionStringRes = 0,
- )
- )
- else -> {
- val result = ArrayList(list.size + 1)
- list.toUi(result, mode)
- when {
- error != null -> result += error.toErrorFooter()
- hasNext -> result += LoadingFooter
- }
- result
- }
- }
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
-
- init {
- onRefresh()
- }
-
- override fun onRetry() {
- onRefresh()
- }
-
- override fun onRefresh() {
- searchJob?.cancel()
- searchJob = repository.globalSearch(query)
- .catch { e ->
- listError.value = e
- isLoading.postValue(false)
- }.onStart {
- mangaList.value = null
- listError.value = null
- isLoading.postValue(true)
- hasNextPage.value = true
- }.onEmpty {
- mangaList.value = emptyList()
- }.onCompletion {
- isLoading.postValue(false)
- hasNextPage.value = false
- }.onFirst {
- isLoading.postValue(false)
- }.onEach {
- mangaList.value = mangaList.value?.plus(it) ?: listOf(it)
- }.launchIn(viewModelScope + Dispatchers.Default)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt
new file mode 100644
index 000000000..5bd119a83
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt
@@ -0,0 +1,183 @@
+package org.koitharu.kotatsu.search.ui.multi
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.view.ActionMode
+import androidx.core.graphics.Insets
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.recyclerview.widget.RecyclerView
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.search.ui.SearchActivity
+import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
+import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter
+import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.utils.ext.findViewsByType
+
+class MultiSearchActivity : BaseActivity(), MangaListListener, ActionMode.Callback {
+
+ private val viewModel by viewModel {
+ parametersOf(intent.getStringExtra(EXTRA_QUERY).orEmpty())
+ }
+ private lateinit var adapter: MultiSearchAdapter
+ private lateinit var selectionDecoration: MangaSelectionDecoration
+ private var actionMode: ActionMode? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivitySearchMultiBinding.inflate(layoutInflater))
+
+ val itemCLickListener = object : OnListItemClickListener {
+ override fun onItemClick(item: MultiSearchListModel, view: View) {
+ startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value))
+ }
+ }
+ val sizeResolver = ItemSizeResolver(resources, get())
+ selectionDecoration = MangaSelectionDecoration(this)
+ adapter = MultiSearchAdapter(
+ lifecycleOwner = this,
+ coil = get(),
+ listener = this,
+ itemClickListener = itemCLickListener,
+ sizeResolver = sizeResolver,
+ selectionDecoration = selectionDecoration,
+ )
+ binding.recyclerView.adapter = adapter
+ binding.recyclerView.setHasFixedSize(true)
+
+ supportActionBar?.run {
+ setDisplayHomeAsUpEnabled(true)
+ setSubtitle(R.string.search_results)
+ }
+
+ viewModel.query.observe(this) { title = it }
+ viewModel.list.observe(this) { adapter.items = it }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ with(binding.toolbar) {
+ updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ updateLayoutParams {
+ topMargin = insets.top
+ }
+ }
+ binding.recyclerView.updatePadding(
+ bottom = insets.bottom,
+ left = insets.left,
+ right = insets.right,
+ )
+ }
+
+ override fun onItemClick(item: Manga, view: View) {
+ if (selectionDecoration.checkedItemsCount != 0) {
+ selectionDecoration.toggleItemChecked(item.id)
+ if (selectionDecoration.checkedItemsCount == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ invalidateItemDecorations()
+ }
+ return
+ }
+ val intent = DetailsActivity.newIntent(this, item)
+ startActivity(intent)
+ }
+
+ override fun onItemLongClick(item: Manga, view: View): Boolean {
+ if (actionMode == null) {
+ actionMode = startSupportActionMode(this)
+ }
+ return actionMode?.also {
+ selectionDecoration.setItemIsChecked(item.id, true)
+ invalidateItemDecorations()
+ it.invalidate()
+ } != null
+ }
+
+ override fun onRetryClick(error: Throwable) {
+ viewModel.doSearch(viewModel.query.value.orEmpty())
+ }
+
+ override fun onTagRemoveClick(tag: MangaTag) = Unit
+
+ override fun onFilterClick() = Unit
+
+ override fun onEmptyActionClick() = Unit
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_remote, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.title = selectionDecoration.checkedItemsCount.toString()
+ return true
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_share -> {
+ ShareHelper(this).shareMangaLinks(collectSelectedItems())
+ mode.finish()
+ true
+ }
+ R.id.action_favourite -> {
+ FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems())
+ mode.finish()
+ true
+ }
+ R.id.action_save -> {
+ DownloadService.confirmAndStart(this, collectSelectedItems())
+ mode.finish()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ selectionDecoration.clearSelection()
+ invalidateItemDecorations()
+ actionMode = null
+ }
+
+ private fun collectSelectedItems(): Set {
+ return viewModel.getItems(selectionDecoration.checkedItemsIds)
+ }
+
+ private fun invalidateItemDecorations() {
+ binding.recyclerView.findViewsByType(RecyclerView::class.java).forEach {
+ it.invalidateItemDecorations()
+ }
+ }
+
+ companion object {
+
+ private const val EXTRA_QUERY = "query"
+
+ fun newIntent(context: Context, query: String) =
+ Intent(context, MultiSearchActivity::class.java)
+ .putExtra(EXTRA_QUERY, query)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt
new file mode 100644
index 000000000..eb8d71a7e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt
@@ -0,0 +1,29 @@
+package org.koitharu.kotatsu.search.ui.multi
+
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.list.ui.model.MangaItemModel
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+class MultiSearchListModel(
+ val source: MangaSource,
+ val list: List,
+) : ListModel {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as MultiSearchListModel
+
+ if (source != other.source) return false
+ if (list != other.list) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = source.hashCode()
+ result = 31 * result + list.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
new file mode 100644
index 000000000..adea07898
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt
@@ -0,0 +1,131 @@
+package org.koitharu.kotatsu.search.ui.multi
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.exceptions.CompositeException
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.list.ui.model.*
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+
+private const val MAX_PARALLELISM = 4
+
+class MultiSearchViewModel(
+ initialQuery: String,
+ private val settings: AppSettings,
+) : BaseViewModel() {
+
+ private var searchJob: Job? = null
+ private val listData = MutableStateFlow>(emptyList())
+ private val loadingData = MutableStateFlow(false)
+ private var listError = MutableStateFlow(null)
+
+ val query = MutableLiveData(initialQuery)
+ val list: LiveData> = combine(
+ listData,
+ loadingData,
+ listError,
+ ) { list, loading, error ->
+ when {
+ list.isEmpty() -> listOf(
+ when {
+ loading -> LoadingState
+ error != null -> error.toErrorState(canRetry = true)
+ else -> EmptyState(
+ icon = R.drawable.ic_book_search,
+ textPrimary = R.string.nothing_found,
+ textSecondary = R.string.text_search_holder_secondary,
+ actionStringRes = 0,
+ )
+ }
+ )
+ loading -> list + LoadingFooter
+ else -> list
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+
+ init {
+ doSearch(initialQuery)
+ }
+
+ fun getItems(ids: Set): Set {
+ val result = HashSet(ids.size)
+ listData.value.forEach { x ->
+ for (item in x.list) {
+ if (item.id in ids) {
+ result.add(item.manga)
+ }
+ }
+ }
+ return result
+ }
+
+ fun doSearch(q: String) {
+ val prevJob = searchJob
+ searchJob = launchJob(Dispatchers.Default) {
+ prevJob?.cancelAndJoin()
+ try {
+ listError.value = null
+ listData.value = emptyList()
+ loadingData.value = true
+ query.postValue(q)
+ searchImpl(q)
+ } catch (e: Throwable) {
+ listError.value = e
+ } finally {
+ loadingData.value = false
+ }
+ }
+ }
+
+ private suspend fun searchImpl(q: String) {
+ val sources = settings.getMangaSources(includeHidden = false)
+ val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
+ val deferredList = coroutineScope {
+ sources.map { source ->
+ async(dispatcher) {
+ runCatching {
+ val list = MangaRepository(source).getList(offset = 0, query = q)
+ .toUi(ListMode.GRID)
+ if (list.isNotEmpty()) {
+ MultiSearchListModel(source, list)
+ } else {
+ null
+ }
+ }.onFailure {
+ it.printStackTraceDebug()
+ }
+ }
+ }
+ }
+ val errors = ArrayList()
+ for (deferred in deferredList) {
+ deferred.await()
+ .onSuccess { item ->
+ if (item != null) {
+ listData.update { x -> x + item }
+ }
+ }.onFailure {
+ errors.add(it)
+ }
+ }
+ if (listData.value.isNotEmpty()) {
+ return
+ }
+ when (errors.size) {
+ 0 -> Unit
+ 1 -> throw errors[0]
+ else -> throw CompositeException(errors)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt
new file mode 100644
index 000000000..a5f5d3f72
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt
@@ -0,0 +1,15 @@
+package org.koitharu.kotatsu.search.ui.multi.adapter
+
+import android.content.res.Resources
+import kotlin.math.roundToInt
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.prefs.AppSettings
+
+class ItemSizeResolver(resources: Resources, settings: AppSettings) {
+
+ private val scaleFactor = settings.gridSize / 100f
+ private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
+
+ val cellWidth: Int
+ get() = (gridWidth * scaleFactor).roundToInt()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt
new file mode 100644
index 000000000..35afb49d4
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt
@@ -0,0 +1,59 @@
+package org.koitharu.kotatsu.search.ui.multi.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.list.ui.adapter.*
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
+import kotlin.jvm.internal.Intrinsics
+
+class MultiSearchAdapter(
+ lifecycleOwner: LifecycleOwner,
+ coil: ImageLoader,
+ listener: MangaListListener,
+ itemClickListener: OnListItemClickListener,
+ sizeResolver: ItemSizeResolver,
+ selectionDecoration: MangaSelectionDecoration,
+) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+
+ init {
+ val pool = RecycledViewPool()
+ delegatesManager
+ .addDelegate(
+ searchResultsAD(
+ sharedPool = pool,
+ lifecycleOwner = lifecycleOwner,
+ coil = coil,
+ sizeResolver = sizeResolver,
+ selectionDecoration = selectionDecoration,
+ listener = listener,
+ itemClickListener = itemClickListener,
+ )
+ )
+ .addDelegate(loadingStateAD())
+ .addDelegate(loadingFooterAD())
+ .addDelegate(emptyStateListAD(listener))
+ .addDelegate(errorStateListAD(listener))
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+
+ override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return when {
+ oldItem is MultiSearchListModel && newItem is MultiSearchListModel -> {
+ oldItem.source == newItem.source
+ }
+ else -> oldItem.javaClass == newItem.javaClass
+ }
+ }
+
+ override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return Intrinsics.areEqual(oldItem, newItem)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt
new file mode 100644
index 000000000..ee58933e8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt
@@ -0,0 +1,47 @@
+package org.koitharu.kotatsu.search.ui.multi.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
+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.base.ui.list.decor.SpacingItemDecoration
+import org.koitharu.kotatsu.databinding.ItemListGroupBinding
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
+
+fun searchResultsAD(
+ sharedPool: RecycledViewPool,
+ lifecycleOwner: LifecycleOwner,
+ coil: ImageLoader,
+ sizeResolver: ItemSizeResolver,
+ selectionDecoration: MangaSelectionDecoration,
+ listener: OnListItemClickListener,
+ itemClickListener: OnListItemClickListener,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }
+) {
+
+ binding.recyclerView.setRecycledViewPool(sharedPool)
+ val adapter = ListDelegationAdapter(
+ mangaGridItemAD(coil, lifecycleOwner, listener, sizeResolver)
+ )
+ binding.recyclerView.addItemDecoration(selectionDecoration)
+ binding.recyclerView.adapter = adapter
+ val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing)
+ binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
+ val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
+ itemView.setOnClickListener(eventListener)
+
+ bind {
+ binding.textViewTitle.text = item.source.title
+ adapter.items = item.list
+ adapter.notifyDataSetChanged()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt
index 9a1ffba8f..7d9a3b6cb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt
@@ -9,6 +9,7 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
import org.koitharu.kotatsu.main.ui.AppBarOwner
@@ -43,11 +44,10 @@ class SearchSuggestionFragment :
override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
+ val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing)
binding.root.updatePadding(
- top = headerHeight,
- // left = insets.left,
- // right = insets.right,
- bottom = insets.bottom,
+ top = headerHeight + extraPadding,
+ bottom = insets.bottom + extraPadding,
)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
index 5b7bc661e..a9e2ab345 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
@@ -8,15 +8,6 @@ import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import java.io.ByteArrayInputStream
-import java.io.InputStream
-import java.security.MessageDigest
-import java.security.NoSuchAlgorithmException
-import java.security.cert.CertificateEncodingException
-import java.security.cert.CertificateException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
-import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
@@ -28,6 +19,16 @@ import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import java.io.ByteArrayInputStream
+import java.io.InputStream
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+import java.security.cert.CertificateEncodingException
+import java.security.cert.CertificateException
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.util.concurrent.TimeUnit
class AppUpdateChecker(private val activity: ComponentActivity) {
@@ -45,8 +46,8 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
suspend fun checkNow() = runCatching {
val version = repo.getLatestVersion()
- val newVersionId = VersionId.parse(version.name)
- val currentVersionId = VersionId.parse(BuildConfig.VERSION_NAME)
+ val newVersionId = VersionId(version.name)
+ val currentVersionId = VersionId(BuildConfig.VERSION_NAME)
val result = newVersionId > currentVersionId
if (result) {
withContext(Dispatchers.Main) {
@@ -56,7 +57,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
settings.lastUpdateCheckTimestamp = System.currentTimeMillis()
result
}.onFailure {
- it.printStackTrace()
+ it.printStackTraceDebug()
}.getOrNull()
@MainThread
@@ -99,7 +100,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
PackageManager.GET_SIGNATURES
)
} catch (e: PackageManager.NameNotFoundException) {
- e.printStackTrace()
+ e.printStackTraceDebug()
return null
}
val signatures = packageInfo?.signatures
@@ -109,7 +110,7 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
val cf = CertificateFactory.getInstance("X509")
cf.generateCertificate(input) as X509Certificate
} catch (e: CertificateException) {
- e.printStackTrace()
+ e.printStackTraceDebug()
return null
}
return try {
@@ -117,10 +118,10 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
val publicKey: ByteArray = md.digest(c.encoded)
publicKey.byte2HexFormatted()
} catch (e: NoSuchAlgorithmException) {
- e.printStackTrace()
+ e.printStackTraceDebug()
null
} catch (e: CertificateEncodingException) {
- e.printStackTrace()
+ e.printStackTraceDebug()
null
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
index 36031ec21..50bb254df 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
@@ -5,16 +5,20 @@ import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.view.postDelayed
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.TwoStatePreference
+import com.google.android.material.color.DynamicColors
+import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
+import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.settings.utils.SliderPreference
-import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import java.util.*
@@ -36,7 +40,7 @@ class AppearanceSettingsFragment :
entryValues = ListMode.values().names()
setDefaultValueCompat(ListMode.GRID.name)
}
- findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = AppSettings.isDynamicColorAvailable
+ findPreference(AppSettings.KEY_DYNAMIC_THEME)?.isVisible = DynamicColors.isDynamicColorAvailable()
findPreference(AppSettings.KEY_DATE_FORMAT)?.run {
entryValues = resources.getStringArray(R.array.date_formats)
val now = Date().time
@@ -71,10 +75,10 @@ class AppearanceSettingsFragment :
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_DYNAMIC_THEME -> {
- findPreference(key)?.setSummary(R.string.restart_required)
+ postRestart()
}
AppSettings.KEY_THEME_AMOLED -> {
- findPreference(key)?.setSummary(R.string.restart_required)
+ postRestart()
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference(AppSettings.KEY_PROTECT_APP)
@@ -98,4 +102,10 @@ class AppearanceSettingsFragment :
else -> super.onPreferenceTreeClick(preference)
}
}
+
+ private fun postRestart() {
+ view?.postDelayed(400) {
+ get().recreateAll()
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
index 4f9c79c46..011d7664e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
+import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
@@ -14,11 +15,14 @@ import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
+import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
+import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
+import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class ContentSettingsFragment :
@@ -42,6 +46,15 @@ class ContentSettingsFragment :
true
}
}
+ findPreference(AppSettings.KEY_DOH)?.run {
+ entryValues = arrayOf(
+ DoHProvider.NONE,
+ DoHProvider.GOOGLE,
+ DoHProvider.CLOUDFLARE,
+ DoHProvider.ADGUARD,
+ ).names()
+ setDefaultValueCompat(DoHProvider.NONE.name)
+ }
bindRemoteSourcesSummary()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
index 1ff363c35..dfa8a7bd0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
@@ -43,7 +43,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref ->
viewLifecycleScope.launchWhenResumed {
- val items = trackerRepo.count()
+ val items = trackerRepo.getLogsCount()
pref.summary =
pref.context.resources.getQuantityString(R.plurals.items, items, items)
}
@@ -142,4 +142,4 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach
}
}.show()
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
index 5602d23c2..39f4de6c5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt
@@ -1,26 +1,68 @@
package org.koitharu.kotatsu.settings
+import android.content.SharedPreferences
import android.os.Bundle
+import android.view.View
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
+import androidx.preference.Preference
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.ReaderMode
+import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
-import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
-class ReaderSettingsFragment : BasePreferenceFragment(R.string.reader_settings) {
+class ReaderSettingsFragment :
+ BasePreferenceFragment(R.string.reader_settings),
+ SharedPreferences.OnSharedPreferenceChangeListener {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_reader)
- findPreference(AppSettings.KEY_READER_SWITCHERS)?.let {
- it.summaryProvider = MultiSummaryProvider(R.string.gestures_only)
+ findPreference(AppSettings.KEY_READER_MODE)?.run {
+ entryValues = arrayOf(
+ ReaderMode.STANDARD,
+ ReaderMode.REVERSED,
+ ReaderMode.WEBTOON,
+ ).names()
+ setDefaultValueCompat(ReaderMode.STANDARD.name)
}
- findPreference(AppSettings.KEY_ZOOM_MODE)?.let {
- it.entryValues = ZoomMode.values().names()
- it.setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
+ findPreference(AppSettings.KEY_READER_SWITCHERS)?.run {
+ summaryProvider = MultiSummaryProvider(R.string.gestures_only)
+ }
+ findPreference(AppSettings.KEY_ZOOM_MODE)?.run {
+ entryValues = arrayOf(
+ ZoomMode.FIT_CENTER,
+ ZoomMode.FIT_HEIGHT,
+ ZoomMode.FIT_WIDTH,
+ ZoomMode.KEEP_START,
+ ).names()
+ setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
+ }
+ updateReaderModeDependency()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ settings.subscribe(this)
+ }
+
+ override fun onDestroyView() {
+ settings.unsubscribe(this)
+ super.onDestroyView()
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
+ when (key) {
+ AppSettings.KEY_READER_MODE -> updateReaderModeDependency()
+ }
+ }
+
+ private fun updateReaderModeDependency() {
+ findPreference(AppSettings.KEY_READER_MODE_DETECT)?.run {
+ isEnabled = settings.defaultReaderMode != ReaderMode.WEBTOON
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
index bec03f017..052aba070 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt
@@ -33,7 +33,7 @@ class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLay
fun setTitle(title: CharSequence?) {
currentTitle = title
- if (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen) {
+ if (slidingPaneLayout.isOpen) {
activity?.title = title
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
index 384905df2..4ffa13c5b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt
@@ -6,7 +6,6 @@ import androidx.preference.Preference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -14,6 +13,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.serializableArgument
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -70,9 +70,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
preference.title = getString(R.string.logged_in_as, username)
}.onFailure { error ->
preference.isEnabled = error is AuthRequiredException
- if (BuildConfig.DEBUG) {
- error.printStackTrace()
- }
+ error.printStackTraceDebug()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
index f1637def6..d64b48b43 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
@@ -1,17 +1,23 @@
package org.koitharu.kotatsu.settings
+import android.annotation.SuppressLint
+import android.content.ActivityNotFoundException
+import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.PowerManager
import android.provider.Settings
import android.text.style.URLSpan
import android.view.View
+import androidx.core.net.toUri
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
+import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
@@ -23,6 +29,8 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
+private const val KEY_IGNORE_DOZE = "ignore_dose"
+
class TrackerSettingsFragment :
BasePreferenceFragment(R.string.check_for_new_chapters),
SharedPreferences.OnSharedPreferenceChangeListener {
@@ -50,6 +58,9 @@ class TrackerSettingsFragment :
override fun onResume() {
super.onResume()
+ findPreference(KEY_IGNORE_DOZE)?.run {
+ isVisible = isDozeIgnoreAvailable(context)
+ }
updateCategoriesSummary()
updateNotificationsSummary()
}
@@ -95,6 +106,10 @@ class TrackerSettingsFragment :
startActivity(CategoriesActivity.newIntent(preference.context))
true
}
+ KEY_IGNORE_DOZE -> {
+ startIgnoreDoseActivity(preference.context)
+ true
+ }
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -122,4 +137,34 @@ class TrackerSettingsFragment :
pref.summary = getString(R.string.enabled_d_of_d, count[0], count[1])
}
}
+
+ @SuppressLint("BatteryLife")
+ private fun startIgnoreDoseActivity(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
+ return
+ }
+ val packageName = context.packageName
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
+ try {
+ val intent = Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ "package:$packageName".toUri(),
+ )
+ startActivity(intent)
+ } catch (e: ActivityNotFoundException) {
+ Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ private fun isDozeIgnoreAvailable(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return false
+ }
+ val packageName = context.packageName
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
+ return !powerManager.isIgnoringBatteryOptimizations(packageName)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
index 8e149f83b..985ca0d1a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt
@@ -36,18 +36,6 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
openLink(getString(R.string.url_weblate), preference.title)
true
}
- AppSettings.KEY_FEEDBACK_4PDA -> {
- openLink(getString(R.string.url_forpda), preference.title)
- true
- }
- AppSettings.KEY_FEEDBACK_DISCORD -> {
- openLink(getString(R.string.url_discord), preference.title)
- true
- }
- AppSettings.KEY_FEEDBACK_GITHUB -> {
- openLink(getString(R.string.url_github_issues), preference.title)
- true
- }
else -> super.onPreferenceTreeClick(preference)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt
index 88f30c87b..b309a588b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt
@@ -23,15 +23,16 @@ class BackupDialogFragment : AlertDialogFragment() {
private val viewModel by viewModel()
private var backup: File? = null
- private val saveFileContract =
- registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
- val file = backup
- if (uri != null && file != null) {
- saveBackup(file, uri)
- } else {
- dismiss()
- }
+ private val saveFileContract = registerForActivityResult(
+ ActivityResultContracts.CreateDocument("*/*")
+ ) { uri ->
+ val file = backup
+ if (uri != null && file != null) {
+ saveBackup(file, uri)
+ } else {
+ dismiss()
}
+ }
override fun onInflateView(
inflater: LayoutInflater,
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt
index b41ebb205..53ad51cbc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt
@@ -7,12 +7,13 @@ import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
-import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
-class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
+class BackupSettingsFragment :
+ BasePreferenceFragment(R.string.backup_restore),
ActivityResultCallback {
private val backupSelectCall = registerForActivityResult(
@@ -34,9 +35,7 @@ class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
try {
backupSelectCall.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
- if (BuildConfig.DEBUG) {
- e.printStackTrace()
- }
+ e.printStackTraceDebug()
Snackbar.make(
listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT
).show()
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt
index 530851d46..08ef96c49 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.settings.newsources
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -33,7 +34,7 @@ class NewSourcesViewModel(
sources.value = initialList.map {
SourceConfigItem.SourceItem(
source = it,
- summary = null,
+ summary = it.getLocaleTitle(),
isEnabled = it.name !in hidden,
isDraggable = false,
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
index 60a5b6ff9..e8044fdde 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt
@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.*
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
+import androidx.core.view.MenuProvider
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -20,11 +21,11 @@ import org.koitharu.kotatsu.settings.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
-class SourcesSettingsFragment : BaseFragment(),
+class SourcesSettingsFragment :
+ BaseFragment(),
SourceConfigListener,
- SearchView.OnQueryTextListener,
- MenuItem.OnActionExpandListener,
RecyclerViewOwner {
private var reorderHelper: ItemTouchHelper? = null
@@ -33,11 +34,6 @@ class SourcesSettingsFragment : BaseFragment(),
override val recyclerView: RecyclerView
get() = binding.recyclerView
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
@@ -53,7 +49,6 @@ class SourcesSettingsFragment : BaseFragment(),
val sourcesAdapter = SourceConfigAdapter(this, get(), viewLifecycleOwner)
with(binding.recyclerView) {
setHasFixedSize(true)
- // addItemDecoration(SourceConfigItemDecoration(view.context))
adapter = sourcesAdapter
reorderHelper = ItemTouchHelper(SourcesReorderCallback()).also {
it.attachToRecyclerView(this)
@@ -62,6 +57,7 @@ class SourcesSettingsFragment : BaseFragment(),
viewModel.items.observe(viewLifecycleOwner) {
sourcesAdapter.items = it
}
+ addMenuProvider(SourcesMenuProvider())
}
override fun onDestroyView() {
@@ -69,17 +65,6 @@ class SourcesSettingsFragment : BaseFragment(),
super.onDestroyView()
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_sources, menu)
- val searchMenuItem = menu.findItem(R.id.action_search)
- searchMenuItem.setOnActionExpandListener(this)
- val searchView = searchMenuItem.actionView as SearchView
- searchView.setOnQueryTextListener(this)
- searchView.setIconifiedByDefault(false)
- searchView.queryHint = searchMenuItem.title
- }
-
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(
bottom = insets.bottom,
@@ -106,21 +91,39 @@ class SourcesSettingsFragment : BaseFragment(),
viewModel.expandOrCollapse(header.localeId)
}
- override fun onQueryTextSubmit(query: String?): Boolean = false
+ private inner class SourcesMenuProvider :
+ MenuProvider,
+ MenuItem.OnActionExpandListener,
+ SearchView.OnQueryTextListener {
- override fun onQueryTextChange(newText: String?): Boolean {
- viewModel.performSearch(newText)
- return true
- }
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_sources, menu)
+ val searchMenuItem = menu.findItem(R.id.action_search)
+ searchMenuItem.setOnActionExpandListener(this)
+ val searchView = searchMenuItem.actionView as SearchView
+ searchView.setOnQueryTextListener(this)
+ searchView.setIconifiedByDefault(false)
+ searchView.queryHint = searchMenuItem.title
+ }
- override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
- (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
- return true
- }
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = false
- override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
- (item.actionView as SearchView).setQuery("", false)
- return true
+ override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
+ (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
+ return true
+ }
+
+ override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
+ (item.actionView as SearchView).setQuery("", false)
+ return true
+ }
+
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ viewModel.performSearch(newText)
+ return true
+ }
}
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
index 01185ba2a..da3eba14f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
@@ -4,6 +4,7 @@ import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -51,6 +52,9 @@ class SourcesSettingsViewModel(
} else {
settings.hiddenSources + source.name
}
+ if (isEnabled) {
+ settings.markKnownSources(setOf(source))
+ }
buildList()
}
@@ -79,7 +83,7 @@ class SourcesSettingsViewModel(
}
SourceConfigItem.SourceItem(
source = it,
- summary = null,
+ summary = it.getLocaleTitle(),
isEnabled = it.name !in hiddenSources,
isDraggable = false,
)
@@ -102,7 +106,7 @@ class SourcesSettingsViewModel(
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
- summary = getLocaleTitle(it.locale),
+ summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
)
@@ -159,4 +163,4 @@ class SourcesSettingsViewModel(
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
index 752e3d33e..775da0f6e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
@@ -17,15 +17,17 @@ import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.textAndVisible
-fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
-) {
+fun sourceConfigHeaderDelegate() =
+ adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
+ ) {
- bind {
- binding.textViewTitle.setText(item.titleResId)
+ bind {
+ binding.textViewTitle.setText(item.titleResId)
+ }
}
-}
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
@@ -61,6 +63,7 @@ fun sourceConfigItemDelegate(
bind {
binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled
+ binding.textViewDescription.textAndVisible = item.summary
imageRequest = ImageRequest.Builder(context)
.data(item.faviconUrl)
.error(R.drawable.ic_favicon_fallback)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt
deleted file mode 100644
index 0171b9dcf..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.koitharu.kotatsu.settings.sources.adapter
-
-import android.content.Context
-import androidx.recyclerview.widget.RecyclerView
-import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration
-
-class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) {
-
- override fun shouldDrawDivider(
- above: RecyclerView.ViewHolder,
- below: RecyclerView.ViewHolder,
- ): Boolean {
- return above.itemViewType != 0 && below.itemViewType != 0
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt
new file mode 100644
index 000000000..42d7b70c9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt
@@ -0,0 +1,67 @@
+package org.koitharu.kotatsu.settings.utils
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.util.AttributeSet
+import android.view.View
+import androidx.appcompat.widget.TooltipCompat
+import androidx.core.net.toUri
+import androidx.preference.Preference
+import androidx.preference.PreferenceViewHolder
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding
+
+class AboutLinksPreference @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : Preference(context, attrs), View.OnClickListener {
+
+ init {
+ layoutResource = R.layout.preference_about_links
+ isSelectable = false
+ isPersistent = false
+ }
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+
+ val binding = PreferenceAboutLinksBinding.bind(holder.itemView)
+ arrayOf(
+ binding.btn4pda,
+ binding.btnDiscord,
+ binding.btnGithub,
+ binding.btnReddit,
+ binding.btnTwitter,
+ ).forEach { button ->
+ TooltipCompat.setTooltipText(button, button.contentDescription)
+ button.setOnClickListener(this)
+ }
+ }
+
+ override fun onClick(v: View) {
+ val urlResId = when (v.id) {
+ R.id.btn_4pda -> R.string.url_forpda
+ R.id.btn_discord -> R.string.url_discord
+ R.id.btn_twitter -> R.string.url_twitter
+ R.id.btn_reddit -> R.string.url_reddit
+ R.id.btn_github -> R.string.url_github
+ else -> return
+ }
+ openLink(v.context.getString(urlResId), v.contentDescription)
+ }
+
+ private fun openLink(url: String, title: CharSequence?) {
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ try {
+ context.startActivity(
+ if (title != null) {
+ Intent.createChooser(intent, title)
+ } else {
+ intent
+ }
+ )
+ } catch (_: ActivityNotFoundException) {
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt
index ceb873f49..f1af41bf3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt
@@ -4,30 +4,40 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
+import android.view.View
import androidx.appcompat.view.ActionMode
+import androidx.core.view.MenuProvider
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.settings.SettingsActivity
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
class SuggestionsFragment : MangaListFragment() {
override val viewModel by viewModel()
override val isSwipeRefreshEnabled = false
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ addMenuProvider(SuggestionMenuProvider())
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_suggestions, menu)
+ override fun onScrolledToEnd() = Unit
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_remote, menu)
+ return super.onCreateActionMode(mode, menu)
}
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
+ private inner class SuggestionMenuProvider : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_suggestions, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_update -> {
SuggestionsWorker.startNow(requireContext())
Snackbar.make(
@@ -41,17 +51,10 @@ class SuggestionsFragment : MangaListFragment() {
startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
- else -> super.onOptionsItemSelected(item)
+ else -> false
}
}
- override fun onScrolledToEnd() = Unit
-
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- mode.menuInflater.inflate(R.menu.mode_remote, menu)
- return super.onCreateActionMode(mode, menu)
- }
-
companion object {
fun newInstance() = SuggestionsFragment()
diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
index 634eeb757..832922ea3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -37,8 +38,10 @@ class SuggestionsViewModel(
list.toUi(this, mode)
}
}
+ }.onStart {
+ loadingCounter.increment()
}.onFirst {
- isLoading.postValue(false)
+ loadingCounter.decrement()
}.catch {
it.toErrorState(canRetry = false)
}.asLiveDataDistinct(
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
index 975e96d66..e15791927 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.tracker
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
+import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.FeedViewModel
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -13,5 +14,7 @@ val trackerModule
factory { TrackingRepository(get()) }
factory { TrackerNotificationChannels(androidContext(), get()) }
+ factory { Tracker(get(), get(), get()) }
+
viewModel { FeedViewModel(get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt
new file mode 100644
index 000000000..452f60f8c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/EntityMapping.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.tracker.data
+
+import java.util.*
+import org.koitharu.kotatsu.core.db.entity.toManga
+import org.koitharu.kotatsu.core.db.entity.toMangaTags
+import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
+
+fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
+ id = trackLog.id,
+ chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
+ manga = manga.toManga(tags.toMangaTags()),
+ createdAt = Date(trackLog.createdAt)
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt
similarity index 74%
rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt
index 91d65d82b..52f7b8d18 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackEntity.kt
@@ -1,9 +1,10 @@
-package org.koitharu.kotatsu.core.db.entity
+package org.koitharu.kotatsu.tracker.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "tracks",
@@ -19,9 +20,11 @@ import androidx.room.PrimaryKey
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
+ @get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
+ @get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt
similarity index 86%
rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt
index 8bb8e61b4..1fedc4663 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogEntity.kt
@@ -1,9 +1,10 @@
-package org.koitharu.kotatsu.core.db.entity
+package org.koitharu.kotatsu.tracker.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "track_logs",
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt
similarity index 65%
rename from app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt
rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt
index 7a6e145a4..e83675b41 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TrackLogWithManga.kt
@@ -1,8 +1,11 @@
-package org.koitharu.kotatsu.core.db.entity
+package org.koitharu.kotatsu.tracker.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 TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt
similarity index 82%
rename from app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt
index f8352524b..2fed9de12 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/data/TracksDao.kt
@@ -1,8 +1,7 @@
-package org.koitharu.kotatsu.core.db.dao
+package org.koitharu.kotatsu.tracker.data
import androidx.room.*
-import org.koitharu.kotatsu.core.db.entity.TrackEntity
-
+import kotlinx.coroutines.flow.Flow
@Dao
abstract class TracksDao {
@@ -19,6 +18,9 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int?
+ @Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
+ abstract fun observeNewChapters(mangaId: Long): Flow
+
@Query("DELETE FROM tracks")
abstract suspend fun clear()
@@ -32,7 +34,7 @@ abstract class TracksDao {
abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
- abstract suspend fun cleanup()
+ abstract suspend fun gc()
@Transaction
open suspend fun upsert(entity: TrackEntity) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt
new file mode 100644
index 000000000..e93902485
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/Tracker.kt
@@ -0,0 +1,115 @@
+package org.koitharu.kotatsu.tracker.domain
+
+import androidx.annotation.VisibleForTesting
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
+import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
+import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
+import org.koitharu.kotatsu.tracker.work.TrackingItem
+
+class Tracker(
+ private val settings: AppSettings,
+ private val repository: TrackingRepository,
+ private val channels: TrackerNotificationChannels,
+) {
+
+ suspend fun getAllTracks(): List {
+ val sources = settings.trackSources
+ if (sources.isEmpty()) {
+ return emptyList()
+ }
+ val knownIds = HashSet()
+ val result = ArrayList()
+ // Favourites
+ if (AppSettings.TRACK_FAVOURITES in sources) {
+ val favourites = repository.getAllFavouritesManga()
+ channels.updateChannels(favourites.keys)
+ for ((category, mangaList) in favourites) {
+ if (!category.isTrackingEnabled || mangaList.isEmpty()) {
+ continue
+ }
+ val categoryTracks = repository.getTracks(mangaList)
+ val channelId = if (channels.isFavouriteNotificationsEnabled(category)) {
+ channels.getFavouritesChannelId(category.id)
+ } else {
+ null
+ }
+ for (track in categoryTracks) {
+ if (knownIds.add(track.manga)) {
+ result.add(TrackingItem(track, channelId))
+ }
+ }
+ }
+ }
+ // History
+ if (AppSettings.TRACK_HISTORY in sources) {
+ val history = repository.getAllHistoryManga()
+ val historyTracks = repository.getTracks(history)
+ val channelId = if (channels.isHistoryNotificationsEnabled()) {
+ channels.getHistoryChannelId()
+ } else {
+ null
+ }
+ for (track in historyTracks) {
+ if (knownIds.add(track.manga)) {
+ result.add(TrackingItem(track, channelId))
+ }
+ }
+ }
+ result.trimToSize()
+ return result
+ }
+
+ suspend fun gc() {
+ repository.gc()
+ }
+
+ suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates {
+ val manga = MangaRepository(track.manga.source).getDetails(track.manga)
+ val updates = compare(track, manga)
+ if (commit) {
+ repository.saveUpdates(updates)
+ }
+ return updates
+ }
+
+ @VisibleForTesting
+ suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates {
+ val track = repository.getTrack(manga)
+ val updates = compare(track, manga)
+ if (commit) {
+ repository.saveUpdates(updates)
+ }
+ return updates
+ }
+
+ @VisibleForTesting
+ suspend fun deleteTrack(mangaId: Long) {
+ repository.deleteTrack(mangaId)
+ }
+
+ /**
+ * The main functionality of tracker: check new chapters in [manga] comparing to the [track]
+ */
+ private fun compare(track: MangaTracking, manga: Manga): MangaUpdates {
+ if (track.isEmpty()) {
+ // first check or manga was empty on last check
+ return MangaUpdates(manga, emptyList(), isValid = false)
+ }
+ val chapters = requireNotNull(manga.chapters)
+ val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
+ return when {
+ newChapters.isEmpty() -> {
+ return MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
+ }
+ newChapters.size == chapters.size -> {
+ return MangaUpdates(manga, emptyList(), isValid = false)
+ }
+ else -> {
+ return MangaUpdates(manga, newChapters, isValid = true)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
index aefa9a69a..bf31ad68f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
@@ -1,17 +1,26 @@
package org.koitharu.kotatsu.tracker.domain
+import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction
+import java.util.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.model.FavouriteCategory
-import org.koitharu.kotatsu.core.model.MangaTracking
-import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
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 java.util.*
+import org.koitharu.kotatsu.tracker.data.TrackEntity
+import org.koitharu.kotatsu.tracker.data.TrackLogEntity
+import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
+import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
+import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
+import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
+
+private const val NO_ID = 0L
class TrackingRepository(
private val db: MangaDatabase,
@@ -21,15 +30,96 @@ class TrackingRepository(
return db.tracksDao.findNewChapters(mangaId) ?: 0
}
- suspend fun getHistoryManga(): List {
- return db.historyDao.findAllManga().toMangaList()
+ fun observeNewChaptersCount(mangaId: Long): Flow {
+ return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 }
}
- suspend fun getFavouritesManga(): Map> {
- val categories = db.favouriteCategoriesDao.findAll()
- return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity ->
- categoryEntity.toFavouriteCategory() to db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList()
+ suspend fun getTracks(mangaList: Collection): List {
+ val ids = mangaList.mapToSet { it.id }
+ val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
+ val idSet = HashSet()
+ val result = ArrayList(mangaList.size)
+ for (item in mangaList) {
+ if (item.source == MangaSource.LOCAL || !idSet.add(item.id)) {
+ continue
+ }
+ val track = tracks[item.id]?.lastOrNull()
+ result += MangaTracking(
+ manga = item,
+ lastChapterId = track?.lastChapterId ?: NO_ID,
+ lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
+ )
}
+ return result
+ }
+
+ @VisibleForTesting
+ suspend fun getTrack(manga: Manga): MangaTracking {
+ val track = db.tracksDao.find(manga.id)
+ return MangaTracking(
+ manga = manga,
+ lastChapterId = track?.lastChapterId ?: NO_ID,
+ lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
+ )
+ }
+
+ @VisibleForTesting
+ suspend fun deleteTrack(mangaId: Long) {
+ db.tracksDao.delete(mangaId)
+ }
+
+ suspend fun getTrackingLog(offset: Int, limit: Int): List {
+ return db.trackLogsDao.findAll(offset, limit).map { x ->
+ x.toTrackingLogItem()
+ }
+ }
+
+ suspend fun getLogsCount() = db.trackLogsDao.count()
+
+ suspend fun clearLogs() = db.trackLogsDao.clear()
+
+ suspend fun gc() {
+ db.withTransaction {
+ db.tracksDao.gc()
+ db.trackLogsDao.gc()
+ }
+ }
+
+ suspend fun saveUpdates(updates: MangaUpdates) {
+ db.withTransaction {
+ val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
+ db.tracksDao.upsert(track)
+ if (updates.isValid && updates.newChapters.isNotEmpty()) {
+ val logEntity = TrackLogEntity(
+ mangaId = updates.manga.id,
+ chapters = updates.newChapters.joinToString("\n") { x -> x.name },
+ createdAt = System.currentTimeMillis(),
+ )
+ db.trackLogsDao.insert(logEntity)
+ }
+ }
+ }
+
+ suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
+ val chapters = manga.chapters ?: return
+ val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
+ val track = getOrCreateTrack(manga.id)
+ val lastNewChapterIndex = chapters.size - track.newChapters
+ val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID
+ val entity = TrackEntity(
+ mangaId = manga.id,
+ totalChapters = chapters.size,
+ lastChapterId = lastChapterId,
+ newChapters = when {
+ track.newChapters == 0 -> 0
+ chapterIndex < 0 -> track.newChapters
+ chapterIndex > lastNewChapterIndex -> chapters.lastIndex - chapterIndex
+ else -> track.newChapters
+ },
+ lastCheck = System.currentTimeMillis(),
+ lastNotifiedChapterId = lastChapterId,
+ )
+ db.tracksDao.upsert(entity)
}
suspend fun getCategoriesCount(): IntArray {
@@ -40,81 +130,39 @@ class TrackingRepository(
)
}
- suspend fun getTracks(mangaList: Collection): List {
- val ids = mangaList.mapToSet { it.id }
- val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
- return mangaList // TODO optimize
- .filterNot { it.source == MangaSource.LOCAL }
- .distinctBy { it.id }
- .map { manga ->
- val track = tracks[manga.id]?.singleOrNull()
- MangaTracking(
- manga = manga,
- knownChaptersCount = track?.totalChapters ?: -1,
- lastChapterId = track?.lastChapterId ?: 0L,
- lastNotifiedChapterId = track?.lastNotifiedChapterId ?: 0L,
- lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
- )
- }
- }
-
- suspend fun getTrackingLog(offset: Int, limit: Int): List {
- return db.trackLogsDao.findAll(offset, limit).map { x ->
- x.toTrackingLogItem()
+ suspend fun getAllFavouritesManga(): Map> {
+ val categories = db.favouriteCategoriesDao.findAll()
+ return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity ->
+ categoryEntity.toFavouriteCategory() to
+ db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList()
}
}
- suspend fun count() = db.trackLogsDao.count()
-
- suspend fun clearLogs() = db.trackLogsDao.clear()
-
- suspend fun cleanup() {
- db.withTransaction {
- db.tracksDao.cleanup()
- db.trackLogsDao.cleanup()
- }
+ suspend fun getAllHistoryManga(): List {
+ return db.historyDao.findAllManga().toMangaList()
}
- suspend fun storeTrackResult(
- mangaId: Long,
- knownChaptersCount: Int,
- lastChapterId: Long,
- newChapters: List,
- previousTrackChapterId: Long
- ) {
- db.withTransaction {
- val entity = TrackEntity(
- mangaId = mangaId,
- newChapters = newChapters.size,
- lastCheck = System.currentTimeMillis(),
- lastChapterId = lastChapterId,
- totalChapters = knownChaptersCount,
- lastNotifiedChapterId = newChapters.lastOrNull()?.id ?: previousTrackChapterId
- )
- db.tracksDao.upsert(entity)
- val foundChapters = newChapters.takeLastWhile { x -> x.id != previousTrackChapterId }
- if (foundChapters.isNotEmpty()) {
- val logEntity = TrackLogEntity(
- mangaId = mangaId,
- chapters = foundChapters.joinToString("\n") { x -> x.name },
- createdAt = System.currentTimeMillis()
- )
- db.trackLogsDao.insert(logEntity)
- }
- }
- }
-
- suspend fun upsert(manga: Manga) {
- val chapters = manga.chapters ?: return
- val entity = TrackEntity(
- mangaId = manga.id,
- totalChapters = chapters.size,
- lastChapterId = chapters.lastOrNull()?.id ?: 0L,
+ private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
+ return db.tracksDao.find(mangaId) ?: TrackEntity(
+ mangaId = mangaId,
+ totalChapters = 0,
+ lastChapterId = 0L,
newChapters = 0,
- lastCheck = System.currentTimeMillis(),
- lastNotifiedChapterId = 0L
+ lastCheck = 0L,
+ lastNotifiedChapterId = 0L,
+ )
+ }
+
+ private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity {
+ val chapters = updates.manga.chapters.orEmpty()
+ return TrackEntity(
+ mangaId = mangaId,
+ totalChapters = chapters.size,
+ lastChapterId = chapters.lastOrNull()?.id ?: NO_ID,
+ newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0,
+ lastCheck = System.currentTimeMillis(),
+ lastNotifiedChapterId = NO_ID,
)
- db.tracksDao.upsert(entity)
}
private fun Collection.toMangaList() = map { it.toManga(emptySet()) }
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt
new file mode 100644
index 000000000..74c964ec8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaTracking.kt
@@ -0,0 +1,35 @@
+package org.koitharu.kotatsu.tracker.domain.model
+
+import java.util.*
+import org.koitharu.kotatsu.parsers.model.Manga
+
+class MangaTracking(
+ val manga: Manga,
+ val lastChapterId: Long,
+ val lastCheck: Date?,
+) {
+
+ fun isEmpty(): Boolean {
+ return lastChapterId == 0L
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as MangaTracking
+
+ if (manga != other.manga) return false
+ if (lastChapterId != other.lastChapterId) return false
+ if (lastCheck != other.lastCheck) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = manga.hashCode()
+ result = 31 * result + lastChapterId.hashCode()
+ result = 31 * result + (lastCheck?.hashCode() ?: 0)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt
new file mode 100644
index 000000000..937f06808
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/MangaUpdates.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.tracker.domain.model
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaChapter
+
+class MangaUpdates(
+ val manga: Manga,
+ val newChapters: List,
+ val isValid: Boolean,
+) {
+
+ fun isNotEmpty() = newChapters.isNotEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/TrackingLogItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt
similarity index 63%
rename from app/src/main/java/org/koitharu/kotatsu/core/model/TrackingLogItem.kt
rename to app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt
index 23a922cb6..c5021eaf3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/TrackingLogItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/model/TrackingLogItem.kt
@@ -1,9 +1,7 @@
-package org.koitharu.kotatsu.core.model
+package org.koitharu.kotatsu.tracker.domain.model
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
+import org.koitharu.kotatsu.parsers.model.Manga
data class TrackingLogItem(
val id: Long,
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
index 584e2a4ff..3bcc46ea7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.tracker.ui
import android.os.Bundle
-import android.view.*
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -21,9 +22,9 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
-import org.koitharu.kotatsu.utils.progress.Progress
class FeedFragment :
BaseFragment