diff --git a/.editorconfig b/.editorconfig
index 999845632..e99fe5d60 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,16 +5,15 @@ charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
-insert_final_newline = true
+insert_final_newline = false
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
-disabled_rules = no-wildcard-imports, no-unused-imports
+disabled_rules=no-wildcard-imports,no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
[{*.kt,*.kts}]
-ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 03f83f88f..02304a073 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1 @@
-ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 9af821d54..c7cc0c855 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
- about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
\ No newline at end of file
+ about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_bug.yml b/.github/ISSUE_TEMPLATE/report_bug.yml
deleted file mode 100644
index 261f51945..000000000
--- a/.github/ISSUE_TEMPLATE/report_bug.yml
+++ /dev/null
@@ -1,64 +0,0 @@
-name: 🐞 Bug report
-description: Report a bug in Kotatsu
-labels: [bug]
-body:
-
- - type: textarea
- id: summary
- attributes:
- label: Brief summary
- description: Please describe, what went wrong
- validations:
- required: true
-
- - type: textarea
- id: reproduce-steps
- attributes:
- label: Steps to reproduce
- description: Please provide a way to reproduce this issue. Screenshots or videos can be very helpful
- placeholder: |
- Example:
- 1. First step
- 2. Second step
- 3. Issue here
- validations:
- required: false
-
-
- - type: input
- id: kotatsu-version
- attributes:
- label: Kotatsu version
- description: You can find your Kotatsu version in **Settings → About**.
- placeholder: |
- Example: "3.3"
- validations:
- required: true
-
- - type: input
- id: android-version
- attributes:
- label: Android version
- description: You can find this somewhere in your Android settings.
- placeholder: |
- Example: "12.0"
- validations:
- required: true
-
- - type: input
- id: device
- attributes:
- label: Device
- description: List your device and model.
- placeholder: |
- Example: "LG Nexus 5X"
- validations:
- required: false
-
- - type: checkboxes
- id: acknowledgements
- attributes:
- label: Acknowledgements
- options:
- - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml
new file mode 100644
index 000000000..4bc2d2de9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/report_issue.yml
@@ -0,0 +1,91 @@
+name: 🐞 Issue report
+description: Report an issue in Kotatsu
+labels: [bug]
+body:
+
+ - type: textarea
+ id: reproduce-steps
+ attributes:
+ label: Steps to reproduce
+ description: Provide an example of the issue.
+ placeholder: |
+ Example:
+ 1. First step
+ 2. Second step
+ 3. Issue here
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: Expected behavior
+ description: Explain what you should expect to happen.
+ placeholder: |
+ Example:
+ "This should happen..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual-behavior
+ attributes:
+ label: Actual behavior
+ description: Explain what actually happens.
+ placeholder: |
+ Example:
+ "This happened instead..."
+ validations:
+ required: true
+
+ - type: input
+ id: kotatsu-version
+ attributes:
+ label: Kotatsu version
+ description: You can find your Kotatsu version in **Settings → About**.
+ placeholder: |
+ Example: "3.3"
+ validations:
+ required: true
+
+ - type: input
+ id: android-version
+ attributes:
+ label: Android version
+ description: You can find this somewhere in your Android settings.
+ placeholder: |
+ Example: "Android 12"
+ validations:
+ required: true
+
+ - type: input
+ id: device
+ attributes:
+ label: Device
+ description: List your device and model.
+ placeholder: |
+ Example: "LG Nexus 5X"
+ validations:
+ required: true
+
+ - type: textarea
+ id: other-details
+ attributes:
+ label: Other details
+ placeholder: |
+ Additional details and attachments.
+
+ - type: checkboxes
+ id: acknowledgements
+ attributes:
+ label: Acknowledgements
+ description: Read this carefully, we will close and ignore your issue if you skimmed through this.
+ options:
+ - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
+ required: true
+ - label: I have written a short but informative title.
+ required: true
+ - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
+ 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 a8539d394..b49ba479b 100644
--- a/.github/ISSUE_TEMPLATE/request_feature.yml
+++ b/.github/ISSUE_TEMPLATE/request_feature.yml
@@ -1,5 +1,5 @@
name: ⭐ Feature request
-description: Suggest a new idea how to improve Kotatsu
+description: Suggest a feature to improve Kotatsu
labels: [feature request]
body:
@@ -14,6 +14,23 @@ body:
validations:
required: true
+ - type: textarea
+ id: other-details
+ attributes:
+ label: Other details
+ placeholder: |
+ Additional details and attachments.
+
+ - type: input
+ id: kotatsu-version
+ attributes:
+ label: Kotatsu version
+ description: You can find your Kotatsu version in **Settings → About**.
+ placeholder: |
+ Example: "3.3"
+ validations:
+ required: true
+
- type: checkboxes
id: acknowledgements
attributes:
@@ -21,4 +38,10 @@ body:
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
+ required: true
+ - label: I have written a short but informative title.
+ required: true
+ - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new).
+ 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 621f3e800..5611db9cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,16 +7,13 @@
/.idea/modules.xml
/.idea/misc.xml
/.idea/discord.xml
-/.idea/compiler.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
-/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
-/.idea/inspectionProfiles/
.DS_Store
/build
/captures
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d33521a..000000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 000000000..fb7f4a8a4
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index ae388c2a5..a0de2a152 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000..2bcd23609
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 000000000..0dd4b3546
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ktlint.xml b/.idea/ktlint.xml
index 5b8abea19..e1ecd151a 100644
--- a/.idea/ktlint.xml
+++ b/.idea/ktlint.xml
@@ -1,13 +1,7 @@
- false
true
false
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 759d4298d..1d693109e 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,30 @@
Kotatsu is a free and open source manga reader for Android.
-   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
+   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
### Download
-- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
-- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
+[ ](https://f-droid.org/packages/org.koitharu.kotatsu)
+
+Download APK from GitHub Releases:
+
+- [Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)
### Main Features
-* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
+* Online manga catalogues
* Search manga by name and genres
* Reading history and bookmarks
* Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
-* Tablet-optimized Material You UI
+* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
-* Integration with manga tracking services: Shikimori, AniList, MyAnimeList
-* Password/fingerprint protect access to the app
-* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
+* Available in multiple languages
+* Password protect access to the app
### Screenshots
@@ -34,20 +38,23 @@ Kotatsu is a free and open source manga reader for Android.
### Localization
-[ ](https://hosted.weblate.org/engage/kotatsu/)
+
+
+
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
-please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
+please head over to the Weblate project page
### License
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
-You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
-to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
-install instructions.
+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.
-### DMCA disclaimer
+### Disclaimer
-The developers of this application does not have any affiliation with the content available in the app.
-It is collecting from the sources freely available through any web browser.
+The developers of this application does not have any affiliation with the content providers available.
diff --git a/app/build.gradle b/app/build.gradle
index d81bce6d9..1ca65ee45 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -3,20 +3,19 @@ plugins {
id 'kotlin-android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
- id 'dagger.hilt.android.plugin'
}
android {
- compileSdk = 33
- buildToolsVersion = '33.0.2'
- namespace = 'org.koitharu.kotatsu'
+ compileSdkVersion 32
+ buildToolsVersion '32.0.0'
+ namespace 'org.koitharu.kotatsu'
defaultConfig {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
- targetSdkVersion 33
- versionCode 545
- versionName '5.1.1'
+ targetSdkVersion 32
+ versionCode 411
+ versionName '3.3.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -25,6 +24,10 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
+
+ // define this values in your local.properties file
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
}
buildTypes {
debug {
@@ -42,16 +45,14 @@ android {
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
- main.java.srcDirs += 'src/main/kotlin/'
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17.toString()
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
- '-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
@@ -59,15 +60,12 @@ android {
]
}
lint {
- abortOnError true
- disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
+ abortOnError false
+ disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
}
testOptions {
- unitTests.includeAndroidResources true
- unitTests.returnDefaultValues false
- kotlinOptions {
- freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
- }
+ unitTests.includeAndroidResources = true
+ unitTests.returnDefaultValues = false
}
}
afterEvaluate {
@@ -78,81 +76,65 @@ afterEvaluate {
}
}
dependencies {
- //noinspection GradleDependency
- implementation('com.github.KotatsuApp:kotatsu-parsers:cae7073f87') {
+ implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
+ implementation('com.github.nv95:kotatsu-parsers:c92f89f307') {
exclude group: 'org.json', module: 'json'
}
- implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
-
- implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'androidx.core:core-ktx:1.10.1'
- implementation 'androidx.activity:activity-ktx:1.7.1'
- implementation 'androidx.fragment:fragment-ktx:1.5.7'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
- implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
- implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.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-rc02'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-rc02'
+ implementation 'androidx.lifecycle:lifecycle-service:2.5.0-rc02'
+ implementation 'androidx.lifecycle:lifecycle-process:2.5.0-rc02'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
- implementation 'androidx.recyclerview:recyclerview:1.3.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.biometric:biometric-ktx:1.2.0-alpha05'
- implementation 'com.google.android.material:material:1.9.0'
+ implementation 'androidx.work:work-runtime-ktx:2.7.1'
+ implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
+ implementation 'com.google.android.material:material:1.7.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8
- kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
-
- implementation 'androidx.work:work-runtime-ktx:2.8.1'
- //noinspection GradleDependency
- implementation('com.google.guava:guava:31.1-android') {
- exclude group: 'com.google.guava', module: 'failureaccess'
- exclude group: 'org.checkerframework', module: 'checker-qual'
- exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
- }
+ kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0-rc02'
- implementation 'androidx.room:room-runtime:2.5.1'
- implementation 'androidx.room:room-ktx:2.5.1'
- kapt 'androidx.room:room-compiler:2.5.1'
+ implementation 'androidx.room:room-runtime:2.4.2'
+ implementation 'androidx.room:room-ktx:2.4.2'
+ kapt 'androidx.room:room-compiler:2.4.2'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
- implementation 'com.squareup.okio:okio:3.3.0'
+ implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
- implementation 'com.google.dagger:hilt-android:2.46.1'
- kapt 'com.google.dagger:hilt-compiler:2.46.1'
- implementation 'androidx.hilt:hilt-work:1.0.0'
- kapt 'androidx.hilt:hilt-compiler:1.0.0'
-
- implementation 'io.coil-kt:coil-base:2.3.0'
- implementation 'io.coil-kt:coil-svg:2.3.0'
- implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
+ implementation 'io.insert-koin:koin-android:3.2.0'
+ implementation 'io.coil-kt:coil-base:2.1.0'
+ implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4'
- implementation 'io.noties.markwon:core:4.6.2'
- implementation 'ch.acra:acra-http:5.9.7'
- implementation 'ch.acra:acra-dialog:5.9.7'
+ implementation 'ch.acra:acra-mail:5.9.3'
+ implementation 'ch.acra:acra-dialog:5.9.3'
- debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
+ debugImplementation 'org.jsoup:jsoup:1.15.1'
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.json:json:20230227'
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
- androidTestImplementation 'androidx.test:runner:1.5.2'
- androidTestImplementation 'androidx.test:rules:1.5.0'
- androidTestImplementation 'androidx.test:core-ktx:1.5.0'
- androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
+ androidTestImplementation 'androidx.test:runner:1.4.0'
+ androidTestImplementation 'androidx.test:rules:1.4.0'
+ androidTestImplementation 'androidx.test:core-ktx:1.4.0'
+ androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
- androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
+ androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3'
+ 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.5.1'
- androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
-
- androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
- kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
-}
+ androidTestImplementation 'androidx.room:room-testing:2.4.2'
+ androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index a81d9ac31..fb3509dc2 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -8,13 +8,6 @@
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
--keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
+-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
--dontwarn okhttp3.internal.platform.**
--dontwarn org.conscrypt.**
--dontwarn org.bouncycastle.**
--dontwarn org.openjsse.**
-
--keep class org.koitharu.kotatsu.core.exceptions.* { *; }
--keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
--keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
+-dontwarn okhttp3.internal.platform.ConscryptPlatform
\ No newline at end of file
diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json
deleted file mode 100644
index 58a2ab058..000000000
--- a/app/src/androidTest/assets/categories/simple.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "id": 4,
- "title": "Read later",
- "sortKey": 1,
- "order": "NEWEST",
- "createdAt": 1335906000000,
- "isTrackingEnabled": true,
- "isVisibleInLibrary": true
-}
diff --git a/app/src/androidTest/assets/kotatsu_test.bak b/app/src/androidTest/assets/kotatsu_test.bak
deleted file mode 100755
index a6eae4cdc..000000000
Binary files a/app/src/androidTest/assets/kotatsu_test.bak and /dev/null differ
diff --git a/app/src/androidTest/assets/manga/header.json b/app/src/androidTest/assets/manga/header.json
deleted file mode 100644
index dc56dbf8e..000000000
--- a/app/src/androidTest/assets/manga/header.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "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": null,
- "source": "READMANGA_RU"
-}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
deleted file mode 100644
index dbf4ec642..000000000
--- a/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.koitharu.kotatsu
-
-import android.app.Instrumentation
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-
-suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont ->
- waitForIdle { cont.resume(Unit) }
-}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt b/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt
deleted file mode 100644
index b7d4ad7e3..000000000
--- a/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package org.koitharu.kotatsu
-
-import androidx.test.platform.app.InstrumentationRegistry
-import com.squareup.moshi.*
-import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
-import okio.buffer
-import okio.source
-import org.koitharu.kotatsu.core.model.FavouriteCategory
-import org.koitharu.kotatsu.parsers.model.Manga
-import java.util.*
-import kotlin.reflect.KClass
-
-object SampleData {
-
- private val moshi = Moshi.Builder()
- .add(DateAdapter())
- .add(KotlinJsonAdapterFactory())
- .build()
-
- val manga: Manga = loadAsset("manga/header.json", Manga::class)
-
- val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
-
- val tag = mangaDetails.tags.elementAt(2)
-
- val chapter = checkNotNull(mangaDetails.chapters)[2]
-
- val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
-
- fun loadAsset(name: String, cls: KClass): T {
- val assets = InstrumentationRegistry.getInstrumentation().context.assets
- return assets.open(name).use {
- moshi.adapter(cls.java).fromJson(it.source().buffer())
- } ?: throw RuntimeException("Cannot read asset from json \"$name\"")
- }
-
- private class DateAdapter : JsonAdapter() {
-
- @FromJson
- override fun fromJson(reader: JsonReader): Date? {
- val ms = reader.nextLong()
- return if (ms == 0L) {
- null
- } else {
- Date(ms)
- }
- }
-
- @ToJson
- override fun toJson(writer: JsonWriter, value: Date?) {
- writer.value(value?.time ?: 0L)
- }
- }
-}
\ 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 b7e4a07f5..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
@@ -3,10 +3,11 @@ package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.assertEquals
+import java.io.IOException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.koitharu.kotatsu.core.db.migrations.*
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@@ -17,41 +18,38 @@ class MangaDatabaseTest {
MangaDatabase::class.java,
)
- private val migrations = databaseMigrations
-
- @Test
- fun versions() {
- assertEquals(1, migrations.first().startVersion)
- repeat(migrations.size) { i ->
- assertEquals(i + 1, migrations[i].startVersion)
- assertEquals(i + 2, migrations[i].endVersion)
- }
- assertEquals(DATABASE_VERSION, migrations.last().endVersion)
- }
-
@Test
+ @Throws(IOException::class)
fun migrateAll() {
- helper.createDatabase(TEST_DB, 1).close()
+ helper.createDatabase(TEST_DB, 1).apply {
+ // TODO execSQL("")
+ close()
+ }
for (migration in migrations) {
helper.runMigrationsAndValidate(
TEST_DB,
migration.endVersion,
true,
- migration,
- ).close()
- }
- }
-
- @Test
- fun prePopulate() {
- val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources
- helper.createDatabase(TEST_DB, DATABASE_VERSION).use {
- DatabasePrePopulateCallback(resources).onCreate(it)
+ migration
+ )
}
}
private companion object {
const val TEST_DB = "test-db"
+
+ val migrations = arrayOf(
+ Migration1To2(),
+ Migration2To3(),
+ Migration3To4(),
+ Migration4To5(),
+ Migration5To6(),
+ Migration6To7(),
+ Migration7To8(),
+ Migration8To9(),
+ Migration9To10(),
+ Migration10To11(),
+ )
}
-}
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
deleted file mode 100644
index 4b1784bed..000000000
--- a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.koitharu.kotatsu.core.os
-
-import android.content.pm.ShortcutInfo
-import android.content.pm.ShortcutManager
-import android.os.Build
-import androidx.core.content.getSystemService
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import dagger.hilt.android.testing.HiltAndroidRule
-import dagger.hilt.android.testing.HiltAndroidTest
-import javax.inject.Inject
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koitharu.kotatsu.SampleData
-import org.koitharu.kotatsu.awaitForIdle
-import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.history.domain.HistoryRepository
-
-@HiltAndroidTest
-@RunWith(AndroidJUnit4::class)
-class ShortcutsUpdaterTest {
-
- @get:Rule
- var hiltRule = HiltAndroidRule(this)
-
- @Inject
- lateinit var historyRepository: HistoryRepository
-
- @Inject
- lateinit var shortcutsUpdater: ShortcutsUpdater
-
- @Inject
- lateinit var database: MangaDatabase
-
- @Before
- fun setUp() {
- hiltRule.inject()
- database.clearAllTables()
- }
-
- @Test
- fun testUpdateShortcuts() = runTest {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
- return@runTest
- }
- awaitUpdate()
- assertTrue(getShortcuts().isEmpty())
- historyRepository.addOrUpdate(
- manga = SampleData.manga,
- chapterId = SampleData.chapter.id,
- page = 4,
- scroll = 2,
- percent = 0.3f,
- )
- awaitUpdate()
-
- val shortcuts = getShortcuts()
- assertEquals(1, shortcuts.size)
- }
-
- private fun getShortcuts(): List {
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val manager = checkNotNull(context.getSystemService())
- return manager.dynamicShortcuts.filterNot { it.id == "com.squareup.leakcanary.dynamic_shortcut" }
- }
-
- private suspend fun awaitUpdate() {
- val instrumentation = InstrumentationRegistry.getInstrumentation()
- instrumentation.awaitForIdle()
- shortcutsUpdater.await()
- }
-}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt
deleted file mode 100644
index f885ddb36..000000000
--- a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package org.koitharu.kotatsu.settings.backup
-
-import android.content.res.AssetManager
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import dagger.hilt.android.testing.HiltAndroidRule
-import dagger.hilt.android.testing.HiltAndroidTest
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.*
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koitharu.kotatsu.SampleData
-import org.koitharu.kotatsu.core.backup.BackupRepository
-import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.toMangaTags
-import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
-import org.koitharu.kotatsu.history.domain.HistoryRepository
-import java.io.File
-import javax.inject.Inject
-
-@HiltAndroidTest
-@RunWith(AndroidJUnit4::class)
-class AppBackupAgentTest {
-
- @get:Rule
- var hiltRule = HiltAndroidRule(this)
-
- @Inject
- lateinit var historyRepository: HistoryRepository
-
- @Inject
- lateinit var favouritesRepository: FavouritesRepository
-
- @Inject
- lateinit var backupRepository: BackupRepository
-
- @Inject
- lateinit var database: MangaDatabase
-
- @Before
- fun setUp() {
- hiltRule.inject()
- database.clearAllTables()
- }
-
- @Test
- fun backupAndRestore() = runTest {
- val category = favouritesRepository.createCategory(
- title = SampleData.favouriteCategory.title,
- sortOrder = SampleData.favouriteCategory.order,
- isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
- isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
- )
- favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
- historyRepository.addOrUpdate(
- manga = SampleData.mangaDetails,
- chapterId = SampleData.mangaDetails.chapters!![2].id,
- page = 3,
- scroll = 40,
- percent = 0.2f,
- )
- val history = checkNotNull(historyRepository.getOne(SampleData.manga))
-
- val agent = AppBackupAgent()
- val backup = agent.createBackupFile(
- context = InstrumentationRegistry.getInstrumentation().targetContext,
- repository = backupRepository,
- )
-
- database.clearAllTables()
- assertTrue(favouritesRepository.getAllManga().isEmpty())
- assertNull(historyRepository.getLastOrNull())
-
- backup.inputStream().use {
- agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
- }
-
- assertEquals(category, favouritesRepository.getCategory(category.id))
- assertEquals(history, historyRepository.getOne(SampleData.manga))
- assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
-
- val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
- assertTrue(SampleData.tag in allTags)
- }
-
- @Test
- fun restoreOldBackup() {
- val agent = AppBackupAgent()
- val backup = File.createTempFile("backup_", ".tmp")
- InstrumentationRegistry.getInstrumentation().context.assets
- .open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
- .use { input ->
- backup.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- backup.inputStream().use {
- agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
- }
- runTest {
- assertEquals(6, historyRepository.observeAll().first().size)
- assertEquals(2, favouritesRepository.observeCategories().first().size)
- assertEquals(15, favouritesRepository.getAllManga().size)
- }
- }
-}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
index 5e38b4d15..3b63f429e 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
@@ -1,39 +1,32 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4
-import dagger.hilt.android.testing.HiltAndroidRule
-import dagger.hilt.android.testing.HiltAndroidTest
-import junit.framework.TestCase.*
+import 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 org.junit.Before
-import org.junit.Rule
+import okio.buffer
+import okio.source
import org.junit.Test
import org.junit.runner.RunWith
-import org.koitharu.kotatsu.SampleData
-import org.koitharu.kotatsu.core.parser.MangaDataRepository
+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
-import javax.inject.Inject
-@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
-class TrackerTest {
+class TrackerTest : KoinTest {
- @get:Rule
- var hiltRule = HiltAndroidRule(this)
-
- @Inject
- lateinit var repository: TrackingRepository
-
- @Inject
- lateinit var dataRepository: MangaDataRepository
-
- @Inject
- lateinit var tracker: Tracker
-
- @Before
- fun setUp() {
- hiltRule.inject()
- }
+ private val 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 {
@@ -173,26 +166,23 @@ class TrackerTest {
}
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
- var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
+ val chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
repository.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
-
- chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
- repository.syncWithHistory(mangaFull, chapter.id)
-
- assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
-
tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid)
assert(newChapters.isEmpty())
}
- assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
}
private suspend fun loadManga(name: String): Manga {
- val manga = SampleData.loadAsset("manga/$name", Manga::class)
+ 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/network/CurlLoggingInterceptor.kt b/app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
deleted file mode 100644
index cc6f6a0a7..000000000
--- a/app/src/debug/java/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.koitharu.kotatsu.core.network
-
-import android.util.Log
-import okhttp3.Interceptor
-import okhttp3.Response
-import okio.Buffer
-import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
-
-class CurlLoggingInterceptor(
- private val curlOptions: String? = null
-) : Interceptor {
-
- override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
- var isCompressed = false
-
- val curlCmd = StringBuilder()
- curlCmd.append("curl")
- if (curlOptions != null) {
- curlCmd.append(' ').append(curlOptions)
- }
- curlCmd.append(" -X ").append(request.method)
-
- for ((name, value) in request.headers) {
- if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
- isCompressed = true
- }
- curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
- }
-
- val body = request.body
- if (body != null) {
- val buffer = Buffer()
- body.writeTo(buffer)
- val charset = body.contentType()?.charset() ?: Charsets.UTF_8
- curlCmd.append(" --data-raw '")
- .append(buffer.readString(charset).replace("\n", "\\n"))
- .append("'")
- }
- if (isCompressed) {
- curlCmd.append(" --compressed")
- }
- curlCmd.append(" \"").append(request.url).append('"')
-
- log("---cURL (" + request.url + ")")
- log(curlCmd.toString())
-
- return chain.proceed(request)
- }
-
- private fun String.escape() = replace("\"", "\\\"")
-
- private fun log(msg: String) {
- Log.d("CURL", msg)
- }
-}
diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
index a60655a2a..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
@@ -1,20 +1,15 @@
package org.koitharu.kotatsu.core.parser
+import java.util.*
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import java.util.EnumSet
+import org.koitharu.kotatsu.parsers.model.*
/**
* This parser is just for parser development, it should not be used in releases
*/
-class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
+class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null)
@@ -42,4 +37,4 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
override suspend fun getTags(): Set {
TODO("Not yet implemented")
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaParser.kt
rename to app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt
diff --git a/app/src/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt b/app/src/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt
deleted file mode 100644
index 07ae3c4bb..000000000
--- a/app/src/debug/java/org/koitharu/kotatsu/util/LoggingAdapterDataObserver.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.koitharu.kotatsu.util
-
-import android.util.Log
-import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
-
-class LoggingAdapterDataObserver(
- private val tag: String,
-) : AdapterDataObserver() {
-
- override fun onChanged() {
- Log.d(tag, "onChanged()")
- }
-
- override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
- Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
- }
-
- override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
- Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
- }
-
- override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
- Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
- }
-
- override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
- Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
- }
-
- override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
- Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
- }
-
- override fun onStateRestorationPolicyChanged() {
- Log.d(tag, "onStateRestorationPolicyChanged()")
- }
-}
diff --git a/app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt b/app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt
deleted file mode 100644
index acaab0f0c..000000000
--- a/app/src/debug/java/org/koitharu/kotatsu/util/ext/DebugExt.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.koitharu.kotatsu.util.ext
-
-fun Throwable.printStackTraceDebug() = printStackTrace()
diff --git a/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt
new file mode 100644
index 000000000..e00bb6a83
--- /dev/null
+++ b/app/src/debug/java/org/koitharu/kotatsu/utils/ext/DebugExt.kt
@@ -0,0 +1,3 @@
+package org.koitharu.kotatsu.utils.ext
+
+fun Throwable.printStackTraceDebug() = printStackTrace()
\ No newline at end of file
diff --git a/app/src/debug/res/values/bools.xml b/app/src/debug/res/values/bools.xml
index 36b9b0867..037cba998 100644
--- a/app/src/debug/res/values/bools.xml
+++ b/app/src/debug/res/values/bools.xml
@@ -1,4 +1,4 @@
false
-
+
\ No newline at end of file
diff --git a/app/src/debug/res/values/constants.xml b/app/src/debug/res/values/constants.xml
deleted file mode 100644
index 44e14a54f..000000000
--- a/app/src/debug/res/values/constants.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- org.kotatsu.debug.sync
- org.koitharu.kotatsu.debug.history
- org.koitharu.kotatsu.debug.favourites
-
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 10faa7ae2..22bad7dd9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,27 +10,16 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:label="@string/manga_shelf"
+ android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
@@ -129,40 +100,18 @@
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
+ android:launchMode="singleTop"
+ android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
+ android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
new file mode 100644
index 000000000..d7f25396e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -0,0 +1,140 @@
+package org.koitharu.kotatsu
+
+import android.app.Application
+import android.content.Context
+import android.os.StrictMode
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.fragment.app.strictmode.FragmentStrictMode
+import org.acra.ReportField
+import org.acra.config.dialog
+import org.acra.config.mailSender
+import org.acra.data.StringFormat
+import org.acra.ktx.initAcra
+import org.koin.android.ext.android.get
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
+import org.koitharu.kotatsu.bookmarks.bookmarksModule
+import org.koitharu.kotatsu.core.db.databaseModule
+import org.koitharu.kotatsu.core.github.githubModule
+import org.koitharu.kotatsu.core.network.networkModule
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.ui.uiModule
+import org.koitharu.kotatsu.details.detailsModule
+import org.koitharu.kotatsu.favourites.favouritesModule
+import org.koitharu.kotatsu.history.historyModule
+import org.koitharu.kotatsu.local.data.PagesCache
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.local.localModule
+import org.koitharu.kotatsu.main.mainModule
+import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.reader.readerModule
+import org.koitharu.kotatsu.remotelist.remoteListModule
+import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
+import org.koitharu.kotatsu.search.searchModule
+import org.koitharu.kotatsu.settings.settingsModule
+import org.koitharu.kotatsu.suggestions.suggestionsModule
+import org.koitharu.kotatsu.tracker.trackerModule
+import org.koitharu.kotatsu.widget.WidgetUpdater
+import org.koitharu.kotatsu.widget.appWidgetModule
+
+class KotatsuApp : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ if (BuildConfig.DEBUG) {
+ enableStrictMode()
+ }
+ initKoin()
+ AppCompatDelegate.setDefaultNightMode(get().theme)
+ registerActivityLifecycleCallbacks(get())
+ registerActivityLifecycleCallbacks(get())
+ val widgetUpdater = WidgetUpdater(applicationContext)
+ widgetUpdater.subscribeToFavourites(get())
+ widgetUpdater.subscribeToHistory(get())
+ }
+
+ private fun initKoin() {
+ startKoin {
+ androidContext(this@KotatsuApp)
+ modules(
+ networkModule,
+ databaseModule,
+ githubModule,
+ uiModule,
+ mainModule,
+ searchModule,
+ localModule,
+ favouritesModule,
+ historyModule,
+ remoteListModule,
+ detailsModule,
+ trackerModule,
+ settingsModule,
+ readerModule,
+ appWidgetModule,
+ suggestionsModule,
+ shikimoriModule,
+ bookmarksModule,
+ )
+ }
+ }
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ initAcra {
+ buildConfigClass = BuildConfig::class.java
+ reportFormat = StringFormat.KEY_VALUE_LIST
+ reportContent = listOf(
+ ReportField.PACKAGE_NAME,
+ ReportField.APP_VERSION_CODE,
+ ReportField.APP_VERSION_NAME,
+ ReportField.ANDROID_VERSION,
+ ReportField.PHONE_MODEL,
+ ReportField.CRASH_CONFIGURATION,
+ ReportField.STACK_TRACE,
+ ReportField.CUSTOM_DATA,
+ ReportField.SHARED_PREFERENCES,
+ )
+ dialog {
+ text = getString(R.string.crash_text)
+ title = getString(R.string.error_occurred)
+ positiveButtonText = getString(R.string.send)
+ resIcon = R.drawable.ic_alert_outline
+ resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
+ }
+ mailSender {
+ mailTo = getString(R.string.email_error_report)
+ reportAsFile = true
+ reportFileName = "stacktrace.txt"
+ }
+ }
+ }
+
+ private fun enableStrictMode() {
+ StrictMode.setThreadPolicy(
+ StrictMode.ThreadPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build()
+ )
+ StrictMode.setVmPolicy(
+ StrictMode.VmPolicy.Builder()
+ .detectAll()
+ .setClassInstanceLimit(LocalMangaRepository::class.java, 1)
+ .setClassInstanceLimit(PagesCache::class.java, 1)
+ .setClassInstanceLimit(MangaLoaderContext::class.java, 1)
+ .penaltyLog()
+ .build()
+ )
+ FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
+ .penaltyDeath()
+ .detectFragmentReuse()
+ .detectWrongFragmentContainer()
+ .detectRetainInstanceUsage()
+ .detectSetUserVisibleHint()
+ .detectFragmentTagUsage()
+ .build()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
new file mode 100644
index 000000000..179d87868
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaDataRepository.kt
@@ -0,0 +1,52 @@
+package org.koitharu.kotatsu.base.domain
+
+import androidx.room.withTransaction
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.prefs.ReaderMode
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaTag
+
+class MangaDataRepository(private val db: MangaDatabase) {
+
+ suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
+ val tags = manga.tags.toEntities()
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga.toEntity(), tags)
+ db.preferencesDao.upsert(
+ MangaPrefsEntity(
+ mangaId = manga.id,
+ mode = mode.id
+ )
+ )
+ }
+ }
+
+ suspend fun getReaderMode(mangaId: Long): ReaderMode? {
+ return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
+ }
+
+ suspend fun findMangaById(mangaId: Long): Manga? {
+ return db.mangaDao.find(mangaId)?.toManga()
+ }
+
+ suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
+ intent.manga != null -> intent.manga
+ intent.mangaId != 0L -> findMangaById(intent.mangaId)
+ else -> null // TODO resolve uri
+ }
+
+ suspend fun storeManga(manga: Manga) {
+ val tags = manga.tags.toEntities()
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga.toEntity(), tags)
+ }
+ }
+
+ suspend fun findTags(source: MangaSource): Set {
+ return db.tagsDao.findTags(source.name).toMangaTags()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt
new file mode 100644
index 000000000..fccd1e9fd
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaIntent.kt
@@ -0,0 +1,34 @@
+package org.koitharu.kotatsu.base.domain
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.parsers.model.Manga
+
+class MangaIntent private constructor(
+ val manga: Manga?,
+ val mangaId: Long,
+ val uri: Uri?,
+) {
+
+ constructor(intent: Intent?) : this(
+ manga = intent?.getParcelableExtra(KEY_MANGA)?.manga,
+ mangaId = intent?.getLongExtra(KEY_ID, ID_NONE) ?: ID_NONE,
+ uri = intent?.data
+ )
+
+ constructor(args: Bundle?) : this(
+ manga = args?.getParcelable(KEY_MANGA)?.manga,
+ mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,
+ uri = null
+ )
+
+ companion object {
+
+ const val ID_NONE = 0L
+
+ const val KEY_MANGA = "manga"
+ const val KEY_ID = "id"
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
new file mode 100644
index 000000000..b3b32dc1f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
@@ -0,0 +1,76 @@
+package org.koitharu.kotatsu.base.domain
+
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.Size
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import org.koitharu.kotatsu.core.network.CommonHeaders
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.util.await
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipFile
+import kotlin.math.roundToInt
+
+object MangaUtils : KoinComponent {
+
+ private const val MIN_WEBTOON_RATIO = 2
+
+ /**
+ * Automatic determine type of manga by page size
+ * @return ReaderMode.WEBTOON if page is wide
+ */
+ suspend fun determineMangaIsWebtoon(pages: List): Boolean {
+ val pageIndex = (pages.size * 0.3).roundToInt()
+ val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
+ val url = MangaRepository(page.source).getPageUrl(page)
+ val uri = Uri.parse(url)
+ val size = if (uri.scheme == "cbz") {
+ runInterruptible(Dispatchers.IO) {
+ val zip = ZipFile(uri.schemeSpecificPart)
+ val entry = zip.getEntry(uri.fragment)
+ zip.getInputStream(entry).use {
+ getBitmapSize(it)
+ }
+ }
+ } else {
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header(CommonHeaders.REFERER, page.referer)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .build()
+ get().newCall(request).await().use {
+ runInterruptible(Dispatchers.IO) {
+ getBitmapSize(it.body?.byteStream())
+ }
+ }
+ }
+ return size.width * MIN_WEBTOON_RATIO < size.height
+ }
+
+ suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeFile(file.path, options)?.recycle()
+ options.outMimeType
+ }
+
+ private fun getBitmapSize(input: InputStream?): Size {
+ val options = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeStream(input, null, options)?.recycle()
+ val imageHeight: Int = options.outHeight
+ val imageWidth: Int = options.outWidth
+ check(imageHeight > 0 && imageWidth > 0)
+ return Size(imageWidth, imageHeight)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
new file mode 100644
index 000000000..43c9bf7e4
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
@@ -0,0 +1,19 @@
+package org.koitharu.kotatsu.base.domain
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.utils.ext.processLifecycleScope
+
+fun interface ReversibleHandle {
+
+ suspend fun reverse()
+}
+
+fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
+ reverse()
+}
+
+operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
+ this.reverse()
+ other.reverse()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt
new file mode 100644
index 000000000..e1e328c8f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/AlertDialogFragment.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.base.ui
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.annotation.CallSuper
+import androidx.fragment.app.DialogFragment
+import androidx.viewbinding.ViewBinding
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+abstract class AlertDialogFragment : DialogFragment() {
+
+ private var viewBinding: B? = null
+
+ protected val binding: B
+ get() = checkNotNull(viewBinding)
+
+ final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val binding = onInflateView(layoutInflater, null)
+ viewBinding = binding
+ return MaterialAlertDialogBuilder(requireContext(), theme)
+ .setView(binding.root)
+ .also(::onBuildDialog)
+ .create()
+ }
+
+ final override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ) = viewBinding?.root
+
+ @CallSuper
+ override fun onDestroyView() {
+ viewBinding = null
+ super.onDestroyView()
+ }
+
+ open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit
+
+ protected fun bindingOrNull(): B? = viewBinding
+
+ protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
similarity index 56%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index f547e527a..bd172d695 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -1,8 +1,6 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui
-import android.content.Intent
import android.content.res.Configuration
-import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
@@ -14,65 +12,47 @@ 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.content.ContextCompat
-import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
-import dagger.hilt.android.EntryPointAccessors
+import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
+import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
-import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
-import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
-import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
-import org.koitharu.kotatsu.core.util.ext.getThemeColor
+import org.koitharu.kotatsu.core.prefs.AppSettings
-@Suppress("LeakingThis")
abstract class BaseActivity :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener {
- private var isAmoledTheme = false
-
- lateinit var viewBinding: B
+ protected lateinit var binding: B
private set
- @JvmField
+ @Suppress("LeakingThis")
protected val exceptionResolver = ExceptionResolver(this)
- @JvmField
+ @Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
- @JvmField
val actionModeDelegate = ActionModeDelegate()
- private var defaultStatusBarColor = Color.TRANSPARENT
-
override fun onCreate(savedInstanceState: Bundle?) {
- val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
- isAmoledTheme = settings.isAmoledTheme
- setTheme(settings.colorScheme.styleResId)
- if (isAmoledTheme) {
- setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
+ val settings = get()
+ val isAmoled = settings.isAmoledTheme
+ val isDynamic = settings.isDynamicTheme
+ // TODO support DialogWhenLarge theme
+ when {
+ isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
+ isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
+ isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
- putDataToExtras(intent)
- }
-
- override fun onPostCreate(savedInstanceState: Bundle?) {
- super.onPostCreate(savedInstanceState)
- onBackPressedDispatcher.addCallback(actionModeDelegate)
- }
-
- override fun onNewIntent(intent: Intent?) {
- putDataToExtras(intent)
- super.onNewIntent(intent)
}
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
@@ -88,7 +68,7 @@ abstract class BaseActivity :
}
protected fun setContentView(binding: B) {
- this.viewBinding = binding
+ this.binding = binding
super.setContentView(binding.root)
val toolbar = (binding.root.findViewById(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
@@ -96,15 +76,15 @@ abstract class BaseActivity :
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
- @Suppress("DEPRECATION")
onBackPressed()
true
} else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
- if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
- ActivityCompat.recreate(this)
- return true
+ if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove
+ // ActivityCompat.recreate(this)
+ throw RuntimeException("Test crash")
+ // return true
}
return super.onKeyDown(keyCode, event)
}
@@ -116,46 +96,36 @@ abstract class BaseActivity :
protected fun isDarkAmoledTheme(): Boolean {
val uiMode = resources.configuration.uiMode
val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
- return isNight && isAmoledTheme
+ return isNight && get().isAmoledTheme
}
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
- val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- ColorUtils.compositeColors(
- ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
- getThemeColor(com.google.android.material.R.attr.colorSurface),
- )
- } else {
- ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
- }
- val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
+ val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
- findViewById(androidx.appcompat.R.id.action_mode_bar).apply {
- setBackgroundColor(actionModeColor)
- updateLayoutParams {
- topMargin = insets.top
- }
+ val view = findViewById(androidx.appcompat.R.id.action_mode_bar)
+ view?.updateLayoutParams {
+ topMargin = insets.top
}
- defaultStatusBarColor = window.statusBarColor
- window.statusBarColor = actionModeColor
}
@CallSuper
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
- window.statusBarColor = defaultStatusBarColor
- }
-
- private fun putDataToExtras(intent: Intent?) {
- intent?.putExtra(EXTRA_DATA, intent.data)
}
- companion object {
-
- const val EXTRA_DATA = "data"
+ override fun onBackPressed() {
+ if ( // https://issuetracker.google.com/issues/139738913
+ Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
+ isTaskRoot &&
+ supportFragmentManager.backStackEntryCount == 0
+ ) {
+ finishAfterTransition()
+ } else {
+ super.onBackPressed()
+ }
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
similarity index 58%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
index 9c81104a1..c8c4051b3 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -1,64 +1,54 @@
-package org.koitharu.kotatsu.core.ui
+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.activity.OnBackPressedDispatcher
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.ui.dialog.AppBottomSheetDialog
-import org.koitharu.kotatsu.core.util.ext.findActivity
-import org.koitharu.kotatsu.core.util.ext.getDisplaySize
+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() {
- var viewBinding: B? = null
- private set
+ private var viewBinding: B? = null
- @Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
- get() = requireViewBinding()
+ get() = checkNotNull(viewBinding)
protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior
- val isExpanded: Boolean
- get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
-
- val onBackPressedDispatcher: OnBackPressedDispatcher
- get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
-
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
- savedInstanceState: Bundle?,
+ savedInstanceState: Bundle?
): View {
- val binding = onCreateViewBinding(inflater, container)
+ val binding = onInflateView(inflater, container)
viewBinding = binding
- return binding.root
- }
- final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val binding = requireViewBinding()
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
- // Set peek height to 40% display height
- binding.root.context.findActivity()?.getDisplaySize()?.let {
- behavior?.peekHeight = (it.height() * 0.4).toInt()
+
+ // Set peek height to 50% display height
+ requireContext().displayCompat?.let {
+ val metrics = DisplayMetrics()
+ it.getRealMetrics(metrics)
+ behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
}
- onViewBindingCreated(binding, savedInstanceState)
+
+ return binding.root
}
override fun onDestroyView() {
@@ -79,9 +69,7 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
}
}
- protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
-
- protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
+ protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
val b = behavior ?: return
@@ -95,8 +83,4 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
}
b.isDraggable = !isLocked
}
-
- fun requireViewBinding(): B = checkNotNull(viewBinding) {
- "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
- }
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
similarity index 51%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
index 6dfdadf1d..17c8931b1 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui
import android.os.Bundle
import android.view.LayoutInflater
@@ -6,45 +6,41 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
+import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
+import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
-import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
-import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
-@Suppress("LeakingThis")
abstract class BaseFragment :
Fragment(),
WindowInsetsDelegate.WindowInsetsListener {
- var viewBinding: B? = null
- private set
+ private var viewBinding: B? = null
- @Deprecated("", ReplaceWith("requireViewBinding()"))
protected val binding: B
- get() = requireViewBinding()
+ get() = checkNotNull(viewBinding)
- @JvmField
+ @Suppress("LeakingThis")
protected val exceptionResolver = ExceptionResolver(this)
- @JvmField
+ @Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
- final override fun onCreateView(
+ override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View {
- val binding = onCreateViewBinding(inflater, container)
+ ): View? {
+ val binding = onInflateView(inflater, container)
viewBinding = binding
return binding.root
}
- final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view)
- onViewBindingCreated(requireViewBinding(), savedInstanceState)
}
override fun onDestroyView() {
@@ -53,14 +49,7 @@ abstract class BaseFragment :
super.onDestroyView()
}
- fun requireViewBinding(): B = checkNotNull(viewBinding) {
- "Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
- }
-
- @Deprecated("", ReplaceWith("viewBinding"))
protected fun bindingOrNull() = viewBinding
- protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
-
- protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
-}
+ protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt
similarity index 97%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt
index 96a240e55..e43ca8877 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFullscreenActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFullscreenActivity.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui
import android.graphics.Color
import android.os.Build
@@ -56,4 +56,4 @@ abstract class BaseFullscreenActivity :
}
protected open fun onSystemUiVisibilityChanged(isVisible: Boolean) = Unit
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
similarity index 79%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
index bfffb7ab3..7db0f6e22 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui
import android.os.Bundle
import android.view.View
@@ -8,24 +8,20 @@ import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
-import dagger.hilt.android.AndroidEntryPoint
+import org.koin.android.ext.android.inject
+import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
+import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
-import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
-import javax.inject.Inject
-@Suppress("LeakingThis")
-@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner {
- @Inject
- lateinit var settings: AppSettings
+ protected val settings by inject(mode = LazyThreadSafetyMode.NONE)
- @JvmField
+ @Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
override val recyclerView: RecyclerView
@@ -52,7 +48,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
- bottom = insets.bottom,
+ bottom = insets.bottom
)
}
@@ -61,4 +57,4 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt
new file mode 100644
index 000000000..05e07729e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseService.kt
@@ -0,0 +1,5 @@
+package org.koitharu.kotatsu.base.ui
+
+import androidx.lifecycle.LifecycleService
+
+abstract class BaseService : LifecycleService()
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
index 12cda4167..f17e3aa9f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
@@ -1,26 +1,18 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import org.koitharu.kotatsu.core.ui.util.CountedBooleanLiveData
-import org.koitharu.kotatsu.core.util.SingleLiveEvent
-import org.koitharu.kotatsu.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.*
+import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
abstract class BaseViewModel : ViewModel() {
- @JvmField
protected val loadingCounter = CountedBooleanLiveData()
-
- @JvmField
protected val errorEvent = SingleLiveEvent()
val onError: LiveData
@@ -54,4 +46,4 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.postCall(throwable)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
new file mode 100644
index 000000000..241d13f94
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
@@ -0,0 +1,37 @@
+package org.koitharu.kotatsu.base.ui
+
+import android.app.Service
+import android.content.Intent
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+abstract class CoroutineIntentService : BaseService() {
+
+ private val mutex = Mutex()
+ protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+ launchCoroutine(intent, startId)
+ return Service.START_REDELIVER_INTENT
+ }
+
+ private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
+ mutex.withLock {
+ try {
+ withContext(dispatcher) {
+ processIntent(intent)
+ }
+ } finally {
+ stopSelf(startId)
+ }
+ }
+ }
+
+ protected abstract suspend fun processIntent(intent: Intent?)
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AppBottomSheetDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
similarity index 95%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AppBottomSheetDialog.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
index f76e27d11..8b6da8d3d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AppBottomSheetDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.dialog
+package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.graphics.Color
@@ -26,4 +26,4 @@ class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(con
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt
similarity index 97%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt
index f246aba42..c452bd1ce 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CheckBoxAlertDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/CheckBoxAlertDialog.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.dialog
+package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
@@ -77,4 +77,4 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
fun create() = CheckBoxAlertDialog(delegate.create())
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
similarity index 98%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
index efc47ffde..58e353ca8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/StorageSelectDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.dialog
+package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
@@ -98,4 +98,4 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
fun onStorageSelected(file: File)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt
similarity index 91%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt
index a9e6e13ea..650e816c5 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/AdapterDelegateClickListenerAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterDelegateClickListenerAdapter.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import android.view.View
import android.view.View.OnClickListener
@@ -6,7 +6,7 @@ import android.view.View.OnLongClickListener
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
class AdapterDelegateClickListenerAdapter(
- private val adapterDelegate: AdapterDelegateViewBindingViewHolder,
+ private val adapterDelegate: AdapterDelegateViewBindingViewHolder,
private val clickListener: OnListItemClickListener,
) : OnClickListener, OnLongClickListener {
@@ -17,4 +17,4 @@ class AdapterDelegateClickListenerAdapter(
override fun onLongClick(v: View): Boolean {
return clickListener.onItemLongClick(adapterDelegate.item, v)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt
similarity index 90%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt
index f9d41fec8..4a412d081 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/BoundsScrollListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BoundsScrollListener.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@@ -6,27 +6,26 @@ import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() {
+ constructor(offset: Int = 0) : this(offset, offset)
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
- if (recyclerView.hasPendingAdapterUpdates()) {
- return
- }
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
+ if (firstVisibleItemPosition <= offsetTop) {
+ onScrolledToStart(recyclerView)
+ }
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView)
}
- if (firstVisibleItemPosition <= offsetTop) {
- onScrolledToStart(recyclerView)
- }
}
abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt
index ddb94ce34..fc6564beb 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightGridLayoutManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightGridLayoutManager.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import android.content.Context
import android.util.AttributeSet
@@ -34,4 +34,4 @@ class FitHeightGridLayoutManager : GridLayoutManager {
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt
index f4a36a227..64e73198a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/FitHeightLinearLayoutManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/FitHeightLinearLayoutManager.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import android.content.Context
import android.util.AttributeSet
@@ -34,4 +34,4 @@ class FitHeightLinearLayoutManager : LinearLayoutManager {
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt
similarity index 57%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt
index e394740b9..f39b81d14 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/OnListItemClickListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/OnListItemClickListener.kt
@@ -1,10 +1,10 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import android.view.View
-fun interface OnListItemClickListener {
+interface OnListItemClickListener {
fun onItemClick(item: I, view: View)
fun onItemLongClick(item: I, view: View) = false
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt
similarity index 89%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt
index 4f70dcd4d..5681cae23 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/PaginationScrollListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/PaginationScrollListener.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list
+package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
@@ -15,4 +15,4 @@ class PaginationScrollListener(offset: Int, private val callback: Callback) :
fun onScrolledToEnd()
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractDividerItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt
similarity index 95%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractDividerItemDecoration.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt
index ca4bbec76..2d91e71c7 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractDividerItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list.decor
+package org.koitharu.kotatsu.base.ui.list.decor
import android.annotation.SuppressLint
import android.content.Context
@@ -59,7 +59,7 @@ abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.It
left,
parent.paddingTop.toFloat(),
right,
- (parent.height - parent.paddingBottom).toFloat(),
+ (parent.height - parent.paddingBottom).toFloat()
)
} else {
left = 0f
@@ -84,4 +84,4 @@ abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.It
above: RecyclerView.ViewHolder,
below: RecyclerView.ViewHolder,
): Boolean
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
index 20e3aef78..1974f6a5d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/AbstractSelectionItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list.decor
+package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Canvas
import android.graphics.Rect
@@ -67,7 +67,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
if (parent.clipToPadding) {
canvas.clipRect(
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
- parent.height - parent.paddingBottom,
+ parent.height - parent.paddingBottom
)
}
@@ -108,4 +108,4 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
bounds: RectF,
state: RecyclerView.State,
) = Unit
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
similarity index 82%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
index 88f3593ac..b86a87dce 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/SpacingItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SpacingItemDecoration.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list.decor
+package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Rect
import android.view.View
@@ -11,8 +11,8 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
outRect: Rect,
view: View,
parent: RecyclerView,
- state: RecyclerView.State,
+ state: RecyclerView.State
) {
outRect.set(spacing, spacing, spacing, spacing)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/TypedSpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt
similarity index 94%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/TypedSpacingItemDecoration.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt
index 244936dbf..5662f026a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/decor/TypedSpacingItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.list.decor
+package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Rect
import android.util.SparseIntArray
@@ -13,7 +13,7 @@ class TypedSpacingItemDecoration(
) : RecyclerView.ItemDecoration() {
private val mapping = SparseIntArray(spacingMapping.size)
-
+
init {
spacingMapping.forEach { (k, v) -> mapping[k] = v }
}
@@ -32,4 +32,4 @@ class TypedSpacingItemDecoration(
}
outRect.set(spacing, spacing, spacing, spacing)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt
similarity index 82%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt
index 585f39e69..68a65e638 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt
@@ -1,11 +1,10 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
-import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-class ActionModeDelegate : OnBackPressedCallback(false) {
+class ActionModeDelegate {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList? = null
@@ -13,19 +12,13 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
val isActionModeStarted: Boolean
get() = activeActionMode != null
- override fun handleOnBackPressed() {
- activeActionMode?.finish()
- }
-
fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode
- isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) }
}
fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null
- isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) }
}
@@ -54,4 +47,4 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
removeListener(listener)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt
similarity index 78%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt
index fde599ede..0c87ff612 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ActionModeListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
import androidx.appcompat.view.ActionMode
@@ -7,4 +7,4 @@ interface ActionModeListener {
fun onActionModeStarted(mode: ActionMode)
fun onActionModeFinished(mode: ActionMode)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
similarity index 54%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
index f97db54ae..f072da2fa 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/DefaultActivityLifecycleCallbacks.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
@@ -1,12 +1,17 @@
-package org.koitharu.kotatsu.core.ui
+package org.koitharu.kotatsu.base.ui.util
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
+import java.util.*
-interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
+class ActivityRecreationHandle : ActivityLifecycleCallbacks {
- override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
+ private val activities = WeakHashMap()
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ activities[activity] = Unit
+ }
override fun onActivityStarted(activity: Activity) = Unit
@@ -18,5 +23,12 @@ interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
- override fun onActivityDestroyed(activity: Activity) = Unit
-}
+ override fun onActivityDestroyed(activity: Activity) {
+ activities.remove(activity)
+ }
+
+ fun recreateAll() {
+ val snapshot = activities.keys.toList()
+ snapshot.forEach { it.recreate() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
similarity index 92%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
index a737c0af9..d654e541d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/CountedBooleanLiveData.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
@@ -28,4 +28,4 @@ class CountedBooleanLiveData : LiveData(false) {
postValue(false)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt
index f34963f15..9b0976d51 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/RecyclerViewOwner.kt
@@ -1,8 +1,8 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
import androidx.recyclerview.widget.RecyclerView
interface RecyclerViewOwner {
val recyclerView: RecyclerView
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt
similarity index 79%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt
index 8d648ec3a..526c1e986 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/ShrinkOnScrollBehavior.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ShrinkOnScrollBehavior.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
import android.content.Context
import android.util.AttributeSet
@@ -8,10 +8,10 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
import androidx.core.view.ViewCompat
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
-open class ShrinkOnScrollBehavior : Behavior {
+class ShrinkOnScrollBehavior : Behavior {
- constructor() : super()
- constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
+ @Suppress("unused") constructor() : super()
+ @Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
@@ -45,4 +45,4 @@ open class ShrinkOnScrollBehavior : Behavior {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
index a85868857..c0d9c8c53 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.util
+package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.core.graphics.Insets
@@ -10,10 +10,8 @@ class WindowInsetsDelegate(
private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
- @JvmField
var handleImeInsets: Boolean = false
- @JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private var lastInsets: Insets? = null
@@ -65,4 +63,4 @@ class WindowInsetsDelegate(
fun onWindowInsetsChanged(insets: Insets)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt
new file mode 100644
index 000000000..77d4acc2c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableButtonGroup.kt
@@ -0,0 +1,42 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.annotation.AttrRes
+import androidx.annotation.IdRes
+import androidx.core.view.children
+import com.google.android.material.button.MaterialButton
+
+class CheckableButtonGroup @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = 0,
+) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
+
+ var onCheckedChangeListener: OnCheckedChangeListener? = null
+
+ override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
+ if (child is MaterialButton) {
+ child.setOnClickListener(this)
+ }
+ super.addView(child, index, params)
+ }
+
+ override fun onClick(v: View) {
+ setCheckedId(v.id)
+ }
+
+ fun setCheckedId(@IdRes viewRes: Int) {
+ children.forEach {
+ (it as? MaterialButton)?.isChecked = it.id == viewRes
+ }
+ onCheckedChangeListener?.onCheckedChanged(this, viewRes)
+ }
+
+ fun interface OnCheckedChangeListener {
+ fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
similarity index 89%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
index b872917c0..5d601d67c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CheckableImageView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.widgets
+package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.os.Parcel
@@ -10,7 +10,6 @@ import android.widget.Checkable
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.ParcelCompat
-import androidx.customview.view.AbsSavedState
class CheckableImageView @JvmOverloads constructor(
context: Context,
@@ -74,7 +73,7 @@ class CheckableImageView @JvmOverloads constructor(
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
}
- private class SavedState : AbsSavedState {
+ private class SavedState : BaseSavedState {
val isChecked: Boolean
@@ -82,7 +81,7 @@ class CheckableImageView @JvmOverloads constructor(
isChecked = checked
}
- constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) {
+ constructor(source: Parcel) : super(source) {
isChecked = ParcelCompat.readBoolean(source)
}
@@ -92,13 +91,12 @@ class CheckableImageView @JvmOverloads constructor(
}
companion object {
- @Suppress("unused")
@JvmField
val CREATOR: Creator = object : Creator {
- override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader)
+ override fun createFromParcel(`in`: Parcel) = SavedState(`in`)
override fun newArray(size: Int): Array = arrayOfNulls(size)
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
similarity index 55%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
index 3ad0838ad..c20b615cf 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
@@ -1,38 +1,29 @@
-package org.koitharu.kotatsu.core.ui.widgets
+package org.koitharu.kotatsu.base.ui.widgets
-import android.annotation.SuppressLint
import android.content.Context
-import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.View.OnClickListener
-import androidx.annotation.ColorRes
-import androidx.core.content.ContextCompat
-import androidx.core.content.res.getColorStateListOrThrow
+import androidx.annotation.DrawableRes
import androidx.core.view.children
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.util.ext.castOrNull
-import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
+ defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle
) : ChipGroup(context, attrs, defStyleAttr) {
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
- private val chipOnClickListener = OnClickListener {
+ private var chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
- private val chipOnCloseListener = OnClickListener {
+ private var chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
- private val defaultChipStrokeColor: ColorStateList
- private val defaultChipTextColor: ColorStateList
- private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
@@ -46,15 +37,6 @@ class ChipsView @JvmOverloads constructor(
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
}
- init {
- @SuppressLint("CustomViewStyleable")
- val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip)
- defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor)
- defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
- defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint)
- a.recycle()
- }
-
override fun requestLayout() {
if (isLayoutSuppressedCompat) {
isLayoutCalledOnSuppressed = true
@@ -78,30 +60,15 @@ class ChipsView @JvmOverloads constructor(
}
}
- fun getCheckedData(cls: Class): Set {
- val result = LinkedHashSet(childCount)
- for (child in children) {
- if (child is Chip && child.isChecked) {
- result += cls.castOrNull(child.tag) ?: continue
- }
- }
- return result
- }
-
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
- val tint = if (model.tint == 0) {
- null
+ if (model.icon == 0) {
+ chip.isChipIconVisible = false
} else {
- ContextCompat.getColorStateList(context, model.tint)
+ chip.isCheckedIconVisible = true
+ chip.setChipIconResource(model.icon)
}
- chip.chipIconTint = tint ?: defaultChipIconTint
- chip.checkedIconTint = tint ?: defaultChipIconTint
- chip.chipStrokeColor = tint ?: defaultChipStrokeColor
- chip.setTextColor(tint ?: defaultChipTextColor)
- chip.isClickable = onChipClickListener != null || model.isCheckable
- chip.isCheckable = model.isCheckable
- chip.isChecked = model.isChecked
+ chip.isClickable = onChipClickListener != null
chip.tag = model.data
}
@@ -109,14 +76,11 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
- chip.isCheckedIconVisible = true
- chip.isChipIconVisible = false
- chip.setCheckedIconResource(R.drawable.ic_check)
- chip.checkedIconTint = defaultChipIconTint
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
+ chip.isCheckable = false
addView(chip)
return chip
}
@@ -132,11 +96,9 @@ class ChipsView @JvmOverloads constructor(
}
class ChipModel(
- @ColorRes val tint: Int,
+ @DrawableRes val icon: Int,
val title: CharSequence,
- val isCheckable: Boolean,
- val isChecked: Boolean,
- val data: Any? = null,
+ val data: Any? = null
) {
override fun equals(other: Any?): Boolean {
@@ -145,19 +107,17 @@ class ChipsView @JvmOverloads constructor(
other as ChipModel
- if (tint != other.tint) return false
+ if (icon != other.icon) return false
if (title != other.title) return false
- if (isCheckable != other.isCheckable) return false
- if (isChecked != other.isChecked) return false
- return data == other.data
+ if (data != other.data) return false
+
+ return true
}
override fun hashCode(): Int {
- var result = tint.hashCode()
+ var result = icon
result = 31 * result + title.hashCode()
- result = 31 * result + isCheckable.hashCode()
- result = 31 * result + isChecked.hashCode()
- result = 31 * result + (data?.hashCode() ?: 0)
+ result = 31 * result + data.hashCode()
return result
}
}
@@ -171,4 +131,4 @@ class ChipsView @JvmOverloads constructor(
fun onChipCloseClick(chip: Chip, data: Any?)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt
index 9bcd4ca60..3a52eb237 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/CoverImageView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.widgets
+package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
@@ -40,4 +40,4 @@ class CoverImageView @JvmOverloads constructor(
}
setMeasuredDimension(desiredWidth, desiredHeight)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
new file mode 100644
index 000000000..909252b48
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2018 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import androidx.annotation.ColorInt
+import androidx.annotation.StringRes
+import androidx.core.graphics.drawable.DrawableCompat
+import androidx.core.view.postDelayed
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import com.google.android.material.snackbar.Snackbar
+import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
+import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
+import com.google.android.material.R as materialR
+
+private const val ENTER_DURATION = 300L
+private const val EXIT_DURATION = 200L
+private const val SHORT_DURATION_MS = 1_500L
+private const val LONG_DURATION_MS = 2_750L
+
+/**
+ * A custom snackbar implementation allowing more control over placement and entry/exit animations.
+ *
+ * Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
+ *
+ * https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
+ */
+class FadingSnackbar @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
+
+ init {
+ binding.snackbarLayout.background = createThemedBackground()
+ }
+
+ fun dismiss() {
+ if (visibility == VISIBLE && alpha == 1f) {
+ animate()
+ .alpha(0f)
+ .withEndAction { visibility = GONE }
+ .duration = EXIT_DURATION
+ }
+ }
+
+ fun show(
+ messageText: CharSequence?,
+ @StringRes actionId: Int = 0,
+ duration: Int = Snackbar.LENGTH_SHORT,
+ onActionClick: (FadingSnackbar.() -> Unit)? = null,
+ onDismiss: (() -> Unit)? = null,
+ ) {
+ binding.snackbarText.text = messageText
+ if (actionId != 0) {
+ with(binding.snackbarAction) {
+ visibility = VISIBLE
+ text = context.getString(actionId)
+ setOnClickListener {
+ onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
+ }
+ }
+ } else {
+ binding.snackbarAction.visibility = GONE
+ }
+ alpha = 0f
+ visibility = VISIBLE
+ animate()
+ .alpha(1f)
+ .duration = ENTER_DURATION
+ if (duration == Snackbar.LENGTH_INDEFINITE) {
+ return
+ }
+ val durationMs = ENTER_DURATION + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
+ postDelayed(durationMs) {
+ dismiss()
+ onDismiss?.invoke()
+ }
+ }
+
+ private fun createThemedBackground(): Drawable {
+ val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
+ val shapeAppearanceModel = ShapeAppearanceModel.builder(
+ context,
+ materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
+ 0
+ ).build()
+ val background = createMaterialShapeDrawableBackground(
+ backgroundColor,
+ shapeAppearanceModel,
+ )
+ val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
+ return if (backgroundTint != null) {
+ val wrappedDrawable = DrawableCompat.wrap(background)
+ DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
+ wrappedDrawable
+ } else {
+ DrawableCompat.wrap(background)
+ }
+ }
+
+ private fun createMaterialShapeDrawableBackground(
+ @ColorInt backgroundColor: Int,
+ shapeAppearanceModel: ShapeAppearanceModel,
+ ): MaterialShapeDrawable {
+ val background = MaterialShapeDrawable(shapeAppearanceModel)
+ background.fillColor = ColorStateList.valueOf(backgroundColor)
+ return background
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
similarity index 91%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
index 71d9314f7..bdaf8f476 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ListItemTextView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.widgets
+package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
@@ -9,17 +9,16 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
-import android.graphics.drawable.shapes.RoundRectShape
+import android.graphics.drawable.shapes.RectShape
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView
-import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.util.ext.resolveDp
+import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
@SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor(
@@ -39,11 +38,10 @@ class ListItemTextView @JvmOverloads constructor(
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
val itemRippleColor = getRippleColor(context)
val shape = createShapeDrawable(this)
- val roundCorners = FloatArray(8) { resources.resolveDp(32f) }
background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
shape,
- ShapeDrawable(RoundRectShape(roundCorners, null, null)),
+ ShapeDrawable(RectShape()),
)
checkedDrawableStart = getDrawable(R.styleable.ListItemTextView_checkedDrawableStart)
checkedDrawableEnd = getDrawable(R.styleable.ListItemTextView_checkedDrawableEnd)
@@ -120,7 +118,7 @@ class ListItemTextView @JvmOverloads constructor(
}
private fun getRippleColor(context: Context): ColorStateList {
- return ContextCompat.getColorStateList(context, R.color.selector_overlay)
+ return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt
new file mode 100644
index 000000000..589d75382
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SquareLayout.kt
@@ -0,0 +1,14 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.FrameLayout
+
+class SquareLayout @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt
rename to app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
index 57870cf19..0d915dc4f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
@@ -1,5 +1,6 @@
-package org.koitharu.kotatsu.core.ui.widgets
+package org.koitharu.kotatsu.base.ui.widgets
+import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
@@ -20,7 +21,8 @@ class WindowInsetHolder @JvmOverloads constructor(
private var desiredHeight = 0
private var desiredWidth = 0
- override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
+ @SuppressLint("RtlHardcoded")
+ override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
@@ -39,26 +41,24 @@ class WindowInsetHolder @JvmOverloads constructor(
desiredHeight = newHeight
requestLayout()
}
- return super.onApplyWindowInsets(insets)
+ return super.dispatchApplyWindowInsets(insets)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
- val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
- val heightSize = MeasureSpec.getSize(heightMeasureSpec)
-
- val width: Int = when (widthMode) {
- MeasureSpec.EXACTLY -> widthSize
- MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
- else -> desiredWidth
- }
- val height = when (heightMode) {
- MeasureSpec.EXACTLY -> heightSize
- MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
- else -> desiredHeight
- }
- setMeasuredDimension(width, height)
+ super.onMeasure(
+ if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
+ widthMeasureSpec
+ } else {
+ MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
+ },
+ if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
+ heightMeasureSpec
+ } else {
+ MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
+ },
+ )
}
private fun getLayoutGravity(): Int {
@@ -69,4 +69,4 @@ class WindowInsetHolder @JvmOverloads constructor(
else -> Gravity.NO_GRAVITY
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
new file mode 100644
index 000000000..4a8294765
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
@@ -0,0 +1,10 @@
+package org.koitharu.kotatsu.bookmarks
+
+import org.koin.dsl.module
+import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
+
+val bookmarksModule
+ get() = module {
+
+ factory { BookmarksRepository(get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
index f4bbba185..1f348899b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
),
]
)
-data class BookmarkEntity(
+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,
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/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
similarity index 62%
rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
index 076b19a3c..dd023be7a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
@@ -1,27 +1,20 @@
package org.koitharu.kotatsu.bookmarks.data
-import androidx.room.*
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
import kotlinx.coroutines.flow.Flow
-import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
- @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
- abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
-
@Query("SELECT * FROM bookmarks WHERE 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>
- @Transaction
- @Query(
- "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at"
- )
- abstract fun observe(): Flow>>
-
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
similarity index 74%
rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
index a8b2e0912..0ab69dd18 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
@@ -1,9 +1,15 @@
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,
@@ -24,10 +30,4 @@ fun Bookmark.toEntity() = BookmarkEntity(
imageUrl = imageUrl,
createdAt = createdAt.time,
percent = percent,
-)
-
-fun Collection.toBookmarks(manga: Manga) = map {
- it.toBookmark(manga)
-}
-
-fun Collection.ids() = map { it.pageId }
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
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/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt
similarity index 74%
rename from app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt
index d47bbb785..92040bc97 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt
@@ -1,11 +1,11 @@
-package org.koitharu.kotatsu.bookmarks.ui.adapter
+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
-import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
class BookmarksAdapter(
coil: ImageLoader,
@@ -13,13 +13,13 @@ class BookmarksAdapter(
clickListener: OnListItemClickListener,
) : AsyncListDifferDelegationAdapter(
DiffCallback(),
- bookmarkListAD(coil, lifecycleOwner, clickListener),
+ 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.pageId == newItem.pageId
+ return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
@@ -27,4 +27,4 @@ class BookmarksAdapter(
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
similarity index 66%
rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
index f84ca4899..442808bad 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
@@ -11,35 +11,28 @@ import android.view.MenuItem
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
+import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
-import org.koitharu.kotatsu.core.ui.BaseActivity
-import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
-import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity(), BrowserCallback {
- private lateinit var onBackPressedCallback: WebViewBackPressedCallback
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
- return
- }
+ setContentView(ActivityBrowserBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
- with(viewBinding.webView.settings) {
+ with(binding.webView.settings) {
javaScriptEnabled = true
- userAgentString = CommonHeadersInterceptor.userAgentChrome
+ userAgentString = UserAgentInterceptor.userAgent
}
- viewBinding.webView.webViewClient = BrowserClient(this)
- viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
- onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
- onBackPressedDispatcher.addCallback(onBackPressedCallback)
+ binding.webView.webViewClient = BrowserClient(this)
+ binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
if (savedInstanceState != null) {
return
}
@@ -49,65 +42,70 @@ class BrowserActivity : BaseActivity(), BrowserCallback
} else {
onTitleChanged(
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
- url,
+ url
)
- viewBinding.webView.loadUrl(url)
+ binding.webView.loadUrl(url)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
- viewBinding.webView.saveState(outState)
+ binding.webView.saveState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
- viewBinding.webView.restoreState(savedInstanceState)
+ binding.webView.restoreState(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
- super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu)
- return true
+ return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
- viewBinding.webView.stopLoading()
+ binding.webView.stopLoading()
finishAfterTransition()
true
}
-
R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW)
- intent.data = Uri.parse(viewBinding.webView.url)
+ intent.data = Uri.parse(binding.webView.url)
try {
startActivity(Intent.createChooser(intent, item.title))
} catch (_: ActivityNotFoundException) {
}
true
}
-
else -> super.onOptionsItemSelected(item)
}
+ override fun onBackPressed() {
+ if (binding.webView.canGoBack()) {
+ binding.webView.goBack()
+ } else {
+ super.onBackPressed()
+ }
+ }
+
override fun onPause() {
- viewBinding.webView.onPause()
+ binding.webView.onPause()
super.onPause()
}
override fun onResume() {
super.onResume()
- viewBinding.webView.onResume()
+ binding.webView.onResume()
}
override fun onDestroy() {
super.onDestroy()
- viewBinding.webView.destroy()
+ binding.webView.destroy()
}
override fun onLoadingStateChanged(isLoading: Boolean) {
- viewBinding.progressBar.isVisible = isLoading
+ binding.progressBar.isVisible = isLoading
}
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
@@ -115,15 +113,13 @@ class BrowserActivity : BaseActivity(), BrowserCallback
supportActionBar?.subtitle = subtitle
}
- override fun onHistoryChanged() {
- onBackPressedCallback.onHistoryChanged()
- }
-
override fun onWindowInsetsChanged(insets: Insets) {
- viewBinding.appbar.updatePadding(
+ binding.appbar.updatePadding(
top = insets.top,
+ left = insets.left,
+ right = insets.right,
)
- viewBinding.root.updatePadding(
+ binding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
@@ -140,4 +136,4 @@ class BrowserActivity : BaseActivity(), BrowserCallback
.putExtra(EXTRA_TITLE, title)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt
rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt
index b85fdaa39..e6f3fae84 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserCallback.kt
@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.browser
-interface BrowserCallback : OnHistoryChangedListener {
+interface BrowserCallback {
fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
similarity index 66%
rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
rename to app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
index e6906014e..5e801a93f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
@@ -2,9 +2,10 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap
import android.webkit.WebView
-import android.webkit.WebViewClient
+import org.koin.core.component.KoinComponent
+import org.koitharu.kotatsu.core.network.WebViewClientCompat
-open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
+class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
@@ -20,9 +21,4 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient(
super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url)
}
-
- override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
- super.doUpdateVisitedHistory(view, url, isReload)
- callback.onHistoryChanged()
- }
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
similarity index 94%
rename from app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
rename to app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
index 55bdc9707..1b29d7339 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/ProgressChromeClient.kt
@@ -24,7 +24,7 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else {
- progressIndicator.isIndeterminate = true
+ progressIndicator.setIndeterminate(true)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt
new file mode 100644
index 000000000..aedd22605
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.browser.cloudflare
+
+interface CloudFlareCallback {
+
+ fun onPageLoaded()
+
+ fun onCheckPassed()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt
new file mode 100644
index 000000000..56eef7520
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.browser.cloudflare
+
+import android.graphics.Bitmap
+import android.webkit.WebView
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.koitharu.kotatsu.core.network.AndroidCookieJar
+import org.koitharu.kotatsu.core.network.WebViewClientCompat
+
+private const val CF_CLEARANCE = "cf_clearance"
+
+class CloudFlareClient(
+ private val cookieJar: AndroidCookieJar,
+ private val callback: CloudFlareCallback,
+ private val targetUrl: String
+) : WebViewClientCompat() {
+
+ private val oldClearance = getCookieValue(CF_CLEARANCE)
+
+ override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
+ super.onPageStarted(view, url, favicon)
+ checkClearance()
+ }
+
+ override fun onPageCommitVisible(view: WebView?, url: String?) {
+ super.onPageCommitVisible(view, url)
+ callback.onPageLoaded()
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ callback.onPageLoaded()
+ }
+
+ private fun checkClearance() {
+ val clearance = getCookieValue(CF_CLEARANCE)
+ if (clearance != null && clearance != oldClearance) {
+ callback.onCheckPassed()
+ }
+ }
+
+ private fun getCookieValue(name: String): String? {
+ return cookieJar.loadForRequest(targetUrl.toHttpUrl())
+ .find { it.name == name }?.value
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt
new file mode 100644
index 000000000..8c1de2625
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/cloudflare/CloudFlareDialog.kt
@@ -0,0 +1,93 @@
+package org.koitharu.kotatsu.browser.cloudflare
+
+import android.annotation.SuppressLint
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.CookieManager
+import android.webkit.WebSettings
+import androidx.core.view.isInvisible
+import androidx.fragment.app.setFragmentResult
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koin.android.ext.android.get
+import org.koitharu.kotatsu.base.ui.AlertDialogFragment
+import org.koitharu.kotatsu.core.network.UserAgentInterceptor
+import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
+import org.koitharu.kotatsu.utils.ext.stringArgument
+import org.koitharu.kotatsu.utils.ext.withArgs
+
+class CloudFlareDialog : AlertDialogFragment(), CloudFlareCallback {
+
+ private val url by stringArgument(ARG_URL)
+ private val pendingResult = Bundle(1)
+
+ override fun onInflateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?
+ ) = FragmentCloudflareBinding.inflate(inflater, container, false)
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ with(binding.webView.settings) {
+ javaScriptEnabled = true
+ cacheMode = WebSettings.LOAD_DEFAULT
+ domStorageEnabled = true
+ databaseEnabled = true
+ userAgentString = UserAgentInterceptor.userAgent
+ }
+ binding.webView.webViewClient = CloudFlareClient(get(), this, url.orEmpty())
+ CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
+ if (url.isNullOrEmpty()) {
+ dismissAllowingStateLoss()
+ } else {
+ binding.webView.loadUrl(url.orEmpty())
+ }
+ }
+
+ override fun onDestroyView() {
+ binding.webView.stopLoading()
+ super.onDestroyView()
+ }
+
+ override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
+ builder.setNegativeButton(android.R.string.cancel, null)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ binding.webView.onResume()
+ }
+
+ override fun onPause() {
+ binding.webView.onPause()
+ super.onPause()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ setFragmentResult(TAG, pendingResult)
+ super.onDismiss(dialog)
+ }
+
+ override fun onPageLoaded() {
+ bindingOrNull()?.progressBar?.isInvisible = true
+ }
+
+ override fun onCheckPassed() {
+ pendingResult.putBoolean(EXTRA_RESULT, true)
+ dismiss()
+ }
+
+ companion object {
+
+ const val TAG = "CloudFlareDialog"
+ const val EXTRA_RESULT = "result"
+ private const val ARG_URL = "url"
+
+ fun newInstance(url: String) = CloudFlareDialog().withArgs(1) {
+ putString(ARG_URL, url)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
new file mode 100644
index 000000000..7212b8569
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
@@ -0,0 +1,136 @@
+package org.koitharu.kotatsu.core.backup
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteEntity
+import org.koitharu.kotatsu.history.data.HistoryEntity
+
+private const val PAGE_SIZE = 10
+
+class BackupRepository(private val db: MangaDatabase) {
+
+ suspend fun dumpHistory(): BackupEntry {
+ var offset = 0
+ val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
+ while (true) {
+ val history = db.historyDao.findAll(offset, PAGE_SIZE)
+ if (history.isEmpty()) {
+ break
+ }
+ offset += history.size
+ for (item in history) {
+ val manga = item.manga.toJson()
+ val tags = JSONArray()
+ item.tags.forEach { tags.put(it.toJson()) }
+ manga.put("tags", tags)
+ val json = item.history.toJson()
+ json.put("manga", manga)
+ entry.data.put(json)
+ }
+ }
+ return entry
+ }
+
+ suspend fun dumpCategories(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
+ val categories = db.favouriteCategoriesDao.findAll()
+ for (item in categories) {
+ entry.data.put(item.toJson())
+ }
+ return entry
+ }
+
+ suspend fun dumpFavourites(): BackupEntry {
+ var offset = 0
+ val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
+ while (true) {
+ val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
+ if (favourites.isEmpty()) {
+ break
+ }
+ offset += favourites.size
+ for (item in favourites) {
+ val manga = item.manga.toJson()
+ val tags = JSONArray()
+ item.tags.forEach { tags.put(it.toJson()) }
+ manga.put("tags", tags)
+ val json = item.favourite.toJson()
+ json.put("manga", manga)
+ entry.data.put(json)
+ }
+ }
+ return entry
+ }
+
+ fun createIndex(): BackupEntry {
+ val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
+ val json = JSONObject()
+ json.put("app_id", BuildConfig.APPLICATION_ID)
+ json.put("app_version", BuildConfig.VERSION_CODE)
+ json.put("created_at", System.currentTimeMillis())
+ entry.data.put(json)
+ return entry
+ }
+
+ private fun MangaEntity.toJson(): JSONObject {
+ val jo = JSONObject()
+ jo.put("id", id)
+ jo.put("title", title)
+ jo.put("alt_title", altTitle)
+ jo.put("url", url)
+ jo.put("public_url", publicUrl)
+ jo.put("rating", rating)
+ jo.put("nsfw", isNsfw)
+ jo.put("cover_url", coverUrl)
+ jo.put("large_cover_url", largeCoverUrl)
+ jo.put("state", state)
+ jo.put("author", author)
+ jo.put("source", source)
+ return jo
+ }
+
+ private fun TagEntity.toJson(): JSONObject {
+ val jo = JSONObject()
+ jo.put("id", id)
+ jo.put("title", title)
+ jo.put("key", key)
+ jo.put("source", source)
+ return jo
+ }
+
+ private fun HistoryEntity.toJson(): JSONObject {
+ val jo = JSONObject()
+ jo.put("manga_id", mangaId)
+ jo.put("created_at", createdAt)
+ jo.put("updated_at", updatedAt)
+ jo.put("chapter_id", chapterId)
+ jo.put("page", page)
+ jo.put("scroll", scroll)
+ jo.put("percent", percent)
+ return jo
+ }
+
+ private fun FavouriteCategoryEntity.toJson(): JSONObject {
+ val jo = JSONObject()
+ jo.put("category_id", categoryId)
+ jo.put("created_at", createdAt)
+ jo.put("sort_key", sortKey)
+ jo.put("title", title)
+ jo.put("order", order)
+ jo.put("track", track)
+ return jo
+ }
+
+ private fun FavouriteEntity.toJson(): JSONObject {
+ val jo = JSONObject()
+ jo.put("manga_id", mangaId)
+ jo.put("category_id", categoryId)
+ jo.put("created_at", createdAt)
+ return jo
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
similarity index 92%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
index c06f45a76..8a6217d04 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
@@ -5,11 +5,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.core.zip.ZipOutput
+import org.koitharu.kotatsu.utils.ext.format
import java.io.File
-import java.util.Date
-import java.util.Locale
+import java.util.*
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
@@ -43,4 +42,4 @@ suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptibl
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
new file mode 100644
index 000000000..4e20b955f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
@@ -0,0 +1,113 @@
+package org.koitharu.kotatsu.core.backup
+
+import androidx.room.withTransaction
+import org.json.JSONObject
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteEntity
+import org.koitharu.kotatsu.history.data.HistoryEntity
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.json.*
+
+class RestoreRepository(private val db: MangaDatabase) {
+
+ suspend fun upsertHistory(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = parseManga(mangaJson)
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ parseTag(it)
+ }
+ val history = parseHistory(item)
+ result += runCatching {
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga, tags)
+ db.historyDao.upsert(history)
+ }
+ }
+ }
+ return result
+ }
+
+ suspend fun upsertCategories(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val category = parseCategory(item)
+ result += runCatching {
+ db.favouriteCategoriesDao.upsert(category)
+ }
+ }
+ return result
+ }
+
+ suspend fun upsertFavourites(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = parseManga(mangaJson)
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ parseTag(it)
+ }
+ val favourite = parseFavourite(item)
+ result += runCatching {
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga, tags)
+ db.favouritesDao.upsert(favourite)
+ }
+ }
+ }
+ return result
+ }
+
+ private fun parseManga(json: JSONObject) = MangaEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ altTitle = json.getStringOrNull("alt_title"),
+ url = json.getString("url"),
+ publicUrl = json.getStringOrNull("public_url").orEmpty(),
+ rating = json.getDouble("rating").toFloat(),
+ isNsfw = json.getBooleanOrDefault("nsfw", false),
+ coverUrl = json.getString("cover_url"),
+ largeCoverUrl = json.getStringOrNull("large_cover_url"),
+ state = json.getStringOrNull("state"),
+ author = json.getStringOrNull("author"),
+ source = json.getString("source")
+ )
+
+ private fun parseTag(json: JSONObject) = TagEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ key = json.getString("key"),
+ source = json.getString("source")
+ )
+
+ private fun parseHistory(json: JSONObject) = HistoryEntity(
+ mangaId = json.getLong("manga_id"),
+ createdAt = json.getLong("created_at"),
+ updatedAt = json.getLong("updated_at"),
+ chapterId = json.getLong("chapter_id"),
+ page = json.getInt("page"),
+ scroll = json.getDouble("scroll").toFloat(),
+ percent = json.getFloatOrDefault("percent", -1f),
+ )
+
+ private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(
+ categoryId = json.getInt("category_id"),
+ createdAt = json.getLong("created_at"),
+ sortKey = json.getInt("sort_key"),
+ title = json.getString("title"),
+ order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
+ track = json.getBooleanOrDefault("track", true),
+ )
+
+ private fun parseFavourite(json: JSONObject) = FavouriteEntity(
+ mangaId = json.getLong("manga_id"),
+ categoryId = json.getLong("category_id"),
+ createdAt = json.getLong("created_at")
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt
new file mode 100644
index 000000000..215d02259
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.core.db
+
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val databaseModule
+ get() = module {
+ single { MangaDatabase(androidContext()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
similarity index 67%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
index e13b0d26a..6257a8456 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
@@ -10,16 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
- "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
- arrayOf(
- System.currentTimeMillis(),
- 1,
- resources.getString(R.string.read_later),
- SortOrder.NEWEST.name,
- 1,
- 1,
- 0L,
- )
+ "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)",
+ arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1)
)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
similarity index 50%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index 5378e420e..82d5052aa 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -2,13 +2,8 @@ package org.koitharu.kotatsu.core.db
import android.content.Context
import androidx.room.Database
-import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
-import androidx.room.migration.Migration
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.MangaDao
@@ -19,37 +14,21 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
-import org.koitharu.kotatsu.core.db.migrations.Migration10To11
-import org.koitharu.kotatsu.core.db.migrations.Migration11To12
-import org.koitharu.kotatsu.core.db.migrations.Migration12To13
-import org.koitharu.kotatsu.core.db.migrations.Migration13To14
-import org.koitharu.kotatsu.core.db.migrations.Migration14To15
-import org.koitharu.kotatsu.core.db.migrations.Migration1To2
-import org.koitharu.kotatsu.core.db.migrations.Migration2To3
-import org.koitharu.kotatsu.core.db.migrations.Migration3To4
-import org.koitharu.kotatsu.core.db.migrations.Migration4To5
-import org.koitharu.kotatsu.core.db.migrations.Migration5To6
-import org.koitharu.kotatsu.core.db.migrations.Migration6To7
-import org.koitharu.kotatsu.core.db.migrations.Migration7To8
-import org.koitharu.kotatsu.core.db.migrations.Migration8To9
-import org.koitharu.kotatsu.core.db.migrations.Migration9To10
-import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
+import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
-import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
-import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
-const val DATABASE_VERSION = 15
-
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
@@ -57,7 +36,7 @@ const val DATABASE_VERSION = 15
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class,
],
- version = DATABASE_VERSION,
+ version = 12,
)
abstract class MangaDatabase : RoomDatabase() {
@@ -84,35 +63,22 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val scrobblingDao: ScrobblingDao
}
-val databaseMigrations: Array
- get() = arrayOf(
- Migration1To2(),
- Migration2To3(),
- Migration3To4(),
- Migration4To5(),
- Migration5To6(),
- Migration6To7(),
- Migration7To8(),
- Migration8To9(),
- Migration9To10(),
- Migration10To11(),
- Migration11To12(),
- Migration12To13(),
- Migration13To14(),
- Migration14To15(),
- )
-
-fun MangaDatabase(context: Context): MangaDatabase = Room
- .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
- .addMigrations(*databaseMigrations)
- .addCallback(DatabasePrePopulateCallback(context.resources))
- .build()
-
-fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
- val scope = processLifecycleScope
- if (scope.isActive) {
- processLifecycleScope.launch(Dispatchers.Default) {
- removeObserver(observer)
- }
- }
-}
+fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
+ context,
+ MangaDatabase::class.java,
+ "kotatsu-db"
+).addMigrations(
+ Migration1To2(),
+ Migration2To3(),
+ Migration3To4(),
+ Migration4To5(),
+ Migration5To6(),
+ Migration6To7(),
+ Migration7To8(),
+ Migration8To9(),
+ Migration9To10(),
+ Migration10To11(),
+ Migration11To12(),
+).addCallback(
+ DatabasePrePopulateCallback(context.resources)
+).build()
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
similarity index 82%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
index efca0e36b..ee8255dfc 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/MangaDao.kt
@@ -14,11 +14,11 @@ abstract class MangaDao {
abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
- @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
+ @Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List
@Transaction
- @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
+ @Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List
@Insert(onConflict = OnConflictStrategy.IGNORE)
@@ -47,4 +47,4 @@ abstract class MangaDao {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt
new file mode 100644
index 000000000..9a957b206
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/PreferencesDao.kt
@@ -0,0 +1,24 @@
+package org.koitharu.kotatsu.core.db.dao
+
+import androidx.room.*
+import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
+
+@Dao
+abstract class PreferencesDao {
+
+ @Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
+ abstract suspend fun find(mangaId: Long): MangaPrefsEntity?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ abstract suspend fun insert(pref: MangaPrefsEntity): Long
+
+ @Update
+ abstract suspend fun update(pref: MangaPrefsEntity): Int
+
+ @Transaction
+ open suspend fun upsert(pref: MangaPrefsEntity) {
+ if (update(pref) == 0) {
+ insert(pref)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
similarity index 75%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
index e84ffa352..8b3498e3c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TagsDao.kt
@@ -14,7 +14,7 @@ abstract class TagsDao {
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
- LIMIT :limit""",
+ LIMIT :limit"""
)
abstract suspend fun findPopularTags(limit: Int): List
@@ -24,7 +24,7 @@ abstract class TagsDao {
WHERE tags.source = :source
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
- LIMIT :limit""",
+ LIMIT :limit"""
)
abstract suspend fun findPopularTags(source: String, limit: Int): List
@@ -34,7 +34,7 @@ abstract class TagsDao {
WHERE tags.source = :source AND title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
- LIMIT :limit""",
+ LIMIT :limit"""
)
abstract suspend fun findTags(source: String, query: String, limit: Int): List
@@ -44,10 +44,22 @@ abstract class TagsDao {
WHERE title LIKE :query
GROUP BY tags.title
ORDER BY COUNT(manga_id) DESC
- LIMIT :limit""",
+ LIMIT :limit"""
)
abstract suspend fun findTags(query: String, limit: Int): List
- @Upsert
- abstract suspend fun upsert(tags: Iterable)
-}
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ abstract suspend fun insert(tag: TagEntity): Long
+
+ @Update(onConflict = OnConflictStrategy.IGNORE)
+ abstract suspend fun update(tag: TagEntity): Int
+
+ @Transaction
+ open suspend fun upsert(tags: Iterable) {
+ tags.forEach { tag ->
+ if (update(tag) <= 0) {
+ insert(tag)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
similarity index 86%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
index ee0da6165..ade35613b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
-import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@@ -9,8 +8,8 @@ import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
interface TrackLogsDao {
@Transaction
- @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
- fun observeAll(limit: Int): Flow>
+ @Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
+ suspend fun findAll(offset: Int, limit: Int): List
@Query("DELETE FROM track_logs")
suspend fun clear()
@@ -26,4 +25,4 @@ interface TrackLogsDao {
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
similarity index 67%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
index 80bcb6045..af938a813 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
@@ -1,20 +1,16 @@
package org.koitharu.kotatsu.core.db.entity
-import org.koitharu.kotatsu.core.model.MangaSource
-import org.koitharu.kotatsu.core.util.ext.longHashCode
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaState
-import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.koitharu.kotatsu.utils.ext.longHashCode
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
- source = MangaSource(this.source),
+ source = MangaSource.valueOf(this.source),
)
fun Collection.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@@ -23,7 +19,7 @@ fun MangaEntity.toManga(tags: Set) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
- state = this.state?.let { MangaState(it) },
+ state = this.state?.let { MangaState.valueOf(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
@@ -31,8 +27,8 @@ fun MangaEntity.toManga(tags: Set) = Manga(
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
- source = MangaSource(this.source),
- tags = tags,
+ source = MangaSource.valueOf(this.source),
+ tags = tags
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
@@ -58,17 +54,14 @@ fun MangaTag.toEntity() = TagEntity(
title = title,
key = key,
source = source.name,
- id = "${key}_${source.name}".longHashCode(),
+ id = "${key}_${source.name}".longHashCode()
)
fun Collection.toEntities() = map(MangaTag::toEntity)
// Other
+@Suppress("FunctionName")
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
-}.getOrDefault(fallback)
-
-fun MangaState(name: String): MangaState? = runCatching {
- MangaState.valueOf(name)
-}.getOrNull()
+}.getOrDefault(fallback)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
similarity index 83%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
index e2c474399..c425f0e68 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
@@ -3,10 +3,9 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
-import org.koitharu.kotatsu.core.db.TABLE_MANGA
-@Entity(tableName = TABLE_MANGA)
-data class MangaEntity(
+@Entity(tableName = "manga")
+class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@@ -19,5 +18,5 @@ data class MangaEntity(
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
- @ColumnInfo(name = "source") val source: String,
-)
+ @ColumnInfo(name = "source") val source: String
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
similarity index 56%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
index a5ccdb765..a09ccd884 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt
@@ -12,15 +12,12 @@ import androidx.room.PrimaryKey
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
- onDelete = ForeignKey.CASCADE,
- ),
- ],
+ onDelete = ForeignKey.CASCADE
+ )
+ ]
)
-data class MangaPrefsEntity(
+class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false)
- @ColumnInfo(name = "manga_id")
- val mangaId: Long,
- @ColumnInfo(name = "mode") val mode: Int,
- @ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
- @ColumnInfo(name = "cf_contrast") val cfContrast: Float,
-)
+ @ColumnInfo(name = "manga_id") val mangaId: Long,
+ @ColumnInfo(name = "mode") val mode: Int
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
similarity index 65%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
index bc343f784..d3ee401a6 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt
@@ -3,27 +3,25 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
-import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
@Entity(
- tableName = TABLE_MANGA_TAGS,
- primaryKeys = ["manga_id", "tag_id"],
+ tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
- onDelete = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
- onDelete = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
)
]
)
class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
- @ColumnInfo(name = "tag_id", index = true) val tagId: Long,
+ @ColumnInfo(name = "tag_id", index = true) val tagId: Long
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
new file mode 100644
index 000000000..8c35c376e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
@@ -0,0 +1,15 @@
+package org.koitharu.kotatsu.core.db.entity
+
+import androidx.room.Embedded
+import androidx.room.Junction
+import androidx.room.Relation
+
+class MangaWithTags(
+ @Embedded 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/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
similarity index 66%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
index 6c7907da6..fe813a02b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
@@ -3,13 +3,12 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
-import org.koitharu.kotatsu.core.db.TABLE_TAGS
-@Entity(tableName = TABLE_TAGS)
-data class TagEntity(
+@Entity(tableName = "tags")
+class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
- @ColumnInfo(name = "source") val source: String,
-)
+ @ColumnInfo(name = "source") val source: String
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration10To11.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
similarity index 99%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
index ae82764b2..9d13d2420 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
@@ -24,4 +24,4 @@ class Migration11To12 : Migration(11, 12) {
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration1To2.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration2To3.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration3To4.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration4To5.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration5To6.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration6To7.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration7To8.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
new file mode 100644
index 000000000..ef20b4fb0
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
@@ -0,0 +1,7 @@
+package org.koitharu.kotatsu.core.exceptions
+
+import okio.IOException
+
+class CloudFlareProtectedException(
+ val url: String
+) : IOException("Protected by CloudFlare")
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CompositeException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CompositeException.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/EmptyHistoryException.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/MangaNotFoundException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/MangaNotFoundException.kt
new file mode 100644
index 000000000..9731f97b1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/MangaNotFoundException.kt
@@ -0,0 +1,3 @@
+package org.koitharu.kotatsu.core.exceptions
+
+class MangaNotFoundException(s: String? = null) : RuntimeException(s)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/UnsupportedFileException.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt
new file mode 100644
index 000000000..691a39640
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/WrongPasswordException.kt
@@ -0,0 +1,3 @@
+package org.koitharu.kotatsu.core.exceptions
+
+class WrongPasswordException : SecurityException()
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
index b5e267dcb..50f991b47 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt
@@ -1,24 +1,20 @@
package org.koitharu.kotatsu.core.exceptions.resolve
+import android.util.ArrayMap
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
-import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
-import okhttp3.Headers
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
-import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
-import org.koitharu.kotatsu.core.util.TaggedActivityResult
-import org.koitharu.kotatsu.core.util.isSuccess
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
-import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
+import org.koitharu.kotatsu.utils.TaggedActivityResult
+import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -39,27 +35,19 @@ class ExceptionResolver private constructor(
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
}
- override fun onActivityResult(result: TaggedActivityResult) {
+ override fun onActivityResult(result: TaggedActivityResult?) {
+ result ?: return
continuations.remove(result.tag)?.resume(result.isSuccess)
}
- fun showDetails(e: Throwable, url: String?) {
- ErrorDetailsDialog.show(getFragmentManager(), e, url)
- }
-
suspend fun resolve(e: Throwable): Boolean = when (e) {
- is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
+ is CloudFlareProtectedException -> resolveCF(e.url)
is AuthRequiredException -> resolveAuthException(e.source)
- is NotFoundException -> {
- openInBrowser(e.url)
- false
- }
-
else -> false
}
- private suspend fun resolveCF(url: String, headers: Headers): Boolean {
- val dialog = CloudFlareDialog.newInstance(url, headers)
+ private suspend fun resolveCF(url: String): Boolean {
+ val dialog = CloudFlareDialog.newInstance(url)
val fm = getFragmentManager()
return suspendCancellableCoroutine { cont ->
fm.clearFragmentResult(CloudFlareDialog.TAG)
@@ -71,7 +59,7 @@ class ExceptionResolver private constructor(
cont.invokeOnCancellation {
continuations.remove(CloudFlareDialog.TAG, cont)
fm.clearFragmentResultListener(CloudFlareDialog.TAG)
- dialog.dismissAllowingStateLoss()
+ dialog.dismiss()
}
}
}
@@ -81,11 +69,6 @@ class ExceptionResolver private constructor(
sourceAuthContract.launch(source)
}
- private fun openInBrowser(url: String) {
- val context = activity ?: fragment?.activity ?: return
- context.startActivity(BrowserActivity.newIntent(context, url, null))
- }
-
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
companion object {
@@ -94,10 +77,9 @@ class ExceptionResolver private constructor(
fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve
is AuthRequiredException -> R.string.sign_in
- is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
else -> 0
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt
similarity index 62%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt
index 1dcf7e26f..ff6babc95 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppVersion.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/AppVersion.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.github
import android.os.Parcelable
-import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -11,9 +10,5 @@ data class AppVersion(
val url: String,
val apkSize: Long,
val apkUrl: String,
- val description: String,
-) : Parcelable {
-
- @IgnoredOnParcel
- val versionId = VersionId(name)
-}
+ val description: String
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
new file mode 100644
index 000000000..58d8d22c6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.core.github
+
+import org.koin.dsl.module
+
+val githubModule
+ get() = module {
+ factory { GithubRepository(get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt
new file mode 100644
index 000000000..8b9f4e793
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt
@@ -0,0 +1,25 @@
+package org.koitharu.kotatsu.core.github
+
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.koitharu.kotatsu.parsers.util.await
+import org.koitharu.kotatsu.parsers.util.parseJson
+
+class GithubRepository(private val okHttp: OkHttpClient) {
+
+ suspend fun getLatestVersion(): AppVersion {
+ val request = Request.Builder()
+ .get()
+ .url("https://api.github.com/repos/nv95/Kotatsu/releases/latest")
+ val json = okHttp.newCall(request.build()).await().parseJson()
+ val asset = json.getJSONArray("assets").getJSONObject(0)
+ return AppVersion(
+ id = json.getLong("id"),
+ url = json.getString("html_url"),
+ name = json.getString("name").removePrefix("v"),
+ apkSize = asset.getLong("size"),
+ apkUrl = asset.getString("browser_download_url"),
+ description = json.getString("body")
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
similarity index 96%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
index 4080b8c76..88304755b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/VersionId.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt
@@ -63,9 +63,6 @@ class VersionId(
}
}
-val VersionId.isStable: Boolean
- get() = variantType.isEmpty()
-
fun VersionId(versionName: String): VersionId {
val parts = versionName.substringBeforeLast('-').split('.')
val variant = versionName.substringAfterLast('-', "")
@@ -76,4 +73,4 @@ fun VersionId(versionName: String): VersionId {
variantType = variant.filter(Char::isLetter),
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
similarity index 87%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
index 307cb50cd..798ec2fbd 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
+import java.util.*
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.parsers.model.SortOrder
-import java.util.*
@Parcelize
data class FavouriteCategory(
@@ -13,5 +13,4 @@ data class FavouriteCategory(
val order: SortOrder,
val createdAt: Date,
val isTrackingEnabled: Boolean,
- val isVisibleInLibrary: Boolean,
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
new file mode 100644
index 000000000..ed5594c42
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.core.model
+
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.mapToSet
+
+fun Collection.ids() = mapToSet { it.id }
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaHistory.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt
similarity index 62%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt
index 341796c1e..9bd4ef5cf 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt
@@ -2,16 +2,9 @@ package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
-import java.util.Locale
+import java.util.*
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
-}
-
-fun MangaSource(name: String): MangaSource {
- MangaSource.values().forEach {
- if (it.name == name) return it
- }
- return MangaSource.DUMMY
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/ZoomMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/ZoomMode.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/ZoomMode.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/ZoomMode.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt
similarity index 74%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt
index e774ce82e..67bb80044 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/Parcelable.kt
@@ -2,12 +2,7 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import androidx.core.os.ParcelCompat
-import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
-import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.model.*
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
@@ -42,16 +37,17 @@ fun Parcel.readManga() = Manga(
coverUrl = requireNotNull(readString()),
largeCoverUrl = readString(),
description = readString(),
- tags = requireNotNull(readParcelableCompat()).tags,
- state = readSerializableCompat(),
+ tags = requireNotNull(readParcelable(ParcelableMangaTags::class.java.classLoader)).tags,
+ state = readSerializable() as MangaState?,
author = readString(),
- chapters = readParcelableCompat()?.chapters,
- source = checkNotNull(readSerializableCompat()),
+ chapters = readParcelable(ParcelableMangaChapters::class.java.classLoader)?.chapters,
+ source = readSerializable() as MangaSource,
)
fun MangaPage.writeToParcel(out: Parcel) {
out.writeLong(id)
out.writeString(url)
+ out.writeString(referer)
out.writeString(preview)
out.writeSerializable(source)
}
@@ -59,8 +55,9 @@ fun MangaPage.writeToParcel(out: Parcel) {
fun Parcel.readMangaPage() = MangaPage(
id = readLong(),
url = requireNotNull(readString()),
+ referer = requireNotNull(readString()),
preview = readString(),
- source = checkNotNull(readSerializableCompat()),
+ source = readSerializable() as MangaSource,
)
fun MangaChapter.writeToParcel(out: Parcel) {
@@ -82,7 +79,7 @@ fun Parcel.readMangaChapter() = MangaChapter(
scanlator = readString(),
uploadDate = readLong(),
branch = readString(),
- source = checkNotNull(readSerializableCompat()),
+ source = readSerializable() as MangaSource,
)
fun MangaTag.writeToParcel(out: Parcel) {
@@ -94,5 +91,5 @@ fun MangaTag.writeToParcel(out: Parcel) {
fun Parcel.readMangaTag() = MangaTag(
title = requireNotNull(readString()),
key = requireNotNull(readString()),
- source = checkNotNull(readSerializableCompat()),
-)
+ source = readSerializable() as MangaSource,
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
similarity index 91%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
index ef778f173..b302ce634 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
@@ -5,8 +5,8 @@ import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
-private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
-private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
+private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
+private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
class ParcelableManga(
val manga: Manga,
@@ -50,4 +50,4 @@ class ParcelableManga(
return arrayOfNulls(size)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
similarity index 87%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
index 473b45320..db9ebb9c7 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
@@ -3,13 +3,14 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaChapter
+import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaChapters(
val chapters: List,
) : Parcelable {
constructor(parcel: Parcel) : this(
- List(parcel.readInt()) { parcel.readMangaChapter() }
+ createList(parcel.readInt()) { parcel.readMangaChapter() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
similarity index 87%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
index 3230ec59b..4717132f5 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
@@ -3,13 +3,14 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaPages(
val pages: List,
) : Parcelable {
constructor(parcel: Parcel) : this(
- List(parcel.readInt()) { parcel.readMangaPage() }
+ createList(parcel.readInt()) { parcel.readMangaPage() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
similarity index 87%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
index 7f6cf2f42..0ef0f74e0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
@@ -2,15 +2,15 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
-import org.koitharu.kotatsu.core.util.ext.Set
import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.utils.ext.createSet
class ParcelableMangaTags(
val tags: Set,
) : Parcelable {
constructor(parcel: Parcel) : this(
- Set(parcel.readInt()) { parcel.readMangaTag() },
+ createSet(parcel.readInt()) { parcel.readMangaTag() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
@@ -33,4 +33,4 @@ class ParcelableMangaTags(
return arrayOfNulls(size)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt
similarity index 75%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt
index 5b0c3d822..fb806bda1 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/cookies/AndroidCookieJar.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/AndroidCookieJar.kt
@@ -1,17 +1,16 @@
-package org.koitharu.kotatsu.core.network.cookies
+package org.koitharu.kotatsu.core.network
import android.webkit.CookieManager
-import androidx.annotation.WorkerThread
import okhttp3.Cookie
+import okhttp3.CookieJar
import okhttp3.HttpUrl
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
-class AndroidCookieJar : MutableCookieJar {
+class AndroidCookieJar : CookieJar {
private val cookieManager = CookieManager.getInstance()
- @WorkerThread
override fun loadForRequest(url: HttpUrl): List {
val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList()
return rawCookie.split(';').mapNotNull {
@@ -19,7 +18,6 @@ class AndroidCookieJar : MutableCookieJar {
}
}
- @WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List) {
if (cookies.isEmpty()) {
return
@@ -30,7 +28,7 @@ class AndroidCookieJar : MutableCookieJar {
}
}
- override suspend fun clear() = suspendCoroutine { continuation ->
+ suspend fun clear() = suspendCoroutine { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
similarity index 78%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
index b4dcc9e62..d9c8281d7 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
@@ -13,17 +13,13 @@ private const val SERVER_CLOUDFLARE = "cloudflare"
class CloudFlareInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
- val response = chain.proceed(request)
+ val response = chain.proceed(chain.request())
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
response.closeQuietly()
- throw CloudFlareProtectedException(
- url = response.request.url.toString(),
- headers = request.headers,
- )
+ throw CloudFlareProtectedException(chain.request().url.toString())
}
}
return response
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
similarity index 62%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
index f8976acd6..f377404c8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeaders.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
@@ -7,14 +7,10 @@ object CommonHeaders {
const val REFERER = "Referer"
const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept"
- const val CONTENT_TYPE = "Content-Type"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
- const val CONTENT_ENCODING = "Content-Encoding"
- const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization"
- const val CACHE_CONTROL = "Cache-Control"
- val CACHE_CONTROL_NO_STORE: CacheControl
+ val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt
similarity index 87%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt
index 9547b4da5..7c3c2db6e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHManager.kt
@@ -6,7 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.util.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.net.InetAddress
import java.net.UnknownHostException
@@ -21,12 +21,7 @@ class DoHManager(
private var cachedProvider: DoHProvider? = null
override fun lookup(hostname: String): List {
- return try {
- getDelegate().lookup(hostname)
- } catch (e: UnknownHostException) {
- // fallback
- Dns.SYSTEM.lookup(hostname)
- }
+ return getDelegate().lookup(hostname)
}
@Synchronized
@@ -45,19 +40,16 @@ class DoHManager(
DoHProvider.NONE -> Dns.SYSTEM
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.google/dns-query".toHttpUrl())
- .resolvePrivateAddresses(true)
.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())
- .resolvePrivateAddresses(true)
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("162.159.36.1"),
@@ -69,19 +61,17 @@ class DoHManager(
tryGetByIp("2606:4700:4700::1001"),
tryGetByIp("2606:4700:4700::0064"),
tryGetByIp("2606:4700:4700::6400"),
- ),
+ )
).build()
-
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
- .resolvePrivateAddresses(true)
.bootstrapDnsHosts(
listOfNotNull(
tryGetByIp("94.140.14.140"),
tryGetByIp("94.140.14.141"),
tryGetByIp("2a10:50c0::1:ff"),
tryGetByIp("2a10:50c0::2:ff"),
- ),
+ )
).build()
}
@@ -91,4 +81,4 @@ class DoHManager(
e.printStackTraceDebug()
null
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHProvider.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/network/DoHProvider.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/network/DoHProvider.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
new file mode 100644
index 000000000..2af4c215e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
@@ -0,0 +1,29 @@
+package org.koitharu.kotatsu.core.network
+
+import okhttp3.CookieJar
+import okhttp3.OkHttpClient
+import org.koin.dsl.bind
+import org.koin.dsl.module
+import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
+import org.koitharu.kotatsu.local.data.LocalStorageManager
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import java.util.concurrent.TimeUnit
+
+val networkModule
+ get() = module {
+ single { AndroidCookieJar() } bind CookieJar::class
+ single {
+ val cache = get().createHttpCache()
+ OkHttpClient.Builder().apply {
+ connectTimeout(20, TimeUnit.SECONDS)
+ readTimeout(60, TimeUnit.SECONDS)
+ writeTimeout(20, TimeUnit.SECONDS)
+ cookieJar(get())
+ dns(DoHManager(cache, get()))
+ cache(cache)
+ addInterceptor(UserAgentInterceptor())
+ addInterceptor(CloudFlareInterceptor())
+ }.build()
+ }
+ single { MangaLoaderContextImpl(get(), get(), get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt
new file mode 100644
index 000000000..b6491f154
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt
@@ -0,0 +1,43 @@
+package org.koitharu.kotatsu.core.network
+
+import android.os.Build
+import java.util.*
+import okhttp3.Interceptor
+import okhttp3.Response
+import org.koitharu.kotatsu.BuildConfig
+
+class UserAgentInterceptor : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ return chain.proceed(
+ if (request.header(CommonHeaders.USER_AGENT) == null) {
+ request.newBuilder()
+ .addHeader(CommonHeaders.USER_AGENT, userAgent)
+ .build()
+ } else request
+ )
+ }
+
+ companion object {
+
+ val userAgent
+ get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
+ BuildConfig.VERSION_NAME,
+ Build.VERSION.RELEASE,
+ Build.MODEL,
+ Build.BRAND,
+ Build.DEVICE,
+ Locale.getDefault().language
+ )
+
+ val userAgentChrome
+ get() = (
+ "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
+ "Chrome/100.0.4896.127 Mobile Safari/537.36"
+ ).format(
+ Build.VERSION.RELEASE,
+ Build.MODEL,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/WebViewClientCompat.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/WebViewClientCompat.kt
new file mode 100644
index 000000000..8764a338d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/WebViewClientCompat.kt
@@ -0,0 +1,86 @@
+package org.koitharu.kotatsu.core.network
+
+import android.annotation.TargetApi
+import android.os.Build
+import android.webkit.*
+
+@Suppress("OverridingDeprecatedMember")
+abstract class WebViewClientCompat : WebViewClient() {
+
+ open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
+ return false
+ }
+
+ open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
+ return null
+ }
+
+ open fun onReceivedErrorCompat(
+ view: WebView,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String,
+ isMainFrame: Boolean
+ ) {
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ final override fun shouldOverrideUrlLoading(
+ view: WebView,
+ request: WebResourceRequest
+ ): Boolean = shouldOverrideUrlCompat(view, request.url.toString())
+
+ final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+ return shouldOverrideUrlCompat(view, url)
+ }
+
+ final override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest
+ ): WebResourceResponse? = shouldInterceptRequestCompat(view, request.url.toString())
+
+ final override fun shouldInterceptRequest(
+ view: WebView,
+ url: String
+ ): WebResourceResponse? = shouldInterceptRequestCompat(view, url)
+
+ @TargetApi(Build.VERSION_CODES.M)
+ final override fun onReceivedError(
+ view: WebView,
+ request: WebResourceRequest,
+ error: WebResourceError
+ ) {
+ onReceivedErrorCompat(
+ view,
+ error.errorCode,
+ error.description?.toString(),
+ request.url.toString(),
+ request.isForMainFrame
+ )
+ }
+
+ final override fun onReceivedError(
+ view: WebView,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String
+ ) {
+ onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ final override fun onReceivedHttpError(
+ view: WebView,
+ request: WebResourceRequest,
+ error: WebResourceResponse
+ ) {
+ onReceivedErrorCompat(
+ view,
+ error.statusCode,
+ error.reasonPhrase,
+ request.url
+ .toString(),
+ request.isForMainFrame
+ )
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
new file mode 100644
index 000000000..9e8b18a52
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
@@ -0,0 +1,88 @@
+package org.koitharu.kotatsu.core.os
+
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.ShortcutManager
+import android.media.ThumbnailUtils
+import android.os.Build
+import android.util.Size
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import coil.ImageLoader
+import coil.request.ImageRequest
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.MangaDataRepository
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.utils.ext.requireBitmap
+
+class ShortcutsRepository(
+ private val context: Context,
+ private val coil: ImageLoader,
+ private val historyRepository: HistoryRepository,
+ private val mangaRepository: MangaDataRepository,
+) {
+
+ private val iconSize by lazy {
+ getIconSize(context)
+ }
+
+ suspend fun updateShortcuts() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
+ val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
+ val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
+ .filter { x -> x.title.isNotEmpty() }
+ .map { buildShortcutInfo(it).build().toShortcutInfo() }
+ manager.dynamicShortcuts = shortcuts
+ }
+
+ suspend fun requestPinShortcut(manga: Manga): Boolean {
+ return ShortcutManagerCompat.requestPinShortcut(
+ context,
+ buildShortcutInfo(manga).build(),
+ null
+ )
+ }
+
+ private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
+ val icon = runCatching {
+ withContext(Dispatchers.IO) {
+ val bmp = coil.execute(
+ ImageRequest.Builder(context)
+ .data(manga.coverUrl)
+ .size(iconSize.width, iconSize.height)
+ .build()
+ ).requireBitmap()
+ ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
+ }
+ }.fold(
+ onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
+ onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }
+ )
+ mangaRepository.storeManga(manga)
+ return ShortcutInfoCompat.Builder(context, manga.id.toString())
+ .setShortLabel(manga.title)
+ .setLongLabel(manga.title)
+ .setIcon(icon)
+ .setIntent(
+ ReaderActivity.newIntent(context, manga.id)
+ .setAction(ReaderActivity.ACTION_MANGA_READ)
+ )
+ }
+
+ private fun getIconSize(context: Context): Size {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ (context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
+ Size(it.iconMaxWidth, it.iconMaxHeight)
+ }
+ } else {
+ (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
+ Size(it, it)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
new file mode 100644
index 000000000..ba5412a50
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
@@ -0,0 +1,20 @@
+package org.koitharu.kotatsu.core.parser
+
+import android.net.Uri
+import coil.map.Mapper
+import coil.request.Options
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+class FaviconMapper : Mapper {
+
+ override fun map(data: Uri, options: Options): HttpUrl? {
+ if (data.scheme != "favicon") {
+ return null
+ }
+ val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
+ val repo = MangaRepository(mangaSource) as RemoteMangaRepository
+ return repo.getFaviconUrl().toHttpUrl()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt
similarity index 64%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt
index fe140190d..ffacb6c6e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt
@@ -5,39 +5,29 @@ import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.core.os.LocaleListCompat
-import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
-import org.koitharu.kotatsu.core.network.MangaHttpClient
-import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
+import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings
-import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
-import java.lang.ref.WeakReference
+import org.koitharu.kotatsu.utils.ext.toList
import java.util.*
-import javax.inject.Inject
-import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
-@Singleton
-class MangaLoaderContextImpl @Inject constructor(
- @MangaHttpClient override val httpClient: OkHttpClient,
- override val cookieJar: MutableCookieJar,
- @ApplicationContext private val androidContext: Context,
+class MangaLoaderContextImpl(
+ override val httpClient: OkHttpClient,
+ override val cookieJar: AndroidCookieJar,
+ private val androidContext: Context,
) : MangaLoaderContext() {
- private var webViewCached: WeakReference? = null
-
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
- val webView = webViewCached?.get() ?: WebView(androidContext).also {
- it.settings.javaScriptEnabled = true
- webViewCached = WeakReference(it)
- }
+ val webView = WebView(androidContext)
+ webView.settings.javaScriptEnabled = true
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
@@ -60,4 +50,4 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List {
return LocaleListCompat.getAdjustedDefault().toList()
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
new file mode 100644
index 000000000..90c84d5a8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.core.parser
+
+import java.lang.ref.WeakReference
+import java.util.*
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.model.*
+
+interface MangaRepository {
+
+ val source: MangaSource
+
+ val sortOrders: Set
+
+ suspend fun getList(offset: Int, query: String): List
+
+ suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List
+
+ suspend fun getDetails(manga: Manga): Manga
+
+ suspend fun getPages(chapter: MangaChapter): List
+
+ suspend fun getPageUrl(page: MangaPage): String
+
+ suspend fun getTags(): Set
+
+ companion object : KoinComponent {
+
+ private val cache = EnumMap>(MangaSource::class.java)
+
+ operator fun invoke(source: MangaSource): MangaRepository {
+ if (source == MangaSource.LOCAL) {
+ return get()
+ }
+ cache[source]?.get()?.let { return it }
+ return synchronized(cache) {
+ cache[source]?.get()?.let { return it }
+ val repository = RemoteMangaRepository(MangaParser(source, get()))
+ cache[source] = WeakReference(repository)
+ repository
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
new file mode 100644
index 000000000..999ecb09b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
@@ -0,0 +1,48 @@
+package org.koitharu.kotatsu.core.parser
+
+import org.koitharu.kotatsu.core.prefs.SourceSettings
+import org.koitharu.kotatsu.parsers.MangaParser
+import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.model.*
+
+class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
+
+ override val source: MangaSource
+ get() = parser.source
+
+ override val sortOrders: Set
+ get() = parser.sortOrders
+
+ var defaultSortOrder: SortOrder?
+ get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull()
+ set(value) {
+ getConfig().defaultSortOrder = value
+ }
+
+ override suspend fun getList(offset: Int, query: String): List {
+ return parser.getList(offset, query)
+ }
+
+ override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List {
+ return parser.getList(offset, tags, sortOrder)
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
+
+ override suspend fun getPages(chapter: MangaChapter): List = parser.getPages(chapter)
+
+ override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
+
+ override suspend fun getTags(): Set = parser.getTags()
+
+ fun getFaviconUrl(): String = parser.getFaviconUrl()
+
+ fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
+
+ fun getConfigKeys(): List> = ArrayList>().also {
+ parser.onCreateConfig(it)
+ }
+
+ private fun getConfig() = parser.config as SourceSettings
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt
new file mode 100644
index 000000000..0efa45c92
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSection.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.core.prefs
+
+enum class AppSection {
+
+ LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
similarity index 58%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index fe2e24192..cd9c8a07a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -2,41 +2,33 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.content.SharedPreferences
+import android.net.ConnectivityManager
import android.net.Uri
import android.provider.Settings
-import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.core.content.edit
-import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
-import dagger.hilt.android.qualifiers.ApplicationContext
+import com.google.android.material.color.DynamicColors
+import java.io.File
+import java.text.DateFormat
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
-import org.koitharu.kotatsu.core.util.ext.connectivityManager
-import org.koitharu.kotatsu.core.util.ext.filterToSet
-import org.koitharu.kotatsu.core.util.ext.getEnumValue
-import org.koitharu.kotatsu.core.util.ext.observe
-import org.koitharu.kotatsu.core.util.ext.putEnumValue
-import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.model.MangaSource
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.util.mapToSet
-import org.koitharu.kotatsu.shelf.domain.ShelfSection
-import java.io.File
-import java.net.Proxy
-import java.util.Collections
-import java.util.EnumSet
-import java.util.Locale
-import javax.inject.Inject
-import javax.inject.Singleton
+import org.koitharu.kotatsu.utils.ext.getEnumValue
+import org.koitharu.kotatsu.utils.ext.observe
+import org.koitharu.kotatsu.utils.ext.putEnumValue
+import org.koitharu.kotatsu.utils.ext.toUriOrNull
-@Singleton
-class AppSettings @Inject constructor(@ApplicationContext context: Context) {
+class AppSettings(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
- private val connectivityManager = context.connectivityManager
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
@@ -48,31 +40,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val remoteMangaSources: Set
get() = Collections.unmodifiableSet(remoteSources)
- var shelfSections: List
- get() {
- val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
- val values = enumValues()
- if (raw.isNullOrEmpty()) {
- return values.toList()
- }
- return raw.split('|')
- .mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
- .distinct()
- }
- set(value) {
- val raw = value.joinToString("|") { it.ordinal.toString() }
- prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
- }
-
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
+ var defaultSection: AppSection
+ get() = prefs.getEnumValue(KEY_APP_SECTION, AppSection.HISTORY)
+ set(value) = prefs.edit { putEnumValue(KEY_APP_SECTION, value) }
+
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
- val colorScheme: ColorScheme
- get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
+ val isDynamicTheme: Boolean
+ get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@@ -81,23 +61,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
- var appLocales: LocaleListCompat
- get() {
- val raw = prefs.getString(KEY_APP_LOCALE, null)
- return LocaleListCompat.forLanguageTags(raw)
- }
- set(value) {
- prefs.edit {
- putString(KEY_APP_LOCALE, value.toLanguageTags())
- }
- }
-
val readerPageSwitch: Set
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
- val isReaderTapsAdaptive: Boolean
- get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
-
var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@@ -106,6 +72,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
+ val isUpdateCheckingEnabled: Boolean
+ get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
+
+ var lastUpdateCheckTimestamp: Long
+ get() = prefs.getLong(KEY_APP_UPDATE, 0L)
+ set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
+
val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
@@ -142,10 +115,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
- var isIncognitoModeEnabled: Boolean
- get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
- set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
-
var chaptersReverse: Boolean
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
@@ -160,30 +129,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
- val isLoggingEnabled: Boolean
- get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
-
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
- val isMirrorSwitchingAvailable: Boolean
- get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
-
- val isExitConfirmationEnabled: Boolean
- get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
-
- val isDynamicShortcutsEnabled: Boolean
- get() = prefs.getBoolean(KEY_SHORTCUTS, true)
-
- val isUnstableUpdatesAllowed: Boolean
- get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
-
- fun isContentPrefetchEnabled(): Boolean {
- val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
- return policy.isNetworkAllowed(connectivityManager)
- }
-
var sourcesOrder: List
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -193,9 +142,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
var hiddenSources: Set
- get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
- remoteSources.any { it.name == name }
- }.orEmpty()
+ get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null) ?: emptySet()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean
@@ -215,10 +162,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
- var isSourcesGridMode: Boolean
- get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
- set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
-
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -243,67 +186,46 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
- val isDownloadsWiFiOnly: Boolean
- get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
+ val downloadsParallelism: Int
+ get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
- var isSuggestionsEnabled: Boolean
+ val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
- set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
- val isSuggestionsNotificationAvailable: Boolean
- get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
-
- val suggestionsTagsBlacklist: Set
- get() {
- val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
- if (string.isNullOrEmpty()) {
- return emptySet()
- }
- return string.split(',').mapToSet { it.trim() }
- }
-
- val isReaderBarEnabled: Boolean
- get() = prefs.getBoolean(KEY_READER_BAR, true)
-
- val isReaderSliderEnabled: Boolean
- get() = prefs.getBoolean(KEY_READER_SLIDER, true)
+ var isSearchSingleSource: Boolean
+ get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
+ set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
- val isSSLBypassEnabled: Boolean
- get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
-
- val proxyType: Proxy.Type
- get() {
- val raw = prefs.getString(KEY_PROXY_TYPE, null) ?: return Proxy.Type.DIRECT
- return enumValues().find { it.name == raw } ?: Proxy.Type.DIRECT
+ fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
+ return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
+ NETWORK_ALWAYS -> true
+ NETWORK_NEVER -> false
+ else -> cm.isActiveNetworkMetered
}
+ }
- val proxyAddress: String?
- get() = prefs.getString(KEY_PROXY_ADDRESS, null)
-
- val proxyPort: Int
- get() = prefs.getString(KEY_PROXY_PORT, null)?.toIntOrNull() ?: 0
-
- var localListOrder: SortOrder
- get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
- set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
-
- val isWebtoonZoomEnable: Boolean
- get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
-
- @get:FloatRange(from = 0.0, to = 1.0)
- var readerAutoscrollSpeed: Float
- get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
- set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
+ fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
+ when (format) {
+ "" -> DateFormat.getDateInstance(DateFormat.SHORT)
+ else -> SimpleDateFormat(format, Locale.getDefault())
+ }
- fun isPagesPreloadEnabled(): Boolean {
- val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
- return policy.isNetworkAllowed(connectivityManager)
+ fun getSuggestionsTagsBlacklistRegex(): Regex? {
+ val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
+ if (string.isNullOrEmpty()) {
+ return null
+ }
+ val tags = string.split(',')
+ val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
+ Regex.escape(tag.trim())
+ }
+ return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List {
@@ -320,18 +242,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return list
}
- fun isTipEnabled(tip: String): Boolean {
- return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
- }
-
- fun closeTip(tip: String) {
- val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty()
- if (tip in closedTips) {
- return
- }
- prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
- }
-
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -351,14 +261,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val TRACK_FAVOURITES = "favourites"
const val KEY_LIST_MODE = "list_mode_2"
+ const val KEY_APP_SECTION = "app_section_2"
const val KEY_THEME = "theme"
- const val KEY_COLOR_THEME = "color_theme"
+ const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_theme"
+ const val KEY_DATE_FORMAT = "date_format"
const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
- const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
@@ -397,42 +308,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
- const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
+ const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_SHIKIMORI = "shikimori"
- const val KEY_ANILIST = "anilist"
- const val KEY_MAL = "mal"
+ const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
- const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
- const val KEY_EXIT_CONFIRM = "exit_confirm"
- const val KEY_INCOGNITO_MODE = "incognito"
- const val KEY_SYNC = "sync"
- const val KEY_SYNC_SETTINGS = "sync_settings"
- const val KEY_READER_BAR = "reader_bar"
- const val KEY_READER_SLIDER = "reader_slider"
- const val KEY_SHORTCUTS = "dynamic_shortcuts"
- const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
- const val KEY_LOCAL_LIST_ORDER = "local_order"
- const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
- const val KEY_SHELF_SECTIONS = "shelf_sections_2"
- const val KEY_PREFETCH_CONTENT = "prefetch_content"
- const val KEY_APP_LOCALE = "app_locale"
- const val KEY_LOGGING_ENABLED = "logging"
- const val KEY_LOGS_SHARE = "logs_share"
- const val KEY_SOURCES_GRID = "sources_grid"
- const val KEY_UPDATES_UNSTABLE = "updates_unstable"
- const val KEY_TIPS_CLOSED = "tips_closed"
- const val KEY_SSL_BYPASS = "ssl_bypass"
- const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
- const val KEY_MIRROR_SWITCHING = "mirror_switching"
- const val KEY_PROXY = "proxy"
- const val KEY_PROXY_TYPE = "proxy_type"
- const val KEY_PROXY_ADDRESS = "proxy_address"
- const val KEY_PROXY_PORT = "proxy_port"
// About
const val KEY_APP_UPDATE = "app_update"
+ const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
+
+ private const val NETWORK_NEVER = 0
+ private const val NETWORK_ALWAYS = 1
+ private const val NETWORK_NON_METERED = 2
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
similarity index 58%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
index 606ae9d84..88c62514c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
@@ -1,13 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.flow.flow
fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
@@ -26,7 +21,7 @@ fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() ->
fun AppSettings.observeAsLiveData(
context: CoroutineContext,
key: String,
- valueProducer: AppSettings.() -> T,
+ valueProducer: AppSettings.() -> T
) = liveData(context) {
emit(valueProducer())
observe().collect {
@@ -37,14 +32,4 @@ fun AppSettings.observeAsLiveData(
}
}
}
-}
-
-fun AppSettings.observeAsStateFlow(
- key: String,
- scope: CoroutineScope,
- valueProducer: AppSettings.() -> T,
-): StateFlow = observe().transform {
- if (it == key) {
- emit(valueProducer())
- }
-}.stateIn(scope, SharingStarted.Eagerly, valueProducer())
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/AppWidgetConfig.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ListMode.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ListMode.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ListMode.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ReaderMode.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/ScreenshotsPolicy.kt
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
similarity index 58%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
index 0080c0b1d..ea14c7342 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt
@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import androidx.core.content.edit
-import org.koitharu.kotatsu.core.util.ext.getEnumValue
-import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
-import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.utils.ext.getEnumValue
+import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
+import org.koitharu.kotatsu.utils.ext.putEnumValue
private const val KEY_SORT_ORDER = "sort_order"
@@ -23,17 +23,7 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
@Suppress("UNCHECKED_CAST")
override fun get(key: ConfigKey): T {
return when (key) {
- is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
- is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
-
- operator fun set(key: ConfigKey, value: T) = prefs.edit {
- when (key) {
- is ConfigKey.Domain -> putString(key.key, value as String?)
- is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
- is ConfigKey.UserAgent -> putString(key.key, value as String?)
- }
- }
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
similarity index 75%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
index 8e468b5ad..03bafa077 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
@@ -1,11 +1,11 @@
-package org.koitharu.kotatsu.core.ui.model
+package org.koitharu.kotatsu.core.ui
import android.content.res.Resources
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.util.ext.daysDiff
-import org.koitharu.kotatsu.core.util.ext.format
import org.koitharu.kotatsu.list.ui.model.ListModel
-import java.util.Date
+import org.koitharu.kotatsu.utils.ext.daysDiff
+import org.koitharu.kotatsu.utils.ext.format
+import java.util.*
sealed class DateTimeAgo : ListModel {
@@ -15,10 +15,6 @@ sealed class DateTimeAgo : ListModel {
override fun format(resources: Resources): String {
return resources.getString(R.string.just_now)
}
-
- override fun toString() = "just_now"
-
- override fun equals(other: Any?): Boolean = other === JustNow
}
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
@@ -35,8 +31,6 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = minutes
-
- override fun toString() = "minutes_ago_$minutes"
}
class HoursAgo(val hours: Int) : DateTimeAgo() {
@@ -52,28 +46,18 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = hours
-
- override fun toString() = "hours_ago_$hours"
}
object Today : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.today)
}
-
- override fun toString() = "today"
-
- override fun equals(other: Any?): Boolean = other === Today
}
object Yesterday : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.yesterday)
}
-
- override fun toString() = "yesterday"
-
- override fun equals(other: Any?): Boolean = other === Yesterday
}
class DaysAgo(val days: Int) : DateTimeAgo() {
@@ -89,8 +73,6 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = days
-
- override fun toString() = "days_ago_$days"
}
class Absolute(private val date: Date) : DateTimeAgo() {
@@ -107,23 +89,19 @@ sealed class DateTimeAgo : ListModel {
other as Absolute
- return day == other.day
+ if (day != other.day) return false
+
+ return true
}
override fun hashCode(): Int {
return day
}
-
- override fun toString() = "abs_$day"
}
object LongAgo : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.long_ago)
}
-
- override fun toString() = "long_ago"
-
- override fun equals(other: Any?): Boolean = other === LongAgo
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt
similarity index 89%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt
index 71e6034e6..92b9fd9ef 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/SortOrder.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/SortOrder.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.core.ui.model
+package org.koitharu.kotatsu.core.ui
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
@@ -12,4 +12,4 @@ val SortOrder.titleRes: Int
SortOrder.RATING -> R.string.by_rating
SortOrder.NEWEST -> R.string.newest
SortOrder.ALPHABETICAL -> R.string.by_name
- }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
new file mode 100644
index 000000000..54529b37c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.core.ui
+
+import coil.ComponentRegistry
+import coil.ImageLoader
+import coil.disk.DiskCache
+import kotlinx.coroutines.Dispatchers
+import okhttp3.OkHttpClient
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+import org.koitharu.kotatsu.core.parser.FaviconMapper
+import org.koitharu.kotatsu.local.data.CacheDir
+import org.koitharu.kotatsu.local.data.CbzFetcher
+
+val uiModule
+ get() = module {
+ single {
+ val httpClientFactory = {
+ get().newBuilder()
+ .cache(null)
+ .build()
+ }
+ val diskCacheFactory = {
+ val context = androidContext()
+ val rootDir = context.externalCacheDir ?: context.cacheDir
+ DiskCache.Builder()
+ .directory(rootDir.resolve(CacheDir.THUMBS.dir))
+ .build()
+ }
+ ImageLoader.Builder(androidContext())
+ .okHttpClient(httpClientFactory)
+ .interceptorDispatcher(Dispatchers.Default)
+ .diskCache(diskCacheFactory)
+ .components(
+ ComponentRegistry.Builder()
+ .add(CbzFetcher.Factory())
+ .add(FaviconMapper())
+ .build()
+ ).build()
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
similarity index 94%
rename from app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
index fd38f1c04..d34e753ab 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
@@ -52,13 +52,10 @@ class ZipOutput(
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
- try {
- other.getInputStream(entry).use { input ->
- input.copyTo(output)
- }
- } finally {
- output.closeEntry()
+ other.getInputStream(entry).use { input ->
+ input.copyTo(output)
}
+ output.closeEntry()
true
} else {
false
@@ -118,4 +115,4 @@ class ZipOutput(
closeEntry()
return true
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
new file mode 100644
index 000000000..88b40e2be
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.details
+
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+import org.koitharu.kotatsu.details.ui.DetailsViewModel
+
+val detailsModule
+ get() = module {
+
+ viewModel { intent ->
+ DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get())
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt
new file mode 100644
index 000000000..d0b5a23c3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/domain/BranchComparator.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.details.domain
+
+class BranchComparator : Comparator {
+
+ override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
new file mode 100644
index 000000000..283a141d3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
@@ -0,0 +1,270 @@
+package org.koitharu.kotatsu.details.ui
+
+import android.app.ActivityOptions
+import android.os.Bundle
+import android.view.*
+import android.widget.AdapterView
+import android.widget.Spinner
+import androidx.appcompat.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
+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.databinding.FragmentChaptersBinding
+import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
+import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
+import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
+import kotlin.math.roundToInt
+
+class ChaptersFragment :
+ BaseFragment(),
+ OnListItemClickListener,
+ ActionMode.Callback,
+ AdapterView.OnItemSelectedListener,
+ MenuItem.OnActionExpandListener,
+ SearchView.OnQueryTextListener {
+
+ private val viewModel by sharedViewModel()
+
+ private var chaptersAdapter: ChaptersAdapter? = null
+ private var actionMode: ActionMode? = null
+ private var selectionDecoration: ChaptersSelectionDecoration? = null
+
+ override fun onInflateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?
+ ) = FragmentChaptersBinding.inflate(inflater, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ chaptersAdapter = ChaptersAdapter(this)
+ selectionDecoration = ChaptersSelectionDecoration(view.context)
+ with(binding.recyclerViewChapters) {
+ addItemDecoration(selectionDecoration!!)
+ setHasFixedSize(true)
+ adapter = chaptersAdapter
+ }
+ binding.spinnerBranches?.let(::initSpinner)
+ viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
+ viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
+ viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
+ activity?.invalidateOptionsMenu()
+ }
+ viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
+ binding.textViewHolder.isVisible = it
+ activity?.invalidateOptionsMenu()
+ }
+ addMenuProvider(ChaptersMenuProvider())
+ }
+
+ override fun onDestroyView() {
+ chaptersAdapter = null
+ selectionDecoration = null
+ binding.spinnerBranches?.adapter = null
+ super.onDestroyView()
+ }
+
+ override fun onItemClick(item: ChapterListItem, view: View) {
+ if (selectionDecoration?.checkedItemsCount != 0) {
+ selectionDecoration?.toggleItemChecked(item.chapter.id)
+ if (selectionDecoration?.checkedItemsCount == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ binding.recyclerViewChapters.invalidateItemDecorations()
+ }
+ return
+ }
+ if (item.hasFlag(ChapterListItem.FLAG_MISSING)) {
+ (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
+ return
+ }
+ val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
+ startActivity(
+ ReaderActivity.newIntent(
+ context = view.context,
+ manga = viewModel.manga.value ?: return,
+ state = ReaderState(item.chapter.id, 0, 0),
+ ),
+ options.toBundle()
+ )
+ }
+
+ override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
+ if (actionMode == null) {
+ actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
+ }
+ return actionMode?.also {
+ selectionDecoration?.setItemIsChecked(item.chapter.id, true)
+ binding.recyclerViewChapters.invalidateItemDecorations()
+ it.invalidate()
+ } != null
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_save -> {
+ DownloadService.start(
+ context ?: return false,
+ viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
+ selectionDecoration?.checkedItemsIds?.toSet()
+ )
+ mode.finish()
+ true
+ }
+ R.id.action_delete -> {
+ val ids = selectionDecoration?.checkedItemsIds
+ val manga = viewModel.manga.value
+ when {
+ ids.isNullOrEmpty() || manga == null -> Unit
+ ids.size == manga.chapters?.size -> viewModel.deleteLocal()
+ else -> {
+ LocalChaptersRemoveService.start(requireContext(), manga, ids)
+ Snackbar.make(
+ binding.recyclerViewChapters,
+ R.string.chapters_will_removed_background,
+ Snackbar.LENGTH_LONG
+ ).show()
+ }
+ }
+ mode.finish()
+ true
+ }
+ R.id.action_select_all -> {
+ val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
+ selectionDecoration?.checkAll(ids)
+ binding.recyclerViewChapters.invalidateItemDecorations()
+ mode.invalidate()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val spinner = binding.spinnerBranches ?: return
+ viewModel.setSelectedBranch(spinner.selectedItem as String?)
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_chapters, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
+ val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
+ menu.findItem(R.id.action_save).isVisible = items.none { x ->
+ x.chapter.source == MangaSource.LOCAL
+ }
+ menu.findItem(R.id.action_delete).isVisible = items.all { x ->
+ x.chapter.source == MangaSource.LOCAL
+ }
+ mode.title = items.size.toString()
+ return true
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ selectionDecoration?.clearSelection()
+ binding.recyclerViewChapters.invalidateItemDecorations()
+ actionMode = null
+ }
+
+ override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
+
+ override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
+ (item?.actionView as? SearchView)?.setQuery("", false)
+ viewModel.performChapterSearch(null)
+ return true
+ }
+
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ viewModel.performChapterSearch(newText)
+ return true
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.recyclerViewChapters.updatePadding(
+ bottom = insets.bottom + (binding.spinnerBranches?.height ?: 0),
+ )
+ }
+
+ private fun initSpinner(spinner: Spinner) {
+ val branchesAdapter = BranchesAdapter()
+ spinner.adapter = branchesAdapter
+ spinner.onItemSelectedListener = this
+ viewModel.branches.observe(viewLifecycleOwner) {
+ branchesAdapter.setItems(it)
+ spinner.isVisible = it.size > 1
+ }
+ viewModel.selectedBranchIndex.observe(viewLifecycleOwner) {
+ if (it != -1 && it != spinner.selectedItemPosition) {
+ spinner.setSelection(it)
+ }
+ }
+ }
+
+ private fun onChaptersChanged(list: List) {
+ val adapter = chaptersAdapter ?: return
+ if (adapter.itemCount == 0) {
+ val position = list.indexOfFirst { it.hasFlag(ChapterListItem.FLAG_CURRENT) } - 1
+ if (position > 0) {
+ val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
+ adapter.setItems(list, RecyclerViewScrollCallback(binding.recyclerViewChapters, position, offset))
+ } else {
+ adapter.items = list
+ }
+ } else {
+ adapter.items = list
+ }
+ }
+
+ private fun onLoadingStateChanged(isLoading: Boolean) {
+ binding.progressBar.isVisible = isLoading
+ }
+
+ private inner class ChaptersMenuProvider : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_chapters, menu)
+ val searchMenuItem = menu.findItem(R.id.action_search)
+ searchMenuItem.setOnActionExpandListener(this@ChaptersFragment)
+ val searchView = searchMenuItem.actionView as SearchView
+ searchView.setOnQueryTextListener(this@ChaptersFragment)
+ searchView.setIconifiedByDefault(false)
+ searchView.queryHint = searchMenuItem.title
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
+ menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_reversed -> {
+ viewModel.setChaptersReversed(!menuItem.isChecked)
+ true
+ }
+ else -> false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
new file mode 100644
index 000000000..84155b516
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -0,0 +1,373 @@
+package org.koitharu.kotatsu.details.ui
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.Spinner
+import android.widget.Toast
+import androidx.appcompat.view.ActionMode
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.Insets
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.fragment.app.commit
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import kotlinx.coroutines.launch
+import org.acra.ktx.sendWithAcra
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.core.parameter.parametersOf
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.MangaIntent
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.browser.BrowserActivity
+import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.os.ShortcutsRepository
+import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
+import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class DetailsActivity :
+ BaseActivity(),
+ TabLayoutMediator.TabConfigurationStrategy,
+ AdapterView.OnItemSelectedListener {
+
+ private val viewModel by viewModel {
+ parametersOf(MangaIntent(intent))
+ }
+
+ private val downloadReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
+ viewModel.onDownloadComplete(downloadedManga)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityDetailsBinding.inflate(layoutInflater))
+ supportActionBar?.run {
+ setDisplayHomeAsUpEnabled(true)
+ setDisplayShowTitleEnabled(false)
+ }
+ val pager = binding.pager
+ if (pager != null) {
+ pager.adapter = MangaDetailsAdapter(this)
+ TabLayoutMediator(checkNotNull(binding.tabs), pager, this).attach()
+ }
+ gcFragments()
+ binding.spinnerBranches?.let(::initSpinner)
+
+ viewModel.manga.observe(this, ::onMangaUpdated)
+ viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
+ viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
+ viewModel.onError.observe(this, ::onError)
+ viewModel.onShowToast.observe(this) {
+ binding.snackbar.show(messageText = getString(it))
+ }
+
+ registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
+ }
+
+ override fun onDestroy() {
+ unregisterReceiver(downloadReceiver)
+ super.onDestroy()
+ }
+
+ private fun onMangaUpdated(manga: Manga) {
+ title = manga.title
+ invalidateOptionsMenu()
+ }
+
+ private fun onMangaRemoved(manga: Manga) {
+ Toast.makeText(
+ this, getString(R.string._s_deleted_from_local_storage, manga.title),
+ Toast.LENGTH_SHORT
+ ).show()
+ finishAfterTransition()
+ }
+
+ private fun onError(e: Throwable) {
+ when {
+ ExceptionResolver.canResolve(e) -> {
+ resolveError(e)
+ }
+ viewModel.manga.value == null -> {
+ Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
+ finishAfterTransition()
+ }
+ e is ParseException || e is IllegalArgumentException || e is IllegalStateException -> {
+ binding.snackbar.show(
+ messageText = e.getDisplayMessage(resources),
+ actionId = R.string.report,
+ duration = if (viewModel.manga.value?.chapters == null) {
+ Snackbar.LENGTH_INDEFINITE
+ } else {
+ Snackbar.LENGTH_LONG
+ },
+ onActionClick = {
+ e.sendWithAcra()
+ dismiss()
+ }
+ )
+ }
+ else -> {
+ binding.snackbar.show(e.getDisplayMessage(resources))
+ }
+ }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.snackbar.updatePadding(
+ bottom = insets.bottom
+ )
+ binding.toolbar.updateLayoutParams {
+ topMargin = insets.top
+ }
+ binding.root.updatePadding(
+ left = insets.left,
+ right = insets.right
+ )
+ }
+
+ private fun onNewChaptersChanged(newChapters: Int) {
+ val tab = binding.tabs?.getTabAt(1) ?: return
+ if (newChapters == 0) {
+ tab.removeBadge()
+ } else {
+ val badge = tab.orCreateBadge
+ badge.number = newChapters
+ badge.isVisible = true
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.opt_details, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+ val manga = viewModel.manga.value
+ menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
+ menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
+ menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.action_delete -> {
+ val title = viewModel.manga.value?.title.orEmpty()
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.delete_manga)
+ .setMessage(getString(R.string.text_delete_local_manga, title))
+ .setPositiveButton(R.string.delete) { _, _ ->
+ viewModel.deleteLocal()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ true
+ }
+ R.id.action_save -> {
+ viewModel.manga.value?.let {
+ val chaptersCount = it.chapters?.size ?: 0
+ val branches = viewModel.branches.value.orEmpty()
+ if (chaptersCount > 5 || branches.size > 1) {
+ showSaveConfirmation(it, chaptersCount, branches)
+ } else {
+ DownloadService.start(this, it)
+ }
+ }
+ true
+ }
+ R.id.action_browser -> {
+ viewModel.manga.value?.let {
+ startActivity(BrowserActivity.newIntent(this, it.publicUrl, it.title))
+ }
+ true
+ }
+ R.id.action_related -> {
+ viewModel.manga.value?.let {
+ startActivity(MultiSearchActivity.newIntent(this, it.title))
+ }
+ true
+ }
+ R.id.action_shiki_track -> {
+ viewModel.manga.value?.let {
+ ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
+ }
+ true
+ }
+ R.id.action_shortcut -> {
+ viewModel.manga.value?.let {
+ lifecycleScope.launch {
+ if (!get().requestPinShortcut(it)) {
+ binding.snackbar.show(getString(R.string.operation_not_supported))
+ }
+ }
+ }
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
+ tab.text = when (position) {
+ 0 -> getString(R.string.details)
+ 1 -> getString(R.string.chapters)
+ else -> null
+ }
+ }
+
+ override fun onSupportActionModeStarted(mode: ActionMode) {
+ super.onSupportActionModeStarted(mode)
+ binding.pager?.isUserInputEnabled = false
+ }
+
+ override fun onSupportActionModeFinished(mode: ActionMode) {
+ super.onSupportActionModeFinished(mode)
+ binding.pager?.isUserInputEnabled = true
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ val spinner = binding.spinnerBranches ?: return
+ viewModel.setSelectedBranch(spinner.selectedItem as String?)
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ fun showChapterMissingDialog(chapterId: Long) {
+ val remoteManga = viewModel.getRemoteManga()
+ if (remoteManga == null) {
+ binding.snackbar.show(getString(R.string.chapter_is_missing))
+ return
+ }
+ MaterialAlertDialogBuilder(this).apply {
+ setMessage(R.string.chapter_is_missing_text)
+ setTitle(R.string.chapter_is_missing)
+ setNegativeButton(android.R.string.cancel, null)
+ setPositiveButton(R.string.read) { _, _ ->
+ startActivity(
+ ReaderActivity.newIntent(
+ context = this@DetailsActivity,
+ manga = remoteManga,
+ state = ReaderState(chapterId, 0, 0)
+ )
+ )
+ }
+ setNeutralButton(R.string.download) { _, _ ->
+ DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId))
+ }
+ setCancelable(true)
+ }.show()
+ }
+
+ private fun initSpinner(spinner: Spinner) {
+ val branchesAdapter = BranchesAdapter()
+ spinner.adapter = branchesAdapter
+ spinner.onItemSelectedListener = this
+ viewModel.branches.observe(this) {
+ branchesAdapter.setItems(it)
+ spinner.isVisible = it.size > 1
+ }
+ viewModel.selectedBranchIndex.observe(this) {
+ if (it != -1 && it != spinner.selectedItemPosition) {
+ spinner.setSelection(it)
+ }
+ }
+ }
+
+ private fun resolveError(e: Throwable) {
+ lifecycleScope.launch {
+ if (exceptionResolver.resolve(e)) {
+ viewModel.reload()
+ } else if (viewModel.manga.value == null) {
+ Toast.makeText(this@DetailsActivity, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
+ finishAfterTransition()
+ }
+ }
+ }
+
+ private fun gcFragments() {
+ val mustHaveId = binding.pager == null
+ val fm = supportFragmentManager
+ val fragmentsToRemove = fm.fragments.filter { f ->
+ (f.id == 0) == mustHaveId
+ }
+ if (fragmentsToRemove.isEmpty()) {
+ return
+ }
+ fm.commit {
+ setReorderingAllowed(true)
+ for (f in fragmentsToRemove) {
+ remove(f)
+ }
+ }
+ }
+
+ private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List) {
+ val dialogBuilder = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.save_manga)
+ .setNegativeButton(android.R.string.cancel, null)
+ if (branches.size > 1) {
+ val items = Array(branches.size) { i -> branches[i].orEmpty() }
+ val currentBranch = viewModel.selectedBranchIndex.value ?: -1
+ val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
+ dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
+ checkedIndices[i] = checked
+ }.setPositiveButton(R.string.save) { _, _ ->
+ val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
+ val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
+ if (c.branch in selectedBranches) c.id else null
+ }
+ DownloadService.start(this, manga, chaptersIds)
+ }
+ } else {
+ dialogBuilder.setMessage(
+ getString(
+ R.string.large_manga_save_confirm,
+ resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
+ )
+ ).setPositiveButton(R.string.save) { _, _ ->
+ DownloadService.start(this, manga)
+ }
+ }
+ dialogBuilder.show()
+ }
+
+ companion object {
+
+ fun newIntent(context: Context, manga: Manga): Intent {
+ return Intent(context, DetailsActivity::class.java)
+ .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
+ }
+
+ fun newIntent(context: Context, mangaId: Long): Intent {
+ return Intent(context, DetailsActivity::class.java)
+ .putExtra(MangaIntent.KEY_ID, mangaId)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
new file mode 100644
index 000000000..47ee11616
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -0,0 +1,382 @@
+package org.koitharu.kotatsu.details.ui
+
+import android.app.ActivityOptions
+import android.os.Bundle
+import android.text.Spanned
+import android.text.method.LinkMovementMethod
+import android.view.*
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.Insets
+import androidx.core.net.toFile
+import androidx.core.net.toUri
+import androidx.core.text.parseAsHtml
+import androidx.core.view.MenuProvider
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.size.Scale
+import coil.util.CoilUtils
+import com.google.android.material.chip.Chip
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseFragment
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
+import org.koitharu.kotatsu.base.ui.widgets.ChipsView
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
+import org.koitharu.kotatsu.core.model.MangaHistory
+import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
+import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
+import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.image.ui.ImageActivity
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.model.MangaState
+import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
+import org.koitharu.kotatsu.search.ui.MangaListActivity
+import org.koitharu.kotatsu.search.ui.SearchActivity
+import org.koitharu.kotatsu.utils.FileSize
+import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.utils.ext.*
+
+class DetailsFragment :
+ BaseFragment(),
+ View.OnClickListener,
+ View.OnLongClickListener,
+ ChipsView.OnChipClickListener,
+ OnListItemClickListener {
+
+ private val viewModel by sharedViewModel()
+ private val coil by inject(mode = LazyThreadSafetyMode.NONE)
+
+ override fun onInflateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ ) = FragmentDetailsBinding.inflate(inflater, container, false)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.textViewAuthor.setOnClickListener(this)
+ binding.buttonFavorite.setOnClickListener(this)
+ binding.buttonRead.setOnClickListener(this)
+ binding.buttonRead.setOnLongClickListener(this)
+ binding.imageViewCover.setOnClickListener(this)
+ binding.scrobblingLayout.root.setOnClickListener(this)
+ binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
+ binding.chipsTags.onChipClickListener = this
+ viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
+ viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
+ viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
+ viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
+ viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
+ addMenuProvider(DetailsMenuProvider())
+ }
+
+ override fun onItemClick(item: Bookmark, view: View) {
+ val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
+ startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle())
+ }
+
+ override fun onItemLongClick(item: Bookmark, view: View): Boolean {
+ val menu = PopupMenu(view.context, view)
+ menu.inflate(R.menu.popup_bookmark)
+ menu.setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ R.id.action_remove -> viewModel.removeBookmark(item)
+ }
+ true
+ }
+ menu.show()
+ return true
+ }
+
+ private fun onMangaUpdated(manga: Manga) {
+ with(binding) {
+ // Main
+ loadCover(manga)
+ textViewTitle.text = manga.title
+ textViewSubtitle.textAndVisible = manga.altTitle
+ textViewAuthor.textAndVisible = manga.author
+ textViewDescription.text = manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
+ ?: getString(R.string.no_description)
+ when (manga.state) {
+ MangaState.FINISHED -> {
+ textViewState.apply {
+ textAndVisible = resources.getString(R.string.state_finished)
+ drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
+ }
+ }
+ MangaState.ONGOING -> {
+ textViewState.apply {
+ textAndVisible = resources.getString(R.string.state_ongoing)
+ drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
+ }
+ }
+ else -> textViewState.isVisible = false
+ }
+
+ // Info containers
+ val chapters = manga.chapters
+ if (chapters.isNullOrEmpty()) {
+ infoLayout.textViewChapters.isVisible = false
+ } else {
+ infoLayout.textViewChapters.isVisible = true
+ infoLayout.textViewChapters.text = resources.getQuantityString(
+ R.plurals.chapters,
+ chapters.size,
+ chapters.size,
+ )
+ }
+ if (manga.hasRating) {
+ infoLayout.textViewRating.text = String.format("%.1f", manga.rating * 5)
+ infoLayout.ratingContainer.isVisible = true
+ } else {
+ infoLayout.ratingContainer.isVisible = false
+ }
+ if (manga.source == MangaSource.LOCAL) {
+ infoLayout.textViewSource.isVisible = false
+ val file = manga.url.toUri().toFileOrNull()
+ if (file != null) {
+ viewLifecycleScope.launch {
+ val size = file.computeSize()
+ infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size)
+ infoLayout.textViewSize.isVisible = true
+ }
+ } else {
+ infoLayout.textViewSize.isVisible = false
+ }
+ } else {
+ infoLayout.textViewSource.text = manga.source.title
+ infoLayout.textViewSource.isVisible = true
+ infoLayout.textViewSize.isVisible = false
+ }
+
+ infoLayout.textViewNsfw.isVisible = manga.isNsfw
+
+ // Buttons
+ buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
+
+ // Chips
+ bindTags(manga)
+ }
+ }
+
+ private fun onHistoryChanged(history: MangaHistory?) {
+ with(binding.buttonRead) {
+ if (history == null) {
+ setText(R.string.read)
+ setIconResource(R.drawable.ic_read)
+ } else {
+ setText(R.string._continue)
+ setIconResource(R.drawable.ic_play)
+ }
+ }
+ binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
+ }
+
+ private fun onFavouriteChanged(isFavourite: Boolean) {
+ val iconRes = if (isFavourite) {
+ R.drawable.ic_heart
+ } else {
+ R.drawable.ic_heart_outline
+ }
+ binding.buttonFavorite.setIconResource(iconRes)
+ }
+
+ private fun onLoadingStateChanged(isLoading: Boolean) {
+ if (isLoading) {
+ binding.progressBar.show()
+ } else {
+ binding.progressBar.hide()
+ }
+ }
+
+ private fun onBookmarksChanged(bookmarks: List) {
+ var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter
+ binding.groupBookmarks.isGone = bookmarks.isEmpty()
+ if (adapter != null) {
+ adapter.items = bookmarks
+ } else {
+ adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
+ adapter.items = bookmarks
+ binding.recyclerViewBookmarks.adapter = adapter
+ val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
+ binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
+ }
+ }
+
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ with(binding.scrobblingLayout) {
+ root.isVisible = scrobbling != null
+ if (scrobbling == null) {
+ CoilUtils.dispose(imageViewCover)
+ return
+ }
+ imageViewCover.newImageRequest(scrobbling.coverUrl)
+ .crossfade(true)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .scale(Scale.FILL)
+ .lifecycle(viewLifecycleOwner)
+ .enqueueWith(coil)
+ textViewTitle.text = scrobbling.title
+ textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
+ ratingBar.rating = scrobbling.rating * ratingBar.numStars
+ textViewStatus.text = scrobbling.status?.let {
+ resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
+ }
+ }
+ }
+
+ override fun onClick(v: View) {
+ val manga = viewModel.manga.value ?: return
+ when (v.id) {
+ R.id.button_favorite -> {
+ FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
+ }
+ R.id.scrobbling_layout -> {
+ ScrobblingInfoBottomSheet.show(childFragmentManager)
+ }
+ R.id.button_read -> {
+ val chapterId = viewModel.readingHistory.value?.chapterId
+ if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
+ (activity as? DetailsActivity)?.showChapterMissingDialog(chapterId)
+ } else {
+ startActivity(
+ ReaderActivity.newIntent(
+ context = context ?: return,
+ manga = manga,
+ branch = viewModel.selectedBranchValue,
+ )
+ )
+ }
+ }
+ R.id.textView_author -> {
+ startActivity(
+ SearchActivity.newIntent(
+ context = v.context,
+ source = manga.source,
+ query = manga.author ?: return,
+ )
+ )
+ }
+ R.id.imageView_cover -> {
+ val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
+ startActivity(
+ ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
+ options.toBundle()
+ )
+ }
+ }
+ }
+
+ override fun onLongClick(v: View): Boolean {
+ when (v.id) {
+ R.id.button_read -> {
+ if (viewModel.readingHistory.value == null) {
+ return false
+ }
+ val menu = PopupMenu(v.context, v)
+ menu.inflate(R.menu.popup_read)
+ menu.setOnMenuItemClickListener { menuItem ->
+ when (menuItem.itemId) {
+ R.id.action_read -> {
+ val branch = viewModel.selectedBranchValue
+ startActivity(
+ ReaderActivity.newIntent(
+ context = context ?: return@setOnMenuItemClickListener false,
+ manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
+ state = viewModel.chapters.value?.firstOrNull { c ->
+ c.chapter.branch == branch
+ }?.let { c ->
+ ReaderState(c.chapter.id, 0, 0)
+ }
+ )
+ )
+ true
+ }
+ else -> false
+ }
+ }
+ menu.show()
+ return true
+ }
+ else -> return false
+ }
+ }
+
+ override fun onChipClick(chip: Chip, data: Any?) {
+ val tag = data as? MangaTag ?: return
+ startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.root.updatePadding(
+ bottom = insets.bottom,
+ )
+ }
+
+ private fun bindTags(manga: Manga) {
+ binding.chipsTags.setChips(
+ manga.tags.map { tag ->
+ ChipsView.ChipModel(
+ title = tag.title,
+ icon = 0,
+ data = tag,
+ )
+ }
+ )
+ }
+
+ private fun loadCover(manga: Manga) {
+ val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
+ val lastResult = CoilUtils.result(binding.imageViewCover)
+ if (lastResult?.request?.data == imageUrl) {
+ return
+ }
+ val request = ImageRequest.Builder(context ?: return)
+ .target(binding.imageViewCover)
+ .data(imageUrl)
+ .crossfade(true)
+ .referer(manga.publicUrl)
+ .lifecycle(viewLifecycleOwner)
+ lastResult?.drawable?.let {
+ request.fallback(it)
+ } ?: request.fallback(R.drawable.ic_placeholder)
+ request.enqueueWith(coil)
+ }
+
+ private inner class DetailsMenuProvider : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_details_info, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_share -> {
+ viewModel.manga.value?.let {
+ val context = requireContext()
+ if (it.source == MangaSource.LOCAL) {
+ ShareHelper(context).shareCbz(listOf(it.url.toUri().toFile()))
+ } else {
+ ShareHelper(context).shareMangaLink(it)
+ }
+ }
+ true
+ }
+ else -> false
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
new file mode 100644
index 000000000..71a6c49fb
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -0,0 +1,230 @@
+package org.koitharu.kotatsu.details.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.MangaDataRepository
+import org.koitharu.kotatsu.base.domain.MangaIntent
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsFlow
+import org.koitharu.kotatsu.details.domain.BranchComparator
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem
+import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
+import org.koitharu.kotatsu.tracker.domain.TrackingRepository
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import java.io.IOException
+
+class DetailsViewModel(
+ intent: MangaIntent,
+ private val historyRepository: HistoryRepository,
+ favouritesRepository: FavouritesRepository,
+ private val localMangaRepository: LocalMangaRepository,
+ trackingRepository: TrackingRepository,
+ mangaDataRepository: MangaDataRepository,
+ private val bookmarksRepository: BookmarksRepository,
+ private val settings: AppSettings,
+ private val scrobbler: Scrobbler,
+) : BaseViewModel() {
+
+ private val delegate = MangaDetailsDelegate(
+ intent = intent,
+ settings = settings,
+ mangaDataRepository = mangaDataRepository,
+ historyRepository = historyRepository,
+ localMangaRepository = localMangaRepository,
+ )
+
+ private var loadingJob: Job
+
+ val onShowToast = SingleLiveEvent()
+
+ private val history = historyRepository.observeOne(delegate.mangaId)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
+
+ private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
+
+ private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
+
+ private val chaptersQuery = MutableStateFlow("")
+
+ private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
+
+ val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
+ val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
+ val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
+ val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
+ val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
+
+ val bookmarks = delegate.manga.flatMapLatest {
+ if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+
+ val onMangaRemoved = SingleLiveEvent()
+ val isScrobblingAvailable: Boolean
+ get() = scrobbler.isAvailable
+
+ val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
+ .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
+
+ val branches: LiveData> = delegate.manga.map {
+ val chapters = it?.chapters ?: return@map emptyList()
+ chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+
+ val selectedBranchIndex = combine(
+ branches.asFlow(),
+ delegate.selectedBranch
+ ) { branches, selected ->
+ branches.indexOf(selected)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+
+ val isChaptersEmpty: LiveData = combine(
+ delegate.manga,
+ isLoading.asFlow(),
+ ) { m, loading ->
+ m != null && m.chapters.isNullOrEmpty() && !loading
+ }.asLiveDataDistinct(viewModelScope.coroutineContext, false)
+
+ val chapters = combine(
+ combine(
+ delegate.manga,
+ delegate.relatedManga,
+ history,
+ delegate.selectedBranch,
+ newChapters,
+ ) { manga, related, history, branch, news ->
+ delegate.mapChapters(manga, related, history, news, branch)
+ },
+ chaptersReversed,
+ chaptersQuery,
+ ) { list, reversed, query ->
+ (if (reversed) list.asReversed() else list).filterSearch(query)
+ }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
+
+ val selectedBranchValue: String?
+ get() = delegate.selectedBranch.value
+
+ init {
+ loadingJob = doLoad()
+ }
+
+ fun reload() {
+ loadingJob.cancel()
+ loadingJob = doLoad()
+ }
+
+ fun deleteLocal() {
+ val m = delegate.manga.value
+ if (m == null) {
+ onShowToast.call(R.string.file_not_found)
+ return
+ }
+ launchLoadingJob(Dispatchers.Default) {
+ val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
+ checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
+ val original = localMangaRepository.getRemoteManga(manga)
+ localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
+ runCatching {
+ historyRepository.deleteOrSwap(manga, original)
+ }
+ onMangaRemoved.postCall(manga)
+ }
+ }
+
+ fun removeBookmark(bookmark: Bookmark) {
+ launchJob {
+ bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
+ onShowToast.call(R.string.bookmark_removed)
+ }
+ }
+
+ fun setChaptersReversed(newValue: Boolean) {
+ settings.chaptersReverse = newValue
+ }
+
+ fun setSelectedBranch(branch: String?) {
+ delegate.selectedBranch.value = branch
+ }
+
+ fun getRemoteManga(): Manga? {
+ return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
+ }
+
+ fun performChapterSearch(query: String?) {
+ chaptersQuery.value = query?.trim().orEmpty()
+ }
+
+ fun onDownloadComplete(downloadedManga: Manga) {
+ val currentManga = delegate.manga.value ?: return
+ if (currentManga.id != downloadedManga.id) {
+ return
+ }
+ if (currentManga.source == MangaSource.LOCAL) {
+ reload()
+ } else {
+ viewModelScope.launch(Dispatchers.Default) {
+ runCatching {
+ localMangaRepository.getDetails(downloadedManga)
+ }.onSuccess {
+ delegate.relatedManga.value = it
+ }.onFailure {
+ it.printStackTraceDebug()
+ }
+ }
+ }
+ }
+
+ fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
+ launchJob(Dispatchers.Default) {
+ scrobbler.updateScrobblingInfo(
+ mangaId = delegate.mangaId,
+ rating = rating,
+ status = status,
+ comment = null,
+ )
+ }
+ }
+
+ fun unregisterScrobbling() {
+ launchJob(Dispatchers.Default) {
+ scrobbler.unregisterScrobbling(
+ mangaId = delegate.mangaId
+ )
+ }
+ }
+
+ private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
+ delegate.doLoad()
+ }
+
+ private fun List.filterSearch(query: String): List {
+ if (query.isEmpty() || this.isEmpty()) {
+ return this
+ }
+ return filter {
+ it.chapter.name.contains(query, ignoreCase = true)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsAdapter.kt
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..c6c45ecc1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
@@ -0,0 +1,189 @@
+package org.koitharu.kotatsu.details.ui
+
+import androidx.core.os.LocaleListCompat
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.acra.ACRA
+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.exception.ParseException
+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
+import org.koitharu.kotatsu.utils.ext.setCurrentManga
+
+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")
+ ACRA.setCurrentManga(manga)
+ mangaData.value = manga
+ manga = MangaRepository(manga.source).getDetails(manga)
+ // find default branch
+ val hist = historyRepository.getOne(manga)
+ selectedBranch.value = if (hist != null) {
+ val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
+ if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
+ } else {
+ predictBranch(manga.chapters)
+ }
+ mangaData.value = manga
+ relatedManga.value = runCatching {
+ if (manga.source == MangaSource.LOCAL) {
+ val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
+ MangaRepository(m.source).getDetails(m)
+ } else {
+ localMangaRepository.findSavedManga(manga)
+ }
+ }.onFailure { error ->
+ error.printStackTraceDebug()
+ }.getOrNull()
+ }
+
+ fun mapChapters(
+ manga: Manga?,
+ related: Manga?,
+ history: MangaHistory?,
+ newCount: Int,
+ branch: String?,
+ ): List {
+ val chapters = manga?.chapters ?: return emptyList()
+ val relatedChapters = related?.chapters
+ return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
+ mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch)
+ } else {
+ mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch)
+ }
+ }
+
+ private fun mapChapters(
+ chapters: List,
+ downloadedChapters: List?,
+ currentId: Long?,
+ newCount: Int,
+ branch: String?,
+ ): List {
+ val result = ArrayList(chapters.size)
+ val dateFormat = settings.getDateFormat()
+ val currentIndex = chapters.indexOfFirst { it.id == currentId }
+ val firstNewIndex = chapters.size - newCount
+ val downloadedIds = downloadedChapters?.mapToSet { it.id }
+ for (i in chapters.indices) {
+ val chapter = chapters[i]
+ if (chapter.branch != branch) {
+ continue
+ }
+ result += chapter.toListItem(
+ isCurrent = i == currentIndex,
+ isUnread = i > currentIndex,
+ isNew = i >= firstNewIndex,
+ isMissing = false,
+ isDownloaded = downloadedIds?.contains(chapter.id) == true,
+ dateFormat = dateFormat,
+ )
+ }
+ return result
+ }
+
+ private fun mapChaptersWithSource(
+ chapters: List,
+ sourceChapters: List,
+ currentId: Long?,
+ newCount: Int,
+ branch: String?,
+ ): List {
+ val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
+ val result = ArrayList(sourceChapters.size)
+ val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
+ val firstNewIndex = sourceChapters.size - newCount
+ val dateFormat = settings.getDateFormat()
+ for (i in sourceChapters.indices) {
+ val chapter = sourceChapters[i]
+ val localChapter = chaptersMap.remove(chapter.id)
+ if (chapter.branch != branch) {
+ continue
+ }
+ result += localChapter?.toListItem(
+ isCurrent = i == currentIndex,
+ isUnread = i > currentIndex,
+ isNew = i >= firstNewIndex,
+ isMissing = false,
+ isDownloaded = false,
+ dateFormat = dateFormat,
+ ) ?: chapter.toListItem(
+ isCurrent = i == currentIndex,
+ isUnread = i > currentIndex,
+ isNew = i >= firstNewIndex,
+ isMissing = true,
+ isDownloaded = false,
+ dateFormat = dateFormat,
+ )
+ }
+ if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
+ result.ensureCapacity(result.size + chaptersMap.size)
+ chaptersMap.values.mapNotNullTo(result) {
+ if (it.branch == branch) {
+ it.toListItem(
+ isCurrent = false,
+ isUnread = true,
+ isNew = false,
+ isMissing = false,
+ isDownloaded = false,
+ dateFormat = dateFormat,
+ )
+ } else {
+ null
+ }
+ }
+ result.sortBy { it.chapter.number }
+ }
+ return result
+ }
+
+ private fun predictBranch(chapters: List?): String? {
+ if (chapters.isNullOrEmpty()) {
+ return null
+ }
+ val groups = chapters.groupBy { it.branch }
+ for (locale in LocaleListCompat.getAdjustedDefault()) {
+ var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
+ if (groups.containsKey(language)) {
+ return language
+ }
+ language = locale.getDisplayName(locale).toTitleCase(locale)
+ if (groups.containsKey(language)) {
+ return language
+ }
+ }
+ return groups.maxByOrNull { it.value.size }?.key
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt
new file mode 100644
index 000000000..2ca602df9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/BranchesAdapter.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.details.ui.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.BaseAdapter
+import android.widget.TextView
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.parsers.util.replaceWith
+
+class BranchesAdapter : BaseAdapter() {
+
+ private val dataSet = ArrayList()
+
+ override fun getCount(): Int {
+ return dataSet.size
+ }
+
+ override fun getItem(position: Int): Any? {
+ return dataSet[position]
+ }
+
+ override fun getItemId(position: Int): Long {
+ return dataSet[position].hashCode().toLong()
+ }
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_branch, parent, false)
+ (view as TextView).text = dataSet[position]
+ return view
+ }
+
+ override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val view = convertView ?: LayoutInflater.from(parent.context)
+ .inflate(R.layout.item_branch_dropdown, parent, false)
+ (view as TextView).text = dataSet[position]
+ return view
+ }
+
+ fun setItems(items: Collection) {
+ dataSet.replaceWith(items)
+ notifyDataSetChanged()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
similarity index 50%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
index 6ff818ccf..f65951d47 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt
@@ -3,18 +3,22 @@ package org.koitharu.kotatsu.details.ui.adapter
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
-import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.util.ext.getThemeColor
-import org.koitharu.kotatsu.core.util.ext.textAndVisible
+import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
-import com.google.android.material.R as materialR
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
+import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.textAndVisible
fun chapterListItemAD(
clickListener: OnListItemClickListener,
) = adapterDelegateViewBinding(
- { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
+ { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
@@ -27,23 +31,26 @@ fun chapterListItemAD(
binding.textViewNumber.text = item.chapter.number.toString()
binding.textViewDescription.textAndVisible = item.description()
}
- when {
- item.isCurrent -> {
- binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_primary)
- binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnPrimary))
- }
-
- item.isUnread -> {
+ when (item.status) {
+ FLAG_UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
- binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnTertiary))
+ binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse))
+ }
+ FLAG_CURRENT -> {
+ binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
+ binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
}
-
else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
}
}
- binding.imageViewDownloaded.isVisible = item.isDownloaded
- binding.imageViewNew.isVisible = item.isNew
+ val isMissing = item.hasFlag(FLAG_MISSING)
+ binding.textViewTitle.alpha = if (isMissing) 0.3f else 1f
+ binding.textViewDescription.alpha = if (isMissing) 0.3f else 1f
+ binding.textViewNumber.alpha = if (isMissing) 0.3f else 1f
+
+ binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED)
+ binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
similarity index 72%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
index d1de826d9..033b9ed92 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
@@ -1,16 +1,14 @@
package org.koitharu.kotatsu.details.ui.adapter
-import android.content.Context
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener,
-) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer {
+) : AsyncListDifferDelegationAdapter(DiffCallback()) {
init {
setHasStableIds(true)
@@ -41,9 +39,4 @@ class ChaptersAdapter(
return null
}
}
-
- override fun getSectionText(context: Context, position: Int): CharSequence? {
- val item = items.getOrNull(position) ?: return null
- return item.chapter.number.toString()
- }
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt
similarity index 74%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt
index 505de1c4b..0ff2fbc94 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt
@@ -6,10 +6,9 @@ import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
-import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
-import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
-import org.koitharu.kotatsu.core.util.ext.getThemeColor
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import org.koitharu.kotatsu.utils.ext.getThemeColor
import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
@@ -18,10 +17,7 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
init {
- paint.color = ColorUtils.setAlphaComponent(
- context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY),
- 98,
- )
+ paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY)
paint.style = Paint.Style.FILL
}
@@ -34,4 +30,4 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
) {
canvas.drawRoundRect(bounds, radius, radius, paint)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
similarity index 57%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
index 2a62acb1e..2d5b90840 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
@@ -1,38 +1,19 @@
package org.koitharu.kotatsu.details.ui.model
-import android.text.format.DateUtils
import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
- private val uploadDateMs: Long,
+ val uploadDate: String?,
) {
- var uploadDate: CharSequence? = null
- private set
- get() {
- if (field != null) return field
- if (uploadDateMs == 0L) return null
- field = DateUtils.getRelativeTimeSpanString(
- uploadDateMs,
- System.currentTimeMillis(),
- DateUtils.DAY_IN_MILLIS,
- )
- return field
- }
-
- val isCurrent: Boolean
- get() = hasFlag(FLAG_CURRENT)
-
- val isUnread: Boolean
- get() = hasFlag(FLAG_UNREAD)
+ val status: Int
+ get() = flags and MASK_STATUS
- val isDownloaded: Boolean
- get() = hasFlag(FLAG_DOWNLOADED)
-
- val isNew: Boolean
- get() = hasFlag(FLAG_NEW)
+ fun hasFlag(flag: Int): Boolean {
+ return (flags and flag) == flag
+ }
fun description(): CharSequence? {
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
@@ -43,10 +24,6 @@ class ChapterListItem(
}
}
- private fun hasFlag(flag: Int): Boolean {
- return (flags and flag) == flag
- }
-
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
@@ -55,13 +32,15 @@ class ChapterListItem(
if (chapter != other.chapter) return false
if (flags != other.flags) return false
- return uploadDateMs == other.uploadDateMs
+ if (uploadDate != other.uploadDate) return false
+
+ return true
}
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
- result = 31 * result + uploadDateMs.hashCode()
+ result = 31 * result + (uploadDate?.hashCode() ?: 0)
return result
}
@@ -70,6 +49,8 @@ class ChapterListItem(
const val FLAG_UNREAD = 2
const val FLAG_CURRENT = 4
const val FLAG_NEW = 8
+ const val FLAG_MISSING = 16
const val FLAG_DOWNLOADED = 32
+ const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
similarity index 74%
rename from app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
rename to app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
index 73d70d3df..22d272346 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt
@@ -2,24 +2,29 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
+import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter
+import java.text.DateFormat
fun MangaChapter.toListItem(
isCurrent: Boolean,
isUnread: Boolean,
isNew: Boolean,
+ isMissing: Boolean,
isDownloaded: Boolean,
+ dateFormat: DateFormat,
): ChapterListItem {
var flags = 0
if (isCurrent) flags = flags or FLAG_CURRENT
if (isUnread) flags = flags or FLAG_UNREAD
if (isNew) flags = flags or FLAG_NEW
+ if (isMissing) flags = flags or FLAG_MISSING
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
return ChapterListItem(
chapter = this,
flags = flags,
- uploadDateMs = uploadDate,
+ uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
new file mode 100644
index 000000000..8347b0a7e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
@@ -0,0 +1,150 @@
+package org.koitharu.kotatsu.details.ui.scrobbling
+
+import android.app.ActivityOptions
+import android.content.Intent
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.RatingBar
+import android.widget.Toast
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentManager
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.size.Scale
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseBottomSheet
+import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
+import org.koitharu.kotatsu.details.ui.DetailsViewModel
+import org.koitharu.kotatsu.image.ui.ImageActivity
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class ScrobblingInfoBottomSheet :
+ BaseBottomSheet(),
+ AdapterView.OnItemSelectedListener,
+ RatingBar.OnRatingBarChangeListener,
+ View.OnClickListener,
+ PopupMenu.OnMenuItemClickListener {
+
+ private val viewModel by sharedViewModel()
+ private val coil by inject(mode = LazyThreadSafetyMode.NONE)
+ private var menu: PopupMenu? = null
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
+ return SheetScrobblingBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
+ viewModel.onError.observe(viewLifecycleOwner) {
+ Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show()
+ }
+
+ binding.spinnerStatus.onItemSelectedListener = this
+ binding.ratingBar.onRatingBarChangeListener = this
+ binding.buttonMenu.setOnClickListener(this)
+ binding.imageViewCover.setOnClickListener(this)
+ binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
+
+ menu = PopupMenu(view.context, binding.buttonMenu).apply {
+ inflate(R.menu.opt_scrobbling)
+ setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ menu = null
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ viewModel.updateScrobbling(
+ rating = binding.ratingBar.rating / binding.ratingBar.numStars,
+ status = enumValues().getOrNull(position),
+ )
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
+ if (fromUser) {
+ viewModel.updateScrobbling(
+ rating = rating / ratingBar.numStars,
+ status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition),
+ )
+ }
+ }
+
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_menu -> menu?.show()
+ R.id.imageView_cover -> {
+ val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
+ val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
+ startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
+ }
+ }
+ }
+
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ if (scrobbling == null) {
+ dismissAllowingStateLoss()
+ return
+ }
+ binding.textViewTitle.text = scrobbling.title
+ binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
+ binding.textViewDescription.text = scrobbling.description
+ binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
+ ImageRequest.Builder(context ?: return)
+ .target(binding.imageViewCover)
+ .data(scrobbling.coverUrl)
+ .crossfade(true)
+ .lifecycle(viewLifecycleOwner)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .scale(Scale.FILL)
+ .enqueueWith(coil)
+ }
+
+ companion object {
+
+ private const val TAG = "ScrobblingInfoBottomSheet"
+
+ fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_browser -> {
+ val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ startActivity(
+ Intent.createChooser(intent, getString(R.string.open_in_browser))
+ )
+ }
+ R.id.action_unregister -> {
+ viewModel.unregisterScrobbling()
+ dismiss()
+ }
+ R.id.action_edit -> {
+ val manga = viewModel.manga.value ?: return false
+ ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
+ dismiss()
+ }
+ }
+ return true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
new file mode 100644
index 000000000..d079eb51f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -0,0 +1,231 @@
+package org.koitharu.kotatsu.download.domain
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.webkit.MimeTypeMap
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.size.Scale
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.sync.Semaphore
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okio.IOException
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.network.CommonHeaders
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.local.data.PagesCache
+import org.koitharu.kotatsu.local.domain.CbzMangaOutput
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.await
+import org.koitharu.kotatsu.utils.ext.deleteAwait
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.referer
+import org.koitharu.kotatsu.utils.ext.waitForNetwork
+import org.koitharu.kotatsu.utils.progress.ProgressJob
+import java.io.File
+
+private const val MAX_DOWNLOAD_ATTEMPTS = 3
+private const val DOWNLOAD_ERROR_DELAY = 500L
+private const val SLOWDOWN_DELAY = 200L
+
+class DownloadManager(
+ private val coroutineScope: CoroutineScope,
+ private val context: Context,
+ private val imageLoader: ImageLoader,
+ private val okHttp: OkHttpClient,
+ private val cache: PagesCache,
+ private val localMangaRepository: LocalMangaRepository,
+ private val settings: AppSettings,
+) {
+
+ private val connectivityManager = context.getSystemService(
+ Context.CONNECTIVITY_SERVICE
+ ) as ConnectivityManager
+ private val coverWidth = context.resources.getDimensionPixelSize(
+ androidx.core.R.dimen.compat_notification_large_icon_max_width
+ )
+ private val coverHeight = context.resources.getDimensionPixelSize(
+ androidx.core.R.dimen.compat_notification_large_icon_max_height
+ )
+ private val semaphore = Semaphore(settings.downloadsParallelism)
+
+ fun downloadManga(
+ manga: Manga,
+ chaptersIds: LongArray?,
+ startId: Int,
+ ): ProgressJob {
+ val stateFlow = MutableStateFlow(
+ DownloadState.Queued(startId = startId, manga = manga, cover = null)
+ )
+ val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
+ return ProgressJob(job, stateFlow)
+ }
+
+ private fun downloadMangaImpl(
+ manga: Manga,
+ chaptersIds: LongArray?,
+ outState: MutableStateFlow,
+ startId: Int,
+ ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
+ @Suppress("NAME_SHADOWING") var manga = manga
+ val chaptersIdsSet = chaptersIds?.toMutableSet()
+ val cover = loadCover(manga)
+ outState.value = DownloadState.Queued(startId, manga, cover)
+ localMangaRepository.lockManga(manga.id)
+ semaphore.acquire()
+ coroutineContext[WakeLockNode]?.acquire()
+ outState.value = DownloadState.Preparing(startId, manga, null)
+ val destination = localMangaRepository.getOutputDir()
+ checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
+ val tempFileName = "${manga.id}_$startId.tmp"
+ var output: CbzMangaOutput? = null
+ try {
+ if (manga.source == MangaSource.LOCAL) {
+ manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
+ }
+ val repo = MangaRepository(manga.source)
+ outState.value = DownloadState.Preparing(startId, manga, cover)
+ val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
+ output = CbzMangaOutput.get(destination, data)
+ val coverUrl = data.largeCoverUrl ?: data.coverUrl
+ downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
+ output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
+ }
+ val chapters = checkNotNull(
+ if (chaptersIdsSet == null) {
+ data.chapters
+ } else {
+ data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
+ }
+ ) { "Chapters list must not be null" }
+ check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
+ check(chaptersIdsSet.isNullOrEmpty()) {
+ "${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
+ }
+ for ((chapterIndex, chapter) in chapters.withIndex()) {
+ val pages = repo.getPages(chapter)
+ for ((pageIndex, page) in pages.withIndex()) {
+ var retryCounter = 0
+ failsafe@ while (true) {
+ try {
+ val url = repo.getPageUrl(page)
+ val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
+ output.addPage(
+ chapter = chapter,
+ file = file,
+ pageNumber = pageIndex,
+ ext = MimeTypeMap.getFileExtensionFromUrl(url),
+ )
+ break@failsafe
+ } catch (e: IOException) {
+ if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
+ outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
+ delay(DOWNLOAD_ERROR_DELAY)
+ connectivityManager.waitForNetwork()
+ retryCounter++
+ } else {
+ throw e
+ }
+ }
+ }
+
+ outState.value = DownloadState.Progress(
+ startId, data, cover,
+ totalChapters = chapters.size,
+ currentChapter = chapterIndex,
+ totalPages = pages.size,
+ currentPage = pageIndex,
+ )
+
+ if (settings.isDownloadsSlowdownEnabled) {
+ delay(SLOWDOWN_DELAY)
+ }
+ }
+ }
+ outState.value = DownloadState.PostProcessing(startId, data, cover)
+ output.mergeWithExisting()
+ output.finalize()
+ val localManga = localMangaRepository.getFromFile(output.file)
+ outState.value = DownloadState.Done(startId, data, cover, localManga)
+ } catch (e: CancellationException) {
+ outState.value = DownloadState.Cancelled(startId, manga, cover)
+ throw e
+ } catch (e: Throwable) {
+ e.printStackTraceDebug()
+ outState.value = DownloadState.Error(startId, manga, cover, e)
+ } finally {
+ withContext(NonCancellable) {
+ output?.cleanup()
+ File(destination, tempFileName).deleteAwait()
+ }
+ coroutineContext[WakeLockNode]?.release()
+ semaphore.release()
+ localMangaRepository.unlockManga(manga.id)
+ }
+ }
+
+ private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
+ val request = Request.Builder()
+ .url(url)
+ .header(CommonHeaders.REFERER, referer)
+ .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
+ .get()
+ .build()
+ val call = okHttp.newCall(request)
+ val file = File(destination, tempFileName)
+ val response = call.clone().await()
+ runInterruptible(Dispatchers.IO) {
+ file.outputStream().use { out ->
+ checkNotNull(response.body).byteStream().copyTo(out)
+ }
+ }
+ return file
+ }
+
+ private fun errorStateHandler(outState: MutableStateFlow) =
+ CoroutineExceptionHandler { _, throwable ->
+ val prevValue = outState.value
+ outState.value = DownloadState.Error(
+ startId = prevValue.startId,
+ manga = prevValue.manga,
+ cover = prevValue.cover,
+ error = throwable,
+ )
+ }
+
+ private suspend fun loadCover(manga: Manga) = runCatching {
+ imageLoader.execute(
+ ImageRequest.Builder(context)
+ .data(manga.coverUrl)
+ .referer(manga.publicUrl)
+ .size(coverWidth, coverHeight)
+ .scale(Scale.FILL)
+ .build()
+ ).drawable
+ }.getOrNull()
+
+ class Factory(
+ private val context: Context,
+ private val imageLoader: ImageLoader,
+ private val okHttp: OkHttpClient,
+ private val cache: PagesCache,
+ private val localMangaRepository: LocalMangaRepository,
+ private val settings: AppSettings,
+ ) {
+
+ fun create(coroutineScope: CoroutineScope) = DownloadManager(
+ coroutineScope = coroutineScope,
+ context = context,
+ imageLoader = imageLoader,
+ okHttp = okHttp,
+ cache = cache,
+ localMangaRepository = localMangaRepository,
+ settings = settings,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt
new file mode 100644
index 000000000..a0a78ac7a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt
@@ -0,0 +1,251 @@
+package org.koitharu.kotatsu.download.domain
+
+import android.graphics.drawable.Drawable
+import org.koitharu.kotatsu.parsers.model.Manga
+
+sealed interface DownloadState {
+
+ val startId: Int
+ val manga: Manga
+ val cover: Drawable?
+
+ class Queued(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Queued
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ return result
+ }
+ }
+
+ class Preparing(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Preparing
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ return result
+ }
+ }
+
+ class Progress(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val totalChapters: Int,
+ val currentChapter: Int,
+ val totalPages: Int,
+ val currentPage: Int,
+ ) : DownloadState {
+
+ val max: Int = totalChapters * totalPages
+
+ val progress: Int = totalPages * currentChapter + currentPage + 1
+
+ val percent: Float = progress.toFloat() / max
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Progress
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+ if (totalChapters != other.totalChapters) return false
+ if (currentChapter != other.currentChapter) return false
+ if (totalPages != other.totalPages) return false
+ if (currentPage != other.currentPage) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ result = 31 * result + totalChapters
+ result = 31 * result + currentChapter
+ result = 31 * result + totalPages
+ result = 31 * result + currentPage
+ return result
+ }
+ }
+
+ class WaitingForNetwork(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as WaitingForNetwork
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ return result
+ }
+ }
+
+ class Done(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val localManga: Manga,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Done
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+ if (localManga != other.localManga) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ result = 31 * result + localManga.hashCode()
+ return result
+ }
+ }
+
+ class Error(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ val error: Throwable,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Error
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+ if (error != other.error) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ result = 31 * result + error.hashCode()
+ return result
+ }
+ }
+
+ class Cancelled(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Cancelled
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ return result
+ }
+ }
+
+ class PostProcessing(
+ override val startId: Int,
+ override val manga: Manga,
+ override val cover: Drawable?,
+ ) : DownloadState {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as PostProcessing
+
+ if (startId != other.startId) return false
+ if (manga != other.manga) return false
+ if (cover != other.cover) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = startId
+ result = 31 * result + manga.hashCode()
+ result = 31 * result + (cover?.hashCode() ?: 0)
+ return result
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt
new file mode 100644
index 000000000..8bbfc2f2d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/WakeLockNode.kt
@@ -0,0 +1,25 @@
+package org.koitharu.kotatsu.download.domain
+
+import android.os.PowerManager
+import kotlin.coroutines.AbstractCoroutineContextElement
+import kotlin.coroutines.CoroutineContext
+
+class WakeLockNode(
+ private val wakeLock: PowerManager.WakeLock,
+ private val timeout: Long,
+) : AbstractCoroutineContextElement(Key) {
+
+ init {
+ wakeLock.setReferenceCounted(true)
+ }
+
+ fun acquire() {
+ wakeLock.acquire(timeout)
+ }
+
+ fun release() {
+ wakeLock.release()
+ }
+
+ companion object Key : CoroutineContext.Key
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
new file mode 100644
index 000000000..1a86d0153
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
@@ -0,0 +1,108 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.core.view.isVisible
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.ItemDownloadBinding
+import org.koitharu.kotatsu.download.domain.DownloadState
+import org.koitharu.kotatsu.parsers.util.format
+import org.koitharu.kotatsu.utils.ext.*
+import org.koitharu.kotatsu.utils.progress.ProgressJob
+
+fun downloadItemAD(
+ scope: CoroutineScope,
+ coil: ImageLoader,
+) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>(
+ { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
+) {
+
+ var job: Job? = null
+ val percentPattern = context.resources.getString(R.string.percent_string_pattern)
+
+ bind {
+ job?.cancel()
+ job = item.progressAsFlow().onFirst { state ->
+ binding.imageViewCover.newImageRequest(state.manga.coverUrl)
+ .referer(state.manga.publicUrl)
+ .placeholder(state.cover)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .allowRgb565(true)
+ .enqueueWith(coil)
+ }.onEach { state ->
+ binding.textViewTitle.text = state.manga.title
+ when (state) {
+ is DownloadState.Cancelled -> {
+ binding.textViewStatus.setText(R.string.cancelling_)
+ binding.progressBar.isIndeterminate = true
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.Done -> {
+ binding.textViewStatus.setText(R.string.download_complete)
+ binding.progressBar.isIndeterminate = false
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.Error -> {
+ binding.textViewStatus.setText(R.string.error_occurred)
+ binding.progressBar.isIndeterminate = false
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
+ binding.textViewDetails.isVisible = true
+ }
+ is DownloadState.PostProcessing -> {
+ binding.textViewStatus.setText(R.string.processing_)
+ binding.progressBar.isIndeterminate = true
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.Preparing -> {
+ binding.textViewStatus.setText(R.string.preparing_)
+ binding.progressBar.isIndeterminate = true
+ binding.progressBar.isVisible = true
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.Progress -> {
+ binding.textViewStatus.setText(R.string.manga_downloading_)
+ binding.progressBar.isIndeterminate = false
+ binding.progressBar.isVisible = true
+ binding.progressBar.max = state.max
+ binding.progressBar.setProgressCompat(state.progress, true)
+ binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
+ binding.textViewPercent.isVisible = true
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.Queued -> {
+ binding.textViewStatus.setText(R.string.queued)
+ binding.progressBar.isIndeterminate = false
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ is DownloadState.WaitingForNetwork -> {
+ binding.textViewStatus.setText(R.string.waiting_for_network)
+ binding.progressBar.isIndeterminate = false
+ binding.progressBar.isVisible = false
+ binding.textViewPercent.isVisible = false
+ binding.textViewDetails.isVisible = false
+ }
+ }
+ }.launchIn(scope)
+ }
+
+ onViewRecycled {
+ job?.cancel()
+ job = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
new file mode 100644
index 000000000..a0c6c63dd
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt
@@ -0,0 +1,57 @@
+package org.koitharu.kotatsu.download.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.graphics.Insets
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.koin.android.ext.android.get
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.utils.bindServiceWithLifecycle
+
+class DownloadsActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ val adapter = DownloadsAdapter(lifecycleScope, get())
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.adapter = adapter
+ bindServiceWithLifecycle(
+ owner = this,
+ service = Intent(this, DownloadService::class.java),
+ flags = 0,
+ ).service.flatMapLatest { binder ->
+ (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null)
+ }.onEach {
+ adapter.items = it?.toList().orEmpty()
+ binding.textViewHolder.isVisible = it.isNullOrEmpty()
+ }.launchIn(lifecycleScope)
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.recyclerView.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ bottom = insets.bottom
+ )
+ binding.toolbar.updatePadding(
+ left = insets.left,
+ right = insets.right
+ )
+ }
+
+ companion object {
+
+ fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
new file mode 100644
index 000000000..0c3629d1b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.download.ui
+
+import androidx.recyclerview.widget.DiffUtil
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import kotlinx.coroutines.CoroutineScope
+import org.koitharu.kotatsu.download.domain.DownloadState
+import org.koitharu.kotatsu.utils.progress.ProgressJob
+
+class DownloadsAdapter(
+ scope: CoroutineScope,
+ coil: ImageLoader,
+) : AsyncListDifferDelegationAdapter>(DiffCallback()) {
+
+ init {
+ delegatesManager.addDelegate(downloadItemAD(scope, coil))
+ setHasStableIds(true)
+ }
+
+ override fun getItemId(position: Int): Long {
+ return items[position].progressValue.startId.toLong()
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback>() {
+
+ override fun areItemsTheSame(
+ oldItem: ProgressJob,
+ newItem: ProgressJob,
+ ): Boolean {
+ return oldItem.progressValue.startId == newItem.progressValue.startId
+ }
+
+ override fun areContentsTheSame(
+ oldItem: ProgressJob,
+ newItem: ProgressJob,
+ ): Boolean {
+ return oldItem.progressValue == newItem.progressValue
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
new file mode 100644
index 000000000..a8f0744bd
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
@@ -0,0 +1,171 @@
+package org.koitharu.kotatsu.download.ui.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.os.Build
+import android.text.format.DateUtils
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toBitmap
+import com.google.android.material.R as materialR
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.download.domain.DownloadState
+import org.koitharu.kotatsu.download.ui.DownloadsActivity
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.format
+import org.koitharu.kotatsu.utils.PendingIntentCompat
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class DownloadNotification(private val context: Context, startId: Int) {
+
+ private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ private val cancelAction = NotificationCompat.Action(
+ materialR.drawable.material_ic_clear_black_24dp,
+ context.getString(android.R.string.cancel),
+ PendingIntent.getBroadcast(
+ context,
+ startId,
+ DownloadService.getCancelIntent(startId),
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
+ )
+ )
+ private val listIntent = PendingIntent.getActivity(
+ context,
+ REQUEST_LIST,
+ DownloadsActivity.newIntent(context),
+ PendingIntentCompat.FLAG_IMMUTABLE,
+ )
+
+ init {
+ builder.setOnlyAlertOnce(true)
+ builder.setDefaults(0)
+ builder.color = ContextCompat.getColor(context, R.color.blue_primary)
+ builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
+ builder.setSilent(true)
+ }
+
+ fun create(state: DownloadState, timeLeft: Long): Notification {
+ builder.setContentTitle(state.manga.title)
+ builder.setContentText(context.getString(R.string.manga_downloading_))
+ builder.setProgress(1, 0, true)
+ builder.setSmallIcon(android.R.drawable.stat_sys_download)
+ builder.setContentIntent(listIntent)
+ builder.setStyle(null)
+ builder.setLargeIcon(state.cover?.toBitmap())
+ builder.clearActions()
+ builder.setVisibility(
+ if (state.manga.isNsfw) {
+ NotificationCompat.VISIBILITY_PRIVATE
+ } else {
+ NotificationCompat.VISIBILITY_PUBLIC
+ }
+ )
+ when (state) {
+ is DownloadState.Cancelled -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.cancelling_))
+ builder.setContentIntent(null)
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ }
+ is DownloadState.Done -> {
+ builder.setProgress(0, 0, false)
+ builder.setContentText(context.getString(R.string.download_complete))
+ builder.setContentIntent(createMangaIntent(context, state.localManga))
+ builder.setAutoCancel(true)
+ builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
+ builder.setCategory(null)
+ builder.setStyle(null)
+ builder.setOngoing(false)
+ }
+ is DownloadState.Error -> {
+ val message = state.error.getDisplayMessage(context.resources)
+ builder.setProgress(0, 0, false)
+ builder.setSmallIcon(android.R.drawable.stat_notify_error)
+ builder.setSubText(context.getString(R.string.error))
+ builder.setContentText(message)
+ builder.setAutoCancel(true)
+ builder.setOngoing(false)
+ builder.setCategory(NotificationCompat.CATEGORY_ERROR)
+ builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
+ }
+ is DownloadState.PostProcessing -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.processing_))
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ }
+ is DownloadState.Queued -> {
+ builder.setProgress(0, 0, false)
+ builder.setContentText(context.getString(R.string.queued))
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ builder.addAction(cancelAction)
+ }
+ is DownloadState.Preparing -> {
+ builder.setProgress(1, 0, true)
+ builder.setContentText(context.getString(R.string.preparing_))
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ builder.addAction(cancelAction)
+ }
+ is DownloadState.Progress -> {
+ builder.setProgress(state.max, state.progress, false)
+ if (timeLeft > 0L) {
+ val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
+ builder.setContentText(eta)
+ } else {
+ val percent = (state.percent * 100).format()
+ builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
+ }
+ builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ builder.addAction(cancelAction)
+ }
+ is DownloadState.WaitingForNetwork -> {
+ builder.setProgress(0, 0, false)
+ builder.setContentText(context.getString(R.string.waiting_for_network))
+ builder.setStyle(null)
+ builder.setOngoing(true)
+ builder.addAction(cancelAction)
+ }
+ }
+ return builder.build()
+ }
+
+ private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
+ context,
+ manga.hashCode(),
+ DetailsActivity.newIntent(context, manga),
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
+ )
+
+ companion object {
+
+ private const val CHANNEL_ID = "download"
+ private const val REQUEST_LIST = 6
+
+ fun createChannel(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = NotificationManagerCompat.from(context)
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.downloads),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ channel.enableVibration(false)
+ channel.enableLights(false)
+ channel.setSound(null, null)
+ manager.createNotificationChannel(channel)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
new file mode 100644
index 000000000..91c742e73
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
@@ -0,0 +1,248 @@
+package org.koitharu.kotatsu.download.ui.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Binder
+import android.os.IBinder
+import android.os.PowerManager
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import org.koin.android.ext.android.get
+import org.koin.core.context.GlobalContext
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseService
+import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.download.domain.DownloadManager
+import org.koitharu.kotatsu.download.domain.DownloadState
+import org.koitharu.kotatsu.download.domain.WakeLockNode
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.utils.ext.connectivityManager
+import org.koitharu.kotatsu.utils.ext.throttle
+import org.koitharu.kotatsu.utils.progress.ProgressJob
+import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
+import java.util.concurrent.TimeUnit
+
+class DownloadService : BaseService() {
+
+ private lateinit var downloadManager: DownloadManager
+ private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
+
+ private val jobs = LinkedHashMap>()
+ private val jobCount = MutableStateFlow(0)
+ private val controlReceiver = ControlReceiver()
+ private var binder: DownloadBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ isRunning = true
+ notificationSwitcher = ForegroundNotificationSwitcher(this)
+ val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
+ downloadManager = get().create(
+ coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
+ )
+ DownloadNotification.createChannel(this)
+ registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ super.onStartCommand(intent, flags, startId)
+ val manga = intent?.getParcelableExtra(EXTRA_MANGA)?.manga
+ val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
+ return if (manga != null) {
+ jobs[startId] = downloadManga(startId, manga, chapters)
+ jobCount.value = jobs.size
+ START_REDELIVER_INTENT
+ } else {
+ stopSelf(startId)
+ START_NOT_STICKY
+ }
+ }
+
+ override fun onBind(intent: Intent): IBinder {
+ super.onBind(intent)
+ return binder ?: DownloadBinder(this).also { binder = it }
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ binder = null
+ return super.onUnbind(intent)
+ }
+
+ override fun onDestroy() {
+ unregisterReceiver(controlReceiver)
+ binder = null
+ isRunning = false
+ super.onDestroy()
+ }
+
+ private fun downloadManga(
+ startId: Int,
+ manga: Manga,
+ chaptersIds: LongArray?,
+ ): ProgressJob {
+ val job = downloadManager.downloadManga(manga, chaptersIds, startId)
+ listenJob(job)
+ return job
+ }
+
+ private fun listenJob(job: ProgressJob) {
+ lifecycleScope.launch {
+ val startId = job.progressValue.startId
+ val notification = DownloadNotification(this@DownloadService, startId)
+ try {
+ val timeLeftEstimator = TimeLeftEstimator()
+ notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
+ job.progressAsFlow()
+ .onEach { state ->
+ if (state is DownloadState.Progress) {
+ timeLeftEstimator.tick(value = state.progress, total = state.max)
+ } else {
+ timeLeftEstimator.emptyTick()
+ }
+ }
+ .throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
+ .whileActive()
+ .collect { state ->
+ val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
+ notificationSwitcher.notify(startId, notification.create(state, timeLeft))
+ }
+ job.join()
+ } finally {
+ (job.progressValue as? DownloadState.Done)?.let {
+ sendBroadcast(
+ Intent(ACTION_DOWNLOAD_COMPLETE)
+ .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
+ )
+ }
+ notificationSwitcher.detach(
+ startId,
+ if (job.isCancelled) {
+ null
+ } else {
+ notification.create(job.progressValue, -1L)
+ }
+ )
+ stopSelf(startId)
+ }
+ }
+ }
+
+ private fun Flow.whileActive(): Flow = transformWhile { state ->
+ emit(state)
+ !state.isTerminal
+ }
+
+ private val DownloadState.isTerminal: Boolean
+ get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
+
+ inner class ControlReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ when (intent?.action) {
+ ACTION_DOWNLOAD_CANCEL -> {
+ val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
+ jobs.remove(cancelId)?.cancel()
+ jobCount.value = jobs.size
+ }
+ }
+ }
+ }
+
+ class DownloadBinder(private val service: DownloadService) : Binder() {
+
+ val downloads: Flow>>
+ get() = service.jobCount.mapLatest { service.jobs.values }
+ }
+
+ companion object {
+
+ var isRunning: Boolean = false
+ private set
+
+ const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
+
+ private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
+
+ private const val EXTRA_MANGA = "manga"
+ private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
+ private const val EXTRA_CANCEL_ID = "cancel_id"
+
+ fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) {
+ if (chaptersIds?.isEmpty() == true) {
+ return
+ }
+ confirmDataTransfer(context) {
+ val intent = Intent(context, DownloadService::class.java)
+ intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
+ if (chaptersIds != null) {
+ intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
+ }
+ ContextCompat.startForegroundService(context, intent)
+ Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ fun start(context: Context, manga: Collection) {
+ if (manga.isEmpty()) {
+ return
+ }
+ confirmDataTransfer(context) {
+ for (item in manga) {
+ val intent = Intent(context, DownloadService::class.java)
+ intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
+ ContextCompat.startForegroundService(context, intent)
+ }
+ }
+ }
+
+ fun confirmAndStart(context: Context, items: Set) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.save_manga)
+ .setMessage(R.string.batch_manga_save_confirm)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.save) { _, _ ->
+ start(context, items)
+ }.show()
+ }
+
+ fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
+ .putExtra(EXTRA_CANCEL_ID, startId)
+
+ fun getDownloadedManga(intent: Intent?): Manga? {
+ if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
+ return intent.getParcelableExtra(EXTRA_MANGA)?.manga
+ }
+ return null
+ }
+
+ private fun confirmDataTransfer(context: Context, callback: () -> Unit) {
+ val settings = GlobalContext.get().get()
+ if (context.connectivityManager.isActiveNetworkMetered && settings.isTrafficWarningEnabled) {
+ CheckBoxAlertDialog.Builder(context)
+ .setTitle(R.string.warning)
+ .setMessage(R.string.network_consumption_warning)
+ .setCheckBoxText(R.string.dont_ask_again)
+ .setCheckBoxChecked(false)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string._continue) { _, doNotAsk ->
+ settings.isTrafficWarningEnabled = !doNotAsk
+ callback()
+ }.create()
+ .show()
+ } else {
+ callback()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
new file mode 100644
index 000000000..679405295
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
@@ -0,0 +1,62 @@
+package org.koitharu.kotatsu.download.ui.service
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.SparseArray
+import androidx.core.app.ServiceCompat
+import androidx.core.util.isEmpty
+import androidx.core.util.size
+
+private const val DEFAULT_DELAY = 500L
+
+class ForegroundNotificationSwitcher(
+ private val service: Service,
+) {
+
+ private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ private val notifications = SparseArray()
+ private val handler = Handler(Looper.getMainLooper())
+
+ @Synchronized
+ fun notify(startId: Int, notification: Notification) {
+ if (notifications.isEmpty()) {
+ service.startForeground(startId, notification)
+ } else {
+ notificationManager.notify(startId, notification)
+ }
+ notifications[startId] = notification
+ }
+
+ @Synchronized
+ fun detach(startId: Int, notification: Notification?) {
+ notifications.remove(startId)
+ if (notifications.isEmpty()) {
+ ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH)
+ }
+ val nextIndex = notifications.size - 1
+ if (nextIndex >= 0) {
+ val nextStartId = notifications.keyAt(nextIndex)
+ val nextNotification = notifications.valueAt(nextIndex)
+ service.startForeground(nextStartId, nextNotification)
+ }
+ handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
+ }
+
+ private inner class NotifyRunnable(
+ private val startId: Int,
+ private val notification: Notification?,
+ ) : Runnable {
+
+ override fun run() {
+ if (notification != null) {
+ notificationManager.notify(startId, notification)
+ } else {
+ notificationManager.cancel(startId)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
new file mode 100644
index 000000000..f3bc159a8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
@@ -0,0 +1,24 @@
+package org.koitharu.kotatsu.favourites
+
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
+import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditViewModel
+import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
+import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
+
+val favouritesModule
+ get() = module {
+
+ single { FavouritesRepository(get(), get()) }
+
+ viewModel { categoryId ->
+ FavouritesListViewModel(categoryId.get(), get(), get(), get(), get())
+ }
+ viewModel { FavouritesCategoriesViewModel(get(), get()) }
+ viewModel { manga ->
+ MangaCategoriesViewModel(manga.get(), get())
+ }
+ viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
similarity index 59%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
index af468c3e8..c6a65c78e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.favourites.data
+import java.util.*
import org.koitharu.kotatsu.core.db.entity.SortOrder
-import org.koitharu.kotatsu.core.db.entity.toManga
-import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
-import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
@@ -14,9 +12,4 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
isTrackingEnabled = track,
- isVisibleInLibrary = isVisibleInLibrary,
-)
-
-fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
-
-fun Collection.toMangaList() = map { it.toManga() }
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
similarity index 57%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
index d387a46ff..148dfd820 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
@@ -6,25 +6,32 @@ import kotlinx.coroutines.flow.Flow
@Dao
abstract class FavouriteCategoriesDao {
- @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
+ @Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract suspend fun find(id: Int): FavouriteCategoryEntity
- @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
+ @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(): List
- @Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
+ @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract fun observeAll(): Flow>
- @Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
+ @Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract fun observe(id: Long): Flow
@Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
- suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
+ @Update
+ abstract suspend fun update(category: FavouriteCategoryEntity): Int
- @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id")
- abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean)
+ @Query("DELETE FROM favourite_categories WHERE category_id = :id")
+ abstract suspend fun delete(id: Long)
+
+ @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
+ abstract suspend fun updateTitle(id: Long, title: String)
+
+ @Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
+ abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String)
@@ -32,25 +39,20 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
- @Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
- abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean)
-
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
- @Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
- abstract suspend fun gc(maxDeletionTime: Long)
-
- @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
+ @Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1
}
- @Upsert
- abstract suspend fun upsert(entity: FavouriteCategoryEntity)
-
- @Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
- protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)
-}
+ @Transaction
+ open suspend fun upsert(entity: FavouriteCategoryEntity) {
+ if (update(entity) == 0) {
+ insert(entity)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
new file mode 100644
index 000000000..5fe02f019
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
@@ -0,0 +1,16 @@
+package org.koitharu.kotatsu.favourites.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "favourite_categories")
+class FavouriteCategoryEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "category_id") val categoryId: Int,
+ @ColumnInfo(name = "created_at") val createdAt: Long,
+ @ColumnInfo(name = "sort_key") val sortKey: Int,
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "order") val order: String,
+ @ColumnInfo(name = "track") val track: Boolean,
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
similarity index 66%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
index 87f8afa55..d79660a12 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
@@ -3,13 +3,10 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
-import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
- tableName = TABLE_FAVOURITES,
- primaryKeys = ["manga_id", "category_id"],
- foreignKeys = [
+ tableName = "favourites", primaryKeys = ["manga_id", "category_id"], foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
@@ -24,10 +21,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
)
]
)
-data class FavouriteEntity(
+class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
- @ColumnInfo(name = "sort_key") val sortKey: Int,
- @ColumnInfo(name = "created_at") val createdAt: Long,
- @ColumnInfo(name = "deleted_at") val deletedAt: Long,
-)
+ @ColumnInfo(name = "created_at") val createdAt: Long
+)
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
similarity index 100%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
new file mode 100644
index 000000000..89fcc92fb
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
@@ -0,0 +1,93 @@
+package org.koitharu.kotatsu.favourites.data
+
+import androidx.room.*
+import androidx.sqlite.db.SimpleSQLiteQuery
+import androidx.sqlite.db.SupportSQLiteQuery
+import kotlinx.coroutines.flow.Flow
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.parsers.model.SortOrder
+
+@Dao
+abstract class FavouritesDao {
+
+ @Transaction
+ @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
+ abstract suspend fun findAll(): List
+
+ fun observeAll(order: SortOrder): Flow> {
+ val orderBy = getOrderBy(order)
+ val query = SimpleSQLiteQuery(
+ "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy",
+ )
+ return observeAllRaw(query)
+ }
+
+ @Transaction
+ @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
+ abstract suspend fun findAll(offset: Int, limit: Int): List
+
+ @Transaction
+ @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
+ abstract suspend fun findAll(categoryId: Long): List
+
+ fun observeAll(categoryId: Long, order: SortOrder): Flow> {
+ val orderBy = getOrderBy(order)
+ val query = SimpleSQLiteQuery(
+ "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy",
+ arrayOf(categoryId),
+ )
+ return observeAllRaw(query)
+ }
+
+ @Transaction
+ @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
+ abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List
+
+ @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)")
+ abstract suspend fun findAllManga(categoryId: Int): List
+
+ @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
+ abstract suspend fun findAllManga(): List
+
+ @Transaction
+ @Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
+ abstract suspend fun find(id: Long): FavouriteManga?
+
+ @Transaction
+ @Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
+ abstract fun observe(id: Long): Flow
+
+ @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id")
+ abstract fun observeIds(id: Long): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ abstract suspend fun insert(favourite: FavouriteEntity)
+
+ @Update
+ abstract suspend fun update(favourite: FavouriteEntity): Int
+
+ @Query("DELETE FROM favourites WHERE manga_id = :mangaId")
+ abstract suspend fun delete(mangaId: Long)
+
+ @Query("DELETE FROM favourites WHERE manga_id = :mangaId AND category_id = :categoryId")
+ abstract suspend fun delete(categoryId: Long, mangaId: Long)
+
+ @Transaction
+ open suspend fun upsert(entity: FavouriteEntity) {
+ if (update(entity) == 0) {
+ insert(entity)
+ }
+ }
+
+ @Transaction
+ @RawQuery(observedEntities = [FavouriteEntity::class])
+ protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow>
+
+ private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) {
+ SortOrder.RATING -> "rating DESC"
+ SortOrder.NEWEST,
+ SortOrder.UPDATED -> "created_at DESC"
+ SortOrder.ALPHABETICAL -> "title ASC"
+ else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
similarity index 50%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index e99653964..13a4c92c4 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -1,58 +1,41 @@
package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction
-import dagger.Reusable
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.SortOrder
-import org.koitharu.kotatsu.core.db.entity.toEntities
-import org.koitharu.kotatsu.core.db.entity.toEntity
+import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.FavouriteCategory
-import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
-import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
-import org.koitharu.kotatsu.favourites.data.toManga
-import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
-import javax.inject.Inject
+import org.koitharu.kotatsu.utils.ext.mapItems
-@Reusable
-class FavouritesRepository @Inject constructor(
+class FavouritesRepository(
private val db: MangaDatabase,
private val channels: TrackerNotificationChannels,
) {
suspend fun getAllManga(): List {
val entities = db.favouritesDao.findAll()
- return entities.toMangaList()
- }
-
- suspend fun getLastManga(limit: Int): List {
- val entities = db.favouritesDao.findLast(limit)
- return entities.toMangaList()
+ return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(order: SortOrder): Flow> {
return db.favouritesDao.observeAll(order)
- .mapItems { it.toManga() }
+ .mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getManga(categoryId: Long): List {
val entities = db.favouritesDao.findAll(categoryId)
- return entities.toMangaList()
+ return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long, order: SortOrder): Flow> {
return db.favouritesDao.observeAll(categoryId, order)
- .mapItems { it.toManga() }
+ .mapItems { it.manga.toManga(it.tags.toMangaTags()) }
}
fun observeAll(categoryId: Long): Flow> {
@@ -66,28 +49,17 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged()
}
- fun observeCategoriesWithCovers(): Flow>> {
- return db.favouriteCategoriesDao.observeAll()
- .map {
- db.withTransaction {
- val res = LinkedHashMap>()
- for (entity in it) {
- val cat = entity.toFavouriteCategory()
- res[cat] = db.favouritesDao.findCovers(
- categoryId = cat.id,
- order = cat.order,
- )
- }
- res
- }
- }
- }
-
fun observeCategory(id: Long): Flow {
return db.favouriteCategoriesDao.observe(id)
.map { it?.toFavouriteCategory() }
}
+ fun observeCategories(mangaId: Long): Flow> {
+ return db.favouritesDao.observe(mangaId).map { entity ->
+ entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
+ }.distinctUntilChanged()
+ }
+
fun observeCategoriesIds(mangaId: Long): Flow> {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
}
@@ -96,12 +68,7 @@ class FavouritesRepository @Inject constructor(
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
}
- suspend fun createCategory(
- title: String,
- sortOrder: SortOrder,
- isTrackerEnabled: Boolean,
- isVisibleOnShelf: Boolean,
- ): FavouriteCategory {
+ suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
@@ -109,8 +76,6 @@ class FavouritesRepository @Inject constructor(
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
- deletedAt = 0L,
- isVisibleInLibrary = isVisibleOnShelf,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
@@ -118,49 +83,43 @@ class FavouritesRepository @Inject constructor(
return category
}
- suspend fun updateCategory(
- id: Long,
- title: String,
- sortOrder: SortOrder,
- isTrackerEnabled: Boolean,
- isVisibleOnShelf: Boolean,
- ) {
- db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
+ suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) {
+ db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled)
}
- suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
- db.favouriteCategoriesDao.updateLibVisibility(id, isVisibleInLibrary)
+ suspend fun addCategory(title: String): FavouriteCategory {
+ val entity = FavouriteCategoryEntity(
+ title = title,
+ createdAt = System.currentTimeMillis(),
+ sortKey = db.favouriteCategoriesDao.getNextSortKey(),
+ categoryId = 0,
+ order = SortOrder.NEWEST.name,
+ track = true,
+ )
+ val id = db.favouriteCategoriesDao.insert(entity)
+ val category = entity.toFavouriteCategory(id)
+ channels.createChannel(category)
+ return category
}
- suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) {
- db.favouriteCategoriesDao.updateTracking(id, isTrackingEnabled)
+ suspend fun renameCategory(id: Long, title: String) {
+ db.favouriteCategoriesDao.updateTitle(id, title)
+ channels.renameChannel(id, title)
}
suspend fun removeCategory(id: Long) {
- db.withTransaction {
- db.favouriteCategoriesDao.delete(id)
- db.favouritesDao.deleteAll(id)
- }
+ db.favouriteCategoriesDao.delete(id)
channels.deleteChannel(id)
}
- suspend fun removeCategories(ids: Collection) {
- db.withTransaction {
- for (id in ids) {
- db.favouritesDao.deleteAll(id)
- db.favouriteCategoriesDao.delete(id)
- }
- }
- // run after transaction success
- for (id in ids) {
- channels.deleteChannel(id)
- }
- }
-
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name)
}
+ suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) {
+ db.favouriteCategoriesDao.updateTracking(id, isEnabled)
+ }
+
suspend fun reorderCategories(orderedIds: List) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
@@ -176,34 +135,26 @@ class FavouritesRepository @Inject constructor(
val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
- val entity = FavouriteEntity(
- mangaId = manga.id,
- categoryId = categoryId,
- createdAt = System.currentTimeMillis(),
- sortKey = 0,
- deletedAt = 0L,
- )
+ val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
db.favouritesDao.insert(entity)
}
}
}
- suspend fun removeFromFavourites(ids: Collection