Compare commits
1 Commits
v8.1.2
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df55d1fe9 |
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
ko_fi: xtimms
|
||||
custom: ["https://yoomoney.ru/to/410012543938752"]
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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 a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||
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
|
||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
- 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: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||
required: true
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
BIN
.github/assets/vtuber.png
vendored
BIN
.github/assets/vtuber.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,13 +10,11 @@
|
||||
/.idea/compiler.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/ktlint-plugin.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/kotlinScripting.xml
|
||||
/.idea/kotlinc.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetSelector.xml
|
||||
/.idea/render.experimental.xml
|
||||
/.idea/inspectionProfiles/
|
||||
.DS_Store
|
||||
@@ -24,6 +22,3 @@
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
1
.idea/.gitignore
generated
vendored
1
.idea/.gitignore
generated
vendored
@@ -2,4 +2,3 @@
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
/runConfigurations.xml
|
||||
|
||||
6
.idea/AndroidProjectSystem.xml
generated
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
5
.idea/gradle.xml
generated
5
.idea/gradle.xml
generated
@@ -4,15 +4,16 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
## Kotatsu contribution guidelines
|
||||
|
||||
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
|
||||
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
|
||||
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
||||
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
||||
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
|
||||
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
|
||||
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
|
||||
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
|
||||
|
||||
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
|
||||
|
||||
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||
+ Please, **do not modify readme and other information files** (except for typos).
|
||||
+ **Avoid adding new dependencies** unless required. APK size is important.
|
||||
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
|
||||
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
|
||||
- Please, do not modify readme and other information files (except for typos).
|
||||
- Avoid adding new dependencies unless required. APK size is important.
|
||||
|
||||
53
LICENSE
53
LICENSE
@@ -619,3 +619,56 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
112
README.md
112
README.md
@@ -1,107 +1,57 @@
|
||||
<div align="center">
|
||||
# Kotatsu
|
||||
|
||||
<a href="https://kotatsu.app">
|
||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
||||
</a>
|
||||
Kotatsu is a free and open source manga reader for Android.
|
||||
|
||||
# [Kotatsu](https://kotatsu.app)
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||
|
||||
### Download
|
||||
|
||||
<div align="left">
|
||||
|
||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
||||
|
||||
</div>
|
||||
- **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.
|
||||
|
||||
### Main Features
|
||||
|
||||
<div align="left">
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||
* 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
|
||||
* 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
|
||||
|
||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
||||
* Search manga by name, genres, and more filters
|
||||
* Favorites organized by user-defined categories
|
||||
* Reading history, bookmarks, and incognito mode support
|
||||
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
||||
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password / fingerprint-protected access to the app
|
||||
* Automatically sync app data with other devices on the same account
|
||||
* Support for older devices running Android 5+
|
||||
### Screenshots
|
||||
|
||||
</div>
|
||||
|  |  |  |
|
||||
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||
|  |  |  |
|
||||
|
||||
### In-App Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
|
||||
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
|
||||
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
|
||||
</div>
|
||||
|  |  |
|
||||
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||
|
||||
### Localization
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
||||
</a>
|
||||
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
||||
**📌 If you would like to help improve these or add new languages,
|
||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
||||
|
||||
### Contributing
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/KotatsuApp/Kotatsu">
|
||||
<picture>
|
||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
|
||||
</picture>
|
||||
</a>
|
||||
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
|
||||
<picture>
|
||||
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
|
||||
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
|
||||
</picture>
|
||||
</a><br></br>
|
||||
|
||||
</br>
|
||||
|
||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
||||
|
||||
### License
|
||||
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
<div align="left">
|
||||
|
||||
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.
|
||||
|
||||
</div>
|
||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||
install instructions.
|
||||
|
||||
### DMCA disclaimer
|
||||
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||
|
||||
</div>
|
||||
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.
|
||||
|
||||
186
app/build.gradle
186
app/build.gradle
@@ -1,5 +1,3 @@
|
||||
import java.time.LocalDateTime
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
@@ -7,24 +5,23 @@ plugins {
|
||||
id 'com.google.devtools.ksp'
|
||||
id 'kotlin-parcelize'
|
||||
id 'dagger.hilt.android.plugin'
|
||||
id 'androidx.room'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
buildToolsVersion = '35.0.0'
|
||||
compileSdk = 34
|
||||
buildToolsVersion = '34.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1008
|
||||
versionName = '8.1.2'
|
||||
targetSdk = 34
|
||||
versionCode = 584
|
||||
versionName = '6.1.6'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
|
||||
ksp {
|
||||
arg('room.generateKotlin', 'true')
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig true
|
||||
@@ -35,54 +32,37 @@ android {
|
||||
applicationIdSuffix = '.debug'
|
||||
}
|
||||
release {
|
||||
multiDexEnabled false
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
nightly {
|
||||
initWith release
|
||||
applicationIdSuffix = '.nightly'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += [
|
||||
'META-INF/README.md',
|
||||
'META-INF/NOTICE.md'
|
||||
]
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
main.java.srcDirs += 'src/main/kotlin/'
|
||||
}
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
freeCompilerArgs += [
|
||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||
]
|
||||
}
|
||||
room {
|
||||
schemaDirectory "$projectDir/schemas"
|
||||
}
|
||||
lint {
|
||||
abortOnError true
|
||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources true
|
||||
@@ -91,15 +71,6 @@ android {
|
||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||
}
|
||||
}
|
||||
applicationVariants.configureEach { variant ->
|
||||
if (variant.name == 'nightly') {
|
||||
variant.outputs.each { output ->
|
||||
def now = LocalDateTime.now()
|
||||
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
||||
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
afterEvaluate {
|
||||
compileDebugKotlin {
|
||||
@@ -109,92 +80,81 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
def parsersVersion = libs.versions.parsers.get()
|
||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||
// usage:
|
||||
// -DparsersVersionOverride=$(curl -s https://api.github.com/repos/kotatsuapp/kotatsu-parsers/commits/master -H "Accept: application/vnd.github.sha" | cut -c -10)
|
||||
parsersVersion = System.getProperty('parsersVersionOverride')
|
||||
}
|
||||
//noinspection UseTomlInstead
|
||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsersVersion") {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:400a90464e') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
||||
implementation libs.kotlin.stdlib
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
implementation libs.kotlinx.coroutines.guava
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.core
|
||||
implementation libs.androidx.activity
|
||||
implementation libs.androidx.fragment
|
||||
implementation libs.androidx.transition
|
||||
implementation libs.androidx.collection
|
||||
implementation libs.lifecycle.viewmodel
|
||||
implementation libs.lifecycle.service
|
||||
implementation libs.lifecycle.process
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.swiperefreshlayout
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.androidx.viewpager2
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.biometric
|
||||
implementation libs.material
|
||||
implementation libs.androidx.lifecycle.common.java8
|
||||
implementation libs.androidx.webkit
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.8.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.1'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
|
||||
|
||||
implementation libs.androidx.work.runtime
|
||||
implementation libs.guava
|
||||
// TODO https://issuetracker.google.com/issues/254846063
|
||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||
//noinspection GradleDependency
|
||||
implementation('com.google.guava:guava:32.0.1-android') {
|
||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||
exclude group: 'org.checkerframework', module: 'checker-qual'
|
||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||
}
|
||||
|
||||
implementation libs.androidx.room.runtime
|
||||
implementation libs.androidx.room.ktx
|
||||
ksp libs.androidx.room.compiler
|
||||
implementation 'androidx.room:room-runtime:2.5.2'
|
||||
implementation 'androidx.room:room-ktx:2.5.2'
|
||||
ksp 'androidx.room:room-compiler:2.5.2'
|
||||
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttp.tls
|
||||
implementation libs.okhttp.dnsoverhttps
|
||||
implementation libs.okio
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
|
||||
implementation 'com.squareup.okio:okio:3.6.0'
|
||||
|
||||
implementation libs.adapterdelegates
|
||||
implementation libs.adapterdelegates.viewbinding
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation libs.hilt.android
|
||||
kapt libs.hilt.compiler
|
||||
implementation libs.androidx.hilt.work
|
||||
kapt libs.androidx.hilt.compiler
|
||||
implementation 'com.google.dagger:hilt-android:2.48.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.48.1'
|
||||
implementation 'androidx.hilt:hilt-work:1.0.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.0.0'
|
||||
|
||||
implementation libs.coil.core
|
||||
implementation libs.coil.network
|
||||
implementation libs.coil.gif
|
||||
implementation libs.coil.svg
|
||||
implementation libs.avif.decoder
|
||||
implementation libs.ssiv
|
||||
implementation libs.disk.lru.cache
|
||||
implementation libs.markwon
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.4.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:169806d928'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
|
||||
implementation libs.acra.http
|
||||
implementation libs.acra.dialog
|
||||
implementation 'ch.acra:acra-http:5.11.2'
|
||||
implementation 'ch.acra:acra-dialog:5.11.2'
|
||||
|
||||
implementation libs.conscrypt.android
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
|
||||
debugImplementation libs.leakcanary.android
|
||||
debugImplementation libs.workinspector
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20230618'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.json
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
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 libs.androidx.runner
|
||||
androidTestImplementation libs.androidx.rules
|
||||
androidTestImplementation libs.androidx.test.core
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
|
||||
androidTestImplementation libs.kotlinx.coroutines.test
|
||||
androidTestImplementation 'androidx.room:room-testing:2.5.2'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||
|
||||
androidTestImplementation libs.androidx.room.testing
|
||||
androidTestImplementation libs.moshi.kotlin
|
||||
|
||||
androidTestImplementation libs.hilt.android.testing
|
||||
kaptAndroidTest libs.hilt.android.compiler
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
|
||||
}
|
||||
|
||||
10
app/proguard-rules.pro
vendored
10
app/proguard-rules.pro
vendored
@@ -14,17 +14,7 @@
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
-keep class org.jsoup.internal.StringUtil
|
||||
|
||||
-keep class org.acra.security.NoKeyStoreFactory { *; }
|
||||
-keep class org.acra.config.DefaultRetryPolicy { *; }
|
||||
-keep class org.acra.attachment.DefaultAttachmentProvider { *; }
|
||||
-keep class org.acra.sender.JobSenderService
|
||||
|
||||
@@ -57,7 +57,6 @@ class AppShortcutManagerTest {
|
||||
page = 4,
|
||||
scroll = 2,
|
||||
percent = 0.3f,
|
||||
force = false,
|
||||
)
|
||||
awaitUpdate()
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ 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.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -63,7 +61,6 @@ class AppBackupAgentTest {
|
||||
page = 3,
|
||||
scroll = 40,
|
||||
percent = 0.2f,
|
||||
force = false,
|
||||
)
|
||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||
|
||||
@@ -85,7 +82,7 @@ class AppBackupAgentTest {
|
||||
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
||||
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
||||
|
||||
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
||||
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
|
||||
assertTrue(SampleData.tag in allTags)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package org.koitharu.kotatsu.tracker.domain
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koitharu.kotatsu.SampleData
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TrackerTest {
|
||||
|
||||
@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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noUpdates() = runTest {
|
||||
val manga = loadManga("full.json")
|
||||
tracker.deleteTrack(manga.id)
|
||||
|
||||
tracker.checkUpdates(manga, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||
tracker.checkUpdates(manga, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(manga.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasUpdates() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaFull = loadManga("full.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badIds() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaBad = loadManga("bad_ids.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun badIds2() = runTest {
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaBad = loadManga("bad_ids.json")
|
||||
val mangaFull = loadManga("full.json")
|
||||
tracker.deleteTrack(mangaFirst.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaBad, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullReset() = runTest {
|
||||
val mangaFull = loadManga("full.json")
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
val mangaEmpty = loadManga("empty.json")
|
||||
tracker.deleteTrack(mangaFull.id)
|
||||
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
|
||||
tracker.checkUpdates(mangaEmpty, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncWithHistory() = runTest {
|
||||
val mangaFull = loadManga("full.json")
|
||||
val mangaFirst = loadManga("first_chapters.json")
|
||||
tracker.deleteTrack(mangaFull.id)
|
||||
|
||||
tracker.checkUpdates(mangaFirst, commit = true).apply {
|
||||
assertFalse(isValid)
|
||||
assert(newChapters.isEmpty())
|
||||
}
|
||||
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
|
||||
tracker.checkUpdates(mangaFull, commit = true).apply {
|
||||
assertTrue(isValid)
|
||||
assertEquals(3, newChapters.size)
|
||||
}
|
||||
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
|
||||
|
||||
var 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))
|
||||
}
|
||||
|
||||
private suspend fun loadManga(name: String): Manga {
|
||||
val manga = SampleData.loadAsset("manga/$name", Manga::class)
|
||||
dataRepository.storeManga(manga)
|
||||
return manga
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.core.BaseApp
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
@@ -15,83 +11,35 @@ import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
|
||||
class KotatsuApp : BaseApp() {
|
||||
|
||||
var isLeakCanaryEnabled: Boolean
|
||||
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
|
||||
set(value) {
|
||||
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
|
||||
configureLeakCanary()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
enableStrictMode()
|
||||
configureLeakCanary()
|
||||
}
|
||||
|
||||
private fun configureLeakCanary() {
|
||||
LeakCanary.config = LeakCanary.config.copy(
|
||||
dumpHeap = isLeakCanaryEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
StrictModeNotifier(this)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder().apply {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build(),
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder().apply {
|
||||
detectActivityLeaks()
|
||||
detectLeakedSqlLiteObjects()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||
detectFileUriExposure()
|
||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
penaltyListener(notifier.executor, notifier)
|
||||
}
|
||||
}.build(),
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
||||
detectWrongFragmentContainer()
|
||||
detectFragmentTagUsage()
|
||||
detectRetainInstanceUsage()
|
||||
detectSetUserVisibleHint()
|
||||
detectWrongNestedHierarchy()
|
||||
detectFragmentReuse()
|
||||
penaltyLog()
|
||||
if (notifier != null) {
|
||||
penaltyListener(notifier)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val PREFS_DEBUG = "_debug"
|
||||
const val KEY_LEAK_CANARY = "leak_canary"
|
||||
|
||||
fun getDebugPreferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.detectFragmentTagUsage()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package org.koitharu.kotatsu
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Notification.BigTextStyle
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.strictmode.Violation
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import kotlin.math.absoluteValue
|
||||
import androidx.fragment.app.strictmode.Violation as FragmentViolation
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
class StrictModeNotifier(
|
||||
private val context: Context,
|
||||
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
|
||||
|
||||
val executor = Dispatchers.Default.asExecutor()
|
||||
|
||||
private val notificationManager by lazy {
|
||||
val nm = checkNotNull(context.getSystemService<NotificationManager>())
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.strict_mode),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
nm.createNotificationChannel(channel)
|
||||
nm
|
||||
}
|
||||
|
||||
override fun onVmViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onThreadViolation(v: Violation) = showNotification(v)
|
||||
|
||||
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
|
||||
|
||||
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_bug)
|
||||
.setContentTitle(context.getString(R.string.strict_mode))
|
||||
.setContentText(violation.message)
|
||||
.setStyle(
|
||||
BigTextStyle()
|
||||
.setBigContentTitle(context.getString(R.string.strict_mode))
|
||||
.setSummaryText(violation.message)
|
||||
.bigText(violation.stackTraceToString()),
|
||||
).setShowWhen(true)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
violation.hashCode(),
|
||||
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setGroup(CHANNEL_ID)
|
||||
.build()
|
||||
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
|
||||
|
||||
private companion object {
|
||||
|
||||
const val CHANNEL_ID = "strict_mode"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||
@@ -11,13 +10,8 @@ class CurlLoggingInterceptor(
|
||||
private val curlOptions: String? = null
|
||||
) : Interceptor {
|
||||
|
||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
||||
logRequest(it.networkResponse?.request ?: it.request)
|
||||
}
|
||||
|
||||
private fun logRequest(request: Request) {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
var isCompressed = false
|
||||
|
||||
val curlCmd = StringBuilder()
|
||||
@@ -46,15 +40,15 @@ class CurlLoggingInterceptor(
|
||||
if (isCompressed) {
|
||||
curlCmd.append(" --compressed")
|
||||
}
|
||||
curlCmd.append(" \"").append(request.url.toString().escape()).append('"')
|
||||
curlCmd.append(" \"").append(request.url).append('"')
|
||||
|
||||
log("---cURL (" + request.url + ")")
|
||||
log(curlCmd.toString())
|
||||
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun String.escape() = replace(escapeRegex) { match ->
|
||||
"\\" + match.value
|
||||
}
|
||||
private fun String.escape() = replace("\"", "\\\"")
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.d("CURL", msg)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("")
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder,
|
||||
): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import leakcanary.AppWatcher
|
||||
|
||||
abstract class BaseService : LifecycleService() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
AppWatcher.objectWatcher.watch(
|
||||
watchedObject = this,
|
||||
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.os.Looper
|
||||
|
||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||
|
||||
fun assertNotInMainThread() = check(Looper.myLooper() != Looper.getMainLooper()) {
|
||||
"Calling this from the main thread is prohibited"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
private val application: KotatsuApp
|
||||
get() = context.applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
context.startActivity(WorkInspector.getIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_leakcanary -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
application.isLeakCanaryEnabled = checked
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_ssiv_debug -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
SubsamplingScaleImageView.isDebug = checked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.98150784"
|
||||
android:scaleY="0.98150784"
|
||||
android:translateX="0.22190611"
|
||||
android:translateY="-0.2688478">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||
</group>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 417 B |
Binary file not shown.
|
Before Width: | Height: | Size: 308 B |
Binary file not shown.
|
Before Width: | Height: | Size: 480 B |
Binary file not shown.
|
Before Width: | Height: | Size: 792 B |
@@ -1,30 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_ssiv_debug"
|
||||
android:checkable="true"
|
||||
android:title="SSIV debug"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
<item
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:checkable="true"
|
||||
android:title="LeakCanary"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_leaks"
|
||||
android:id="@id/action_leaks"
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_works"
|
||||
android:title="@string/wi_lib_name"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
</menu>
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
|
||||
<bool name="wi_launcher_icon_enabled" tools:node="replace">false</bool>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||
<string name="strict_mode">Strict mode</string>
|
||||
</resources>
|
||||
</resources>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,79 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
|
||||
val sources = getSources(manga.source, throughDisabledSources)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
launch {
|
||||
val searchHelper = searchHelperFactory.create(source)
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||
}
|
||||
}.getOrNull()
|
||||
list?.forEach { m ->
|
||||
if (m.id != manga.id) {
|
||||
launch {
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(m.source).getDetails(m)
|
||||
}.getOrDefault(m)
|
||||
send(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
|
||||
sourcesRepository.getDisabledSources()
|
||||
} else {
|
||||
sourcesRepository.getEnabledSources()
|
||||
}.sortedByDescending { it.priority(ref) }
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||
if (locale == ref.locale) {
|
||||
res += 4
|
||||
} else if (locale.toLocale() == Locale.getDefault()) {
|
||||
res += 2
|
||||
}
|
||||
if (contentType == ref.contentType) {
|
||||
res++
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.lastOrNull
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.concat
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class AutoFixUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
|
||||
val seed = checkNotNull(
|
||||
mangaDataRepository.findMangaById(mangaId, withChapters = true),
|
||||
) { "Manga $mangaId not found" }.getDetailsSafe()
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed, throughDisabledSources = false)
|
||||
.concat(alternativesUseCase(seed, throughDisabledSources = true))
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
candidate
|
||||
} else {
|
||||
best
|
||||
}
|
||||
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
|
||||
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
|
||||
return seed to replacement
|
||||
}
|
||||
|
||||
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
|
||||
val repo = mangaRepositoryFactory.create(source)
|
||||
val details = if (this.chapters != null) this else repo.getDetails(this)
|
||||
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
|
||||
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
|
||||
pageUrl.toHttpUrlOrNull() != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(source).getDetails(this)
|
||||
}.getOrDefault(this)
|
||||
|
||||
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
|
||||
private suspend fun <T> Flow<T>.selectLastWithTimeout(
|
||||
minCount: Int,
|
||||
timeout: Long,
|
||||
timeUnit: TimeUnit
|
||||
): T? = channelFlow<T?> {
|
||||
var lastValue: T? = null
|
||||
launch {
|
||||
delay(timeUnit.toMillis(timeout))
|
||||
close(InternalTimeoutException(lastValue))
|
||||
}
|
||||
withIndex().transformWhile { (index, value) ->
|
||||
lastValue = value
|
||||
emit(value)
|
||||
index < minCount && !isClosedForSend
|
||||
}.collect {
|
||||
send(it)
|
||||
}
|
||||
}.catch { e ->
|
||||
if (e is InternalTimeoutException) {
|
||||
emit(e.value as T?)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}.lastOrNull()
|
||||
|
||||
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
|
||||
|
||||
private class InternalTimeoutException(val value: Any?) : CancellationException()
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrateUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e =
|
||||
f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
val newHistory =
|
||||
if (oldHistory != null) {
|
||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||
historyDao.delete(oldDetails.id)
|
||||
historyDao.upsert(newHistory)
|
||||
newHistory
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// track
|
||||
val tracksDao = database.getTracksDao()
|
||||
val oldTrack = tracksDao.find(oldDetails.id)
|
||||
if (oldTrack != null) {
|
||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||
val newTrack =
|
||||
TrackEntity(
|
||||
mangaId = newDetails.id,
|
||||
lastChapterId = lastChapter?.id ?: 0L,
|
||||
newChapters = 0,
|
||||
lastCheckTime = System.currentTimeMillis(),
|
||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||
lastError = null,
|
||||
)
|
||||
tracksDao.delete(oldDetails.id)
|
||||
tracksDao.upsert(newTrack)
|
||||
}
|
||||
// scrobbling
|
||||
for (scrobbler in scrobblers) {
|
||||
if (!scrobbler.isEnabled) {
|
||||
continue
|
||||
}
|
||||
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||
scrobbler.updateScrobblingInfo(
|
||||
mangaId = newDetails.id,
|
||||
rating = prevInfo.rating,
|
||||
status =
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
comment = prevInfo.comment,
|
||||
)
|
||||
if (newHistory != null) {
|
||||
scrobbler.scrobble(
|
||||
manga = newDetails,
|
||||
chapterId = newHistory.chapterId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter =
|
||||
if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index =
|
||||
if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch =
|
||||
if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
}
|
||||
val newChapterId =
|
||||
checkNotNull(newChapters[newBranch])
|
||||
.let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = history.updatedAt,
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = PROGRESS_NONE,
|
||||
deletedAt = 0,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(
|
||||
volume: Int,
|
||||
number: Float,
|
||||
): MangaChapter? =
|
||||
if (number <= 0f) {
|
||||
null
|
||||
} else {
|
||||
firstOrNull { it.volume == volume && it.number == number }
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.error
|
||||
import coil3.request.fallback
|
||||
import coil3.request.lifecycle
|
||||
import coil3.request.placeholder
|
||||
import coil3.request.transformations
|
||||
import coil3.transform.RoundedCornersTransformation
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import kotlin.math.sign
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun alternativeAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<MangaAlternativeModel>,
|
||||
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
||||
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
||||
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
||||
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
itemView.setOnClickListener(clickListener)
|
||||
binding.buttonMigrate.setOnClickListener(clickListener)
|
||||
binding.chipSource.setOnClickListener(clickListener)
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.mangaModel.title
|
||||
with(binding.iconsView) {
|
||||
clearIcons()
|
||||
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
|
||||
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
|
||||
isVisible = iconsCount > 0
|
||||
}
|
||||
binding.textViewSubtitle.text = buildSpannedString {
|
||||
if (item.chaptersCount > 0) {
|
||||
append(
|
||||
context.resources.getQuantityStringSafe(
|
||||
R.plurals.chapters,
|
||||
item.chaptersCount,
|
||||
item.chaptersCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
append(context.getString(R.string.no_chapters))
|
||||
}
|
||||
when (item.chaptersDiff.sign) {
|
||||
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
||||
append(" ▼ ")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
|
||||
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
||||
append(" ▲ +")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.setProgress(
|
||||
item.mangaModel.progress,
|
||||
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
||||
)
|
||||
binding.chipSource.also { chip ->
|
||||
chip.text = item.manga.source.getTitle(chip.context)
|
||||
ImageRequest.Builder(context)
|
||||
.data(item.manga.source.faviconUri())
|
||||
.lifecycle(lifecycleOwner)
|
||||
.crossfade(false)
|
||||
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(chip))
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.mangaSourceExtra(item.manga.source)
|
||||
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewCover))
|
||||
defaultPlaceholders(context)
|
||||
transformations(TrimTransformation())
|
||||
allowRgb565(true)
|
||||
mangaExtra(item.manga)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import coil3.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<MangaAlternativeModel> {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<AlternativesViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
subtitle = viewModel.manga.title
|
||||
}
|
||||
val listAdapter = BaseListAdapter<ListModel>()
|
||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||
adapter = listAdapter
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
viewModel.list.observe(this, listAdapter)
|
||||
viewModel.onMigrated.observeEvent(this) {
|
||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||
router.openDetails(it)
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
insets: WindowInsetsCompat
|
||||
): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
bottom = barsInsets.bottom,
|
||||
)
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
top = barsInsets.top,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> router.openDetails(item.manga)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = viewModel.retry()
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
override fun onFooterButtonClick() = viewModel.continueSearch()
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
buildAlertDialog(this, isCentered = true) {
|
||||
setIcon(R.drawable.ic_replace)
|
||||
setTitle(R.string.manga_migration)
|
||||
setMessage(
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.getTitle(context),
|
||||
target.title,
|
||||
target.source.getTitle(context),
|
||||
),
|
||||
)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.migrate) { _, _ ->
|
||||
viewModel.migrate(target)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.append
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AlternativesViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
||||
|
||||
private var includeDisabledSources = MutableStateFlow(false)
|
||||
private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
|
||||
|
||||
private var migrationJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
|
||||
private val mangaDetails = suspendLazy {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
|
||||
val list: StateFlow<List<ListModel>> = combine(
|
||||
results,
|
||||
isLoading,
|
||||
includeDisabledSources,
|
||||
) { list, loading, includeDisabled ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(
|
||||
when {
|
||||
loading -> LoadingState
|
||||
else -> EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
loading -> list + LoadingFooter()
|
||||
includeDisabled -> list
|
||||
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
init {
|
||||
doSearch(throughDisabledSources = false)
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
searchJob?.cancel()
|
||||
results.value = emptyList()
|
||||
includeDisabledSources.value = false
|
||||
doSearch(throughDisabledSources = false)
|
||||
}
|
||||
|
||||
fun continueSearch() {
|
||||
if (includeDisabledSources.value) {
|
||||
return
|
||||
}
|
||||
val prevJob = searchJob
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
includeDisabledSources.value = true
|
||||
prevJob?.join()
|
||||
doSearch(throughDisabledSources = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrate(target: Manga) {
|
||||
if (migrationJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||
migrateUseCase(manga, target)
|
||||
onMigrated.call(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doSearch(throughDisabledSources: Boolean) {
|
||||
val prevJob = searchJob
|
||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
val ref = mangaDetails.getOrDefault(manga)
|
||||
val refCount = ref.chaptersCount()
|
||||
alternativesUseCase.invoke(ref, throughDisabledSources)
|
||||
.collect {
|
||||
val model = MangaAlternativeModel(
|
||||
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
results.append(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import androidx.appcompat.R as appcompatR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
@Inject
|
||||
lateinit var autoFixUseCase: AutoFixUseCase
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
}
|
||||
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
|
||||
startForeground(this)
|
||||
for (mangaId in ids) {
|
||||
powerManager.withPartialWakeLock(TAG) {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
.setShowBadge(false)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setSmallIcon(R.drawable.ic_stat_auto_fix)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
|
||||
jobContext.setForeground(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
result.onSuccess { (seed, replacement) ->
|
||||
if (replacement != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
.data(replacement.coverUrl)
|
||||
.mangaSourceExtra(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = AppRouter.detailsIntent(applicationContext, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
replacement.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false,
|
||||
),
|
||||
).setVisibility(
|
||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
||||
)
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
R.string.manga_replaced,
|
||||
seed.title,
|
||||
seed.source.getTitle(applicationContext),
|
||||
replacement.title,
|
||||
replacement.source.getTitle(applicationContext),
|
||||
),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
} else {
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentText(
|
||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
},
|
||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||
notification.addAction(
|
||||
R.drawable.ic_alert_outline,
|
||||
applicationContext.getString(R.string.report),
|
||||
reportIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
return notification.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DATA_IDS = "ids"
|
||||
private const val TAG = "auto_fix"
|
||||
private const val CHANNEL_ID = "auto_fix"
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 38
|
||||
|
||||
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
|
||||
val intent = Intent(context, AutoFixService::class.java)
|
||||
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaAlternativeModel(
|
||||
val mangaModel: MangaGridModel,
|
||||
private val referenceChapters: Int,
|
||||
) : ListModel {
|
||||
|
||||
val manga: Manga
|
||||
get() = mangaModel.manga
|
||||
|
||||
val chaptersCount = manga.chaptersCount()
|
||||
|
||||
val chaptersDiff: Int
|
||||
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
|
||||
mangaModel.getChangePayload(previousState.mangaModel)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||
@Dao
|
||||
abstract class BookmarksDao {
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
||||
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
||||
|
||||
@@ -39,6 +42,9 @@ abstract class BookmarksDao {
|
||||
@Delete
|
||||
abstract suspend fun delete(entity: BookmarkEntity)
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
||||
abstract suspend fun delete(pageId: Long): Int
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||
manga = manga,
|
||||
@@ -11,7 +11,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = Instant.ofEpochMilli(createdAt),
|
||||
createdAt = Date(createdAt),
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ fun Bookmark.toEntity() = BookmarkEntity(
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = createdAt.toEpochMilli(),
|
||||
createdAt = createdAt.time,
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
||||
import org.koitharu.kotatsu.local.data.ImageFileFilter
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import java.time.Instant
|
||||
import java.util.Date
|
||||
|
||||
data class Bookmark(
|
||||
val manga: Manga,
|
||||
@@ -13,10 +13,13 @@ data class Bookmark(
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val imageUrl: String,
|
||||
val createdAt: Instant,
|
||||
val createdAt: Date,
|
||||
val percent: Float,
|
||||
) : ListModel {
|
||||
|
||||
val directImageUrl: String?
|
||||
get() = if (isImageUrlDirect()) imageUrl else null
|
||||
|
||||
val imageLoadData: Any
|
||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||
|
||||
@@ -35,6 +38,7 @@ data class Bookmark(
|
||||
)
|
||||
|
||||
private fun isImageUrlDirect(): Boolean {
|
||||
return hasImageExtension(imageUrl)
|
||||
val extension = imageUrl.substringAfterLast('.')
|
||||
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,15 +25,15 @@ class BookmarksRepository @Inject constructor(
|
||||
) {
|
||||
|
||||
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||
}
|
||||
|
||||
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||
}
|
||||
|
||||
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
|
||||
return db.getBookmarksDao().observe().map { map ->
|
||||
return db.bookmarksDao.observe().map { map ->
|
||||
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
|
||||
for ((k, v) in map) {
|
||||
val manga = k.toManga()
|
||||
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
|
||||
suspend fun addBookmark(bookmark: Bookmark) {
|
||||
db.withTransaction {
|
||||
val tags = bookmark.manga.tags.toEntities()
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
|
||||
db.getBookmarksDao().insert(bookmark.toEntity())
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
|
||||
db.bookmarksDao.insert(bookmark.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
|
||||
val entity = bookmark.toEntity().copy(
|
||||
imageUrl = imageUrl,
|
||||
)
|
||||
db.getBookmarksDao().upsert(listOf(entity))
|
||||
db.bookmarksDao.upsert(listOf(entity))
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
|
||||
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
|
||||
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
|
||||
"Bookmark not found"
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
|
||||
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
|
||||
val entities = ArrayList<BookmarkEntity>(ids.size)
|
||||
db.withTransaction {
|
||||
val dao = db.getBookmarksDao()
|
||||
val dao = db.bookmarksDao
|
||||
for (pageId in ids) {
|
||||
val e = dao.find(pageId)
|
||||
if (e != null) {
|
||||
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
|
||||
db.withTransaction {
|
||||
for (e in entities) {
|
||||
try {
|
||||
db.getBookmarksDao().insert(e)
|
||||
db.bookmarksDao.insert(e)
|
||||
} catch (e: SQLException) {
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
|
||||
|
||||
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
|
||||
@@ -1,22 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||
|
||||
@AndroidEntryPoint
|
||||
abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fragment>) :
|
||||
class BookmarksActivity :
|
||||
BaseActivity<ActivityContainerBinding>(),
|
||||
AppBarOwner,
|
||||
SnackbarOwner {
|
||||
@@ -30,25 +30,25 @@ abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fr
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||
setDisplayHomeAsUp(true, false)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val fm = supportFragmentManager
|
||||
if (fm.findFragmentById(R.id.container) == null) {
|
||||
fm.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(R.id.container, fragmentClass, getFragmentExtras())
|
||||
val fragment = BookmarksFragment.newInstance()
|
||||
replace(R.id.container, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = bars.left,
|
||||
right = bars.right,
|
||||
top = bars.top,
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
return insets.consumeSystemBarsInsets(top = true)
|
||||
}
|
||||
|
||||
protected open fun getFragmentExtras(): Bundle? = intent.extras
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,33 @@ package org.koitharu.kotatsu.bookmarks.ui
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil3.ImageLoader
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
@@ -40,14 +37,15 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AllBookmarksFragment :
|
||||
class BookmarksFragment :
|
||||
BaseFragment<FragmentListSimpleBinding>(),
|
||||
ListStateHolderListener,
|
||||
OnListItemClickListener<Bookmark>,
|
||||
ListSelectionController.Callback,
|
||||
ListSelectionController.Callback2,
|
||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||
|
||||
@Inject
|
||||
@@ -56,7 +54,7 @@ class AllBookmarksFragment :
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by viewModels<AllBookmarksViewModel>()
|
||||
private val viewModel by viewModels<BookmarksViewModel>()
|
||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
|
||||
@@ -73,7 +71,7 @@ class AllBookmarksFragment :
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
selectionController = ListSelectionController(
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
activity = requireActivity(),
|
||||
decoration = BookmarksSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
@@ -87,7 +85,7 @@ class AllBookmarksFragment :
|
||||
val spanSizeLookup = SpanSizeLookup()
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
val spanResolver = GridSpanResolver(resources)
|
||||
val spanResolver = MangaListSpanResolver(resources)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = bookmarksAdapter
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
@@ -102,23 +100,11 @@ class AllBookmarksFragment :
|
||||
}
|
||||
viewModel.onError.observeEvent(
|
||||
viewLifecycleOwner,
|
||||
SnackbarErrorObserver(binding.recyclerView, this),
|
||||
SnackbarErrorObserver(binding.recyclerView, this)
|
||||
)
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
val barsInsets = insets.systemBarsInsets
|
||||
val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
|
||||
viewBinding?.recyclerView?.setPadding(
|
||||
barsInsets.left + basePadding,
|
||||
barsInsets.top + basePadding,
|
||||
barsInsets.right + basePadding,
|
||||
barsInsets.bottom + basePadding,
|
||||
)
|
||||
return insets.consumeAllSystemBarsInsets()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
bookmarksAdapter = null
|
||||
@@ -127,26 +113,22 @@ class AllBookmarksFragment :
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
||||
val intent = ReaderIntent.Builder(view.context)
|
||||
val intent = ReaderActivity.IntentBuilder(view.context)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
router.openReader(intent)
|
||||
startActivity(intent)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListHeaderClick(item: ListHeader, view: View) {
|
||||
val manga = item.payload as? Manga ?: return
|
||||
router.openDetails(manga)
|
||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemLongClick(view, item.pageId) == true
|
||||
}
|
||||
|
||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
||||
return selectionController?.onItemContextClick(view, item.pageId) == true
|
||||
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
@@ -165,23 +147,23 @@ class AllBookmarksFragment :
|
||||
|
||||
override fun onCreateActionMode(
|
||||
controller: ListSelectionController,
|
||||
menuInflater: MenuInflater,
|
||||
mode: ActionMode,
|
||||
menu: Menu,
|
||||
): Boolean {
|
||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(
|
||||
controller: ListSelectionController,
|
||||
mode: ActionMode?,
|
||||
mode: ActionMode,
|
||||
item: MenuItem,
|
||||
): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
val ids = selectionController?.snapshot() ?: return false
|
||||
viewModel.removeBookmarks(ids)
|
||||
mode?.finish()
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -189,6 +171,16 @@ class AllBookmarksFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
val rv = requireViewBinding().recyclerView
|
||||
rv.updatePadding(
|
||||
bottom = insets.bottom + rv.paddingTop,
|
||||
)
|
||||
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin = insets.bottom
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
||||
|
||||
init {
|
||||
@@ -210,4 +202,15 @@ class AllBookmarksFragment :
|
||||
invalidateSpanIndexCache()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@Deprecated(
|
||||
"", ReplaceWith(
|
||||
"BookmarksFragment()",
|
||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
|
||||
)
|
||||
)
|
||||
fun newInstance() = BookmarksFragment()
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AllBookmarksViewModel @Inject constructor(
|
||||
class BookmarksViewModel @Inject constructor(
|
||||
private val repository: BookmarksRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
tag(item)
|
||||
decodeRegion(item.scroll)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,19 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class BookmarksAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
headerClickListener: ListHeaderClickListener?,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
) : BaseListAdapter<Bookmark>() {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
return findHeader(position)?.getText(context)
|
||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkListAD(coil, lifecycleOwner, clickListener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.allowRgb565
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
@@ -23,17 +22,23 @@ fun bookmarkLargeAD(
|
||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
|
||||
bind {
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewThumb))
|
||||
defaultPlaceholders(context)
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
bookmarkExtra(item)
|
||||
tag(item)
|
||||
decodeRegion(item.scroll)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
binding.progressView.setProgress(item.percent, false)
|
||||
binding.progressView.percent = item.percent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class BookmarksAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
headerClickListener: ListHeaderClickListener?,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
val list = items
|
||||
for (i in (0..position).reversed()) {
|
||||
val item = list.getOrNull(i) ?: continue
|
||||
if (item is ListHeader) {
|
||||
return item.getText(context)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.plus
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BookmarksSheet :
|
||||
BaseAdaptiveSheet<SheetPagesBinding>(),
|
||||
AdaptiveSheetCallback,
|
||||
OnListItemClickListener<Bookmark> {
|
||||
|
||||
private val viewModel by viewModels<BookmarksSheetViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||
private var spanResolver: MangaListSpanResolver? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
private val listCommitCallback = Runnable {
|
||||
spanSizeLookup.invalidateCache()
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
||||
return SheetPagesBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
addSheetCallback(this)
|
||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||
bookmarksAdapter = BookmarksAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this@BookmarksSheet,
|
||||
headerClickListener = null,
|
||||
)
|
||||
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = bookmarksAdapter
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
||||
}
|
||||
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
spanResolver = null
|
||||
bookmarksAdapter = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
||||
if (listener != null) {
|
||||
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
|
||||
} else {
|
||||
val intent = IntentBuilder(view.context)
|
||||
.manga(viewModel.manga)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onStateChanged(sheet: View, newState: Int) {
|
||||
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
|
||||
private fun onThumbnailsChanged(list: List<ListModel>) {
|
||||
val adapter = bookmarksAdapter ?: return
|
||||
if (adapter.itemCount == 0) {
|
||||
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
||||
if (position > 0) {
|
||||
val spanCount = spanResolver?.spanCount ?: 0
|
||||
val offset = if (position > spanCount + 1) {
|
||||
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
||||
} else {
|
||||
position = 0
|
||||
0
|
||||
}
|
||||
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
||||
adapter.setItems(list, listCommitCallback + scrollCallback)
|
||||
} else {
|
||||
adapter.setItems(list, listCommitCallback)
|
||||
}
|
||||
} else {
|
||||
adapter.setItems(list, listCommitCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
init {
|
||||
isSpanIndexCacheEnabled = true
|
||||
isSpanGroupIndexCacheEnabled = true
|
||||
}
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||
return when (bookmarksAdapter?.getItemViewType(position)) {
|
||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
||||
else -> total
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
invalidateSpanGroupIndexCache()
|
||||
invalidateSpanIndexCache()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_MANGA = "manga"
|
||||
|
||||
private const val TAG = "BookmarksSheet"
|
||||
|
||||
fun show(fm: FragmentManager, manga: Manga) {
|
||||
BookmarksSheet().withArgs(1) {
|
||||
putParcelable(ARG_MANGA, ParcelableManga(manga))
|
||||
}.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BookmarksSheetViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
bookmarksRepository: BookmarksRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
|
||||
private val chaptersLazy = SuspendLazy {
|
||||
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
|
||||
}
|
||||
|
||||
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
|
||||
.map { mapList(it) }
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
|
||||
|
||||
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
|
||||
val chapters = chaptersLazy.get()
|
||||
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
||||
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
||||
for (chapter in chapters) {
|
||||
val b = bookmarksMap[chapter.id]
|
||||
if (b.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
result += ListHeader(chapter.name)
|
||||
result.addAll(b)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.consumeAll
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
@Inject
|
||||
lateinit var proxyProvider: ProxyProvider
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
|
||||
val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
|
||||
val userAgent = intent?.getStringExtra(AppRouter.KEY_USER_AGENT)?.nullIfEmpty()
|
||||
?: repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
|
||||
onCreate2(savedInstanceState, mangaSource, repository)
|
||||
}
|
||||
|
||||
protected abstract fun onCreate2(
|
||||
savedInstanceState: Bundle?,
|
||||
source: MangaSource,
|
||||
repository: ParserMangaRepository?
|
||||
)
|
||||
|
||||
override fun onApplyWindowInsets(
|
||||
v: View,
|
||||
insets: WindowInsetsCompat
|
||||
): WindowInsetsCompat {
|
||||
val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
|
||||
val barsInsets = insets.getInsets(type)
|
||||
viewBinding.webView.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
bottom = barsInsets.bottom,
|
||||
)
|
||||
viewBinding.appbar.updatePadding(
|
||||
left = barsInsets.left,
|
||||
right = barsInsets.right,
|
||||
top = barsInsets.top,
|
||||
)
|
||||
return insets.consumeAll(type)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (hasViewBinding()) {
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,70 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import android.webkit.CookieManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BrowserActivity : BaseBrowserActivity() {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
|
||||
onBackPressedDispatcher.addCallback(onBackPressedCallback)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(
|
||||
intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
|
||||
url,
|
||||
)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -57,12 +81,66 @@ class BrowserActivity : BaseBrowserActivity() {
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
|
||||
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(viewBinding.webView.url)
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
viewBinding.webView.stopLoading()
|
||||
viewBinding.webView.destroy()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
this.title = title
|
||||
supportActionBar?.subtitle = subtitle
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_TITLE = "title"
|
||||
|
||||
fun newIntent(context: Context, url: String, title: String?): Intent {
|
||||
return Intent(context, BrowserActivity::class.java)
|
||||
.setData(Uri.parse(url))
|
||||
.putExtra(EXTRA_TITLE, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BrowserClient(
|
||||
private val callback: BrowserCallback
|
||||
) : WebViewClientCompat() {
|
||||
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
@@ -18,7 +16,7 @@ open class BrowserClient(
|
||||
callback.onLoadingStateChanged(isLoading = true)
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import coil3.EventListener
|
||||
import coil3.Extras
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil.EventListener
|
||||
import coil.request.ErrorResult
|
||||
import coil.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
|
||||
class CaptchaNotifier(
|
||||
private val context: Context,
|
||||
) : EventListener() {
|
||||
) : EventListener {
|
||||
|
||||
fun notify(exception: CloudFlareProtectedException) {
|
||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
|
||||
if (!context.checkNotificationPermission()) {
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(context.getString(R.string.captcha_required))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
@@ -43,17 +32,16 @@ class CaptchaNotifier(
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
|
||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
|
||||
.setData(exception.url.toUri())
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(channel.name)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setDefaults(0)
|
||||
.setSmallIcon(R.drawable.ic_bot)
|
||||
.setGroup(GROUP_CAPTCHA)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setAutoCancel(true)
|
||||
.setVisibility(
|
||||
if (exception.source?.isNsfw() == true) {
|
||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||
NotificationCompat.VISIBILITY_SECRET
|
||||
} else {
|
||||
NotificationCompat.VISIBILITY_PUBLIC
|
||||
@@ -62,50 +50,25 @@ class CaptchaNotifier(
|
||||
.setContentText(
|
||||
context.getString(
|
||||
R.string.captcha_required_summary,
|
||||
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
||||
exception.source?.title ?: context.getString(R.string.app_name),
|
||||
),
|
||||
)
|
||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val actionIntent = PendingIntentCompat.getActivity(
|
||||
context, SETTINGS_ACTION_CODE,
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
|
||||
0, false,
|
||||
)
|
||||
notification.addAction(
|
||||
R.drawable.ic_settings,
|
||||
context.getString(R.string.notifications_settings),
|
||||
actionIntent,
|
||||
)
|
||||
}
|
||||
manager.notify(TAG, exception.source.hashCode(), notification.build())
|
||||
}
|
||||
|
||||
fun dismiss(source: MangaSource) {
|
||||
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
|
||||
.build()
|
||||
manager.notify(TAG, exception.source.hashCode(), notification)
|
||||
}
|
||||
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
val e = result.throwable
|
||||
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
|
||||
if (e is CloudFlareProtectedException) {
|
||||
notify(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||
extras[ignoreCaptchaKey] = true
|
||||
}
|
||||
|
||||
val ignoreCaptchaKey = Extras.Key(false)
|
||||
private companion object {
|
||||
|
||||
private const val CHANNEL_ID = "captcha"
|
||||
private const val TAG = CHANNEL_ID
|
||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||
private const val SETTINGS_ACTION_CODE = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,60 +5,98 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.webkit.CookieManager
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.yield
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
||||
|
||||
private var pendingResult = RESULT_CANCELED
|
||||
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||
val url = intent?.dataString
|
||||
if (url.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability {
|
||||
setContentView(
|
||||
ActivityBrowserBinding.inflate(
|
||||
layoutInflater
|
||||
)
|
||||
)
|
||||
}) {
|
||||
return
|
||||
}
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
proxyProvider.applyWebViewConfig()
|
||||
} catch (e: Exception) {
|
||||
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
if (savedInstanceState != null) {
|
||||
return
|
||||
}
|
||||
if (url.isEmpty()) {
|
||||
finishAfterTransition()
|
||||
} else {
|
||||
onTitleChanged(getString(R.string.loading_), url)
|
||||
viewBinding.webView.loadUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
viewBinding.webView.run {
|
||||
stopLoading()
|
||||
destroy()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
@@ -66,6 +104,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
)
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
viewBinding.webView.stopLoading()
|
||||
@@ -74,73 +123,90 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
||||
}
|
||||
|
||||
R.id.action_retry -> {
|
||||
restartCheck()
|
||||
lifecycleScope.launch {
|
||||
viewBinding.webView.stopLoading()
|
||||
yield()
|
||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||
if (targetUrl != null) {
|
||||
clearCfCookies(targetUrl)
|
||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewBinding.webView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewBinding.webView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(pendingResult)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onPageLoaded() {
|
||||
viewBinding.progressBar.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onLoopDetected() {
|
||||
restartCheck()
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult = RESULT_OK
|
||||
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
||||
if (source != null) {
|
||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
||||
}
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
viewBinding.progressBar.isVisible = isLoading
|
||||
}
|
||||
|
||||
override fun onHistoryChanged() {
|
||||
onBackPressedCallback?.onHistoryChanged()
|
||||
}
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||
setTitle(title)
|
||||
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
|
||||
}
|
||||
|
||||
private fun restartCheck() {
|
||||
lifecycleScope.launch {
|
||||
viewBinding.webView.stopLoading()
|
||||
yield()
|
||||
cfClient.reset()
|
||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||
if (targetUrl != null) {
|
||||
clearCfCookies(targetUrl)
|
||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||
}
|
||||
}
|
||||
supportActionBar?.subtitle =
|
||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
}
|
||||
|
||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||
cookieJar.removeCookies(url) { cookie ->
|
||||
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
||||
val name = cookie.name
|
||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
|
||||
}
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||
return AppRouter.cloudFlareResolveIntent(context, input)
|
||||
class Contract : ActivityResultContract<Pair<String, Headers?>, TaggedActivityResult>() {
|
||||
override fun createIntent(context: Context, input: Pair<String, Headers?>): Intent {
|
||||
return newIntent(context, input.first, input.second)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == RESULT_OK
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||
return TaggedActivityResult(TAG, resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "CloudFlareActivity"
|
||||
private const val ARG_UA = "ua"
|
||||
|
||||
fun newIntent(
|
||||
context: Context,
|
||||
url: String,
|
||||
headers: Headers?,
|
||||
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||
data = url.toUri()
|
||||
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||
putExtra(ARG_UA, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import org.koitharu.kotatsu.browser.BrowserCallback
|
||||
|
||||
interface CloudFlareCallback : BrowserCallback {
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
||||
|
||||
fun onPageLoaded()
|
||||
|
||||
fun onCheckPassed()
|
||||
|
||||
fun onLoopDetected()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package org.koitharu.kotatsu.browser.cloudflare
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
|
||||
private const val LOOP_COUNTER = 3
|
||||
private const val CF_CLEARANCE = "cf_clearance"
|
||||
|
||||
class CloudFlareClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
@@ -15,14 +15,13 @@ class CloudFlareClient(
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
private var counter = 0
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
checkClearance()
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
||||
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||
super.onPageCommitVisible(view, url)
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
@@ -32,22 +31,15 @@ class CloudFlareClient(
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
counter = 0
|
||||
}
|
||||
|
||||
private fun checkClearance() {
|
||||
val clearance = getClearance()
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
callback.onCheckPassed()
|
||||
} else {
|
||||
counter++
|
||||
if (counter >= LOOP_COUNTER) {
|
||||
reset()
|
||||
callback.onLoopDetected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getClearance() = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
private fun getClearance(): String? {
|
||||
return cookieJar.loadForRequest(targetUrl.toHttpUrl())
|
||||
.find { it.name == CF_CLEARANCE }?.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,16 @@ package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.SearchRecentSuggestions
|
||||
import android.text.Html
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.WorkManager
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.disk.directory
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.svg.SvgDecoder
|
||||
import coil3.util.DebugLogger
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -32,37 +26,35 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||
import org.koitharu.kotatsu.core.cache.ContentCache
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.cache.StubContentCache
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
||||
import org.koitharu.kotatsu.core.image.CbzFetcher
|
||||
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
|
||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -77,35 +69,29 @@ interface AppModule {
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@LocalizedAppContext
|
||||
fun provideLocalizedContext(
|
||||
@ApplicationContext context: Context,
|
||||
): Context = ContextCompat.getContextForLanguage(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = NetworkState(context.connectivityManager, settings)
|
||||
@ApplicationContext context: Context
|
||||
) = NetworkState(context.connectivityManager)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMangaDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): MangaDatabase = MangaDatabase(context)
|
||||
): MangaDatabase {
|
||||
return MangaDatabase(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCoil(
|
||||
@LocalizedAppContext context: Context,
|
||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||
@ApplicationContext context: Context,
|
||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||
networkStateProvider: Provider<NetworkState>,
|
||||
): ImageLoader {
|
||||
val diskCacheFactory = {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
@@ -113,43 +99,34 @@ interface AppModule {
|
||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||
.build()
|
||||
}
|
||||
val okHttpClientLazy = lazy {
|
||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||
}
|
||||
return ImageLoader.Builder(context)
|
||||
.interceptorCoroutineContext(Dispatchers.Default)
|
||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.fetcherDispatcher(Dispatchers.IO)
|
||||
.decoderDispatcher(Dispatchers.Default)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.eventListener(CaptchaNotifier(context))
|
||||
.components {
|
||||
add(
|
||||
OkHttpNetworkFetcherFactory(
|
||||
callFactory = okHttpClientLazy::value,
|
||||
connectivityChecker = { networkStateProvider.get() },
|
||||
),
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(CbzFetcher.Factory())
|
||||
add(AvifImageDecoder.Factory())
|
||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
||||
add(MangaPageKeyer())
|
||||
add(pageFetcherFactory)
|
||||
add(imageProxyInterceptor)
|
||||
add(coverRestoreInterceptor)
|
||||
add(MangaSourceHeaderInterceptor())
|
||||
}.build()
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(SvgDecoder.Factory())
|
||||
.add(CbzFetcher.Factory())
|
||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||
.add(pageFetcherFactory)
|
||||
.add(imageProxyInterceptor)
|
||||
.add(coverRestoreInterceptor)
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideSearchSuggestions(
|
||||
@ApplicationContext context: Context,
|
||||
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
|
||||
): SearchRecentSuggestions {
|
||||
return MangaSuggestionsProvider.createSuggestions(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
@@ -170,15 +147,27 @@ interface AppModule {
|
||||
fun provideActivityLifecycleCallbacks(
|
||||
appProtectHelper: AppProtectHelper,
|
||||
activityRecreationHandle: ActivityRecreationHandle,
|
||||
incognitoModeIndicator: IncognitoModeIndicator,
|
||||
acraScreenLogger: AcraScreenLogger,
|
||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||
appProtectHelper,
|
||||
activityRecreationHandle,
|
||||
incognitoModeIndicator,
|
||||
acraScreenLogger,
|
||||
screenshotPolicyHelper,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContentCache(
|
||||
application: Application,
|
||||
): ContentCache {
|
||||
return if (application.isLowRamDevice()) {
|
||||
StubContentCache()
|
||||
} else {
|
||||
MemoryContentCache(application)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@LocalStorageChanges
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
@@ -11,7 +10,6 @@ import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
@@ -20,21 +18,14 @@ import org.acra.config.httpSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.conscrypt.Conscrypt
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.os.RomCompat
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
|
||||
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||
import java.security.Security
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@@ -42,13 +33,13 @@ import javax.inject.Provider
|
||||
open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var databaseObserversProvider: Provider<Set<@JvmSuppressWildcards InvalidationTracker.Observer>>
|
||||
lateinit var databaseObservers: Set<@JvmSuppressWildcards InvalidationTracker.Observer>
|
||||
|
||||
@Inject
|
||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||
|
||||
@Inject
|
||||
lateinit var database: Provider<MangaDatabase>
|
||||
lateinit var database: MangaDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
@@ -65,49 +56,31 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@Inject
|
||||
@LocalStorageChanges
|
||||
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
// TLS 1.3 support for Android < 10
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||
setupActivityLifecycleCallbacks()
|
||||
processLifecycleScope.launch {
|
||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
|
||||
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
|
||||
}
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
setupDatabaseObservers()
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
excludeMatchingSharedPreferencesKeys = listOf(
|
||||
"sources_\\w+",
|
||||
AppSettings.KEY_APP_PASSWORD,
|
||||
AppSettings.KEY_PROXY_LOGIN,
|
||||
AppSettings.KEY_PROXY_ADDRESS,
|
||||
AppSettings.KEY_PROXY_PASSWORD,
|
||||
)
|
||||
httpSender {
|
||||
uri = getString(R.string.url_error_report)
|
||||
basicAuthLogin = getString(R.string.acra_login)
|
||||
@@ -124,6 +97,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
ReportField.STACK_TRACE,
|
||||
ReportField.CRASH_CONFIGURATION,
|
||||
ReportField.CUSTOM_DATA,
|
||||
ReportField.SHARED_PREFERENCES,
|
||||
)
|
||||
|
||||
dialog {
|
||||
@@ -136,10 +110,16 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
}
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration(): Configuration {
|
||||
return Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun setupDatabaseObservers() {
|
||||
val tracker = database.get().invalidationTracker
|
||||
databaseObserversProvider.get().forEach {
|
||||
val tracker = database.invalidationTracker
|
||||
databaseObservers.forEach {
|
||||
tracker.addObserver(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.report
|
||||
|
||||
class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
|
||||
e.report()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||
|
||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||
intent.setAction(ACTION_REPORT)
|
||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
||||
intent.putExtra(AppRouter.KEY_ERROR, e)
|
||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||
} catch (e: BadParcelableException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.koitharu.kotatsu.core
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Target(
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY_GETTER,
|
||||
AnnotationTarget.PROPERTY_SETTER,
|
||||
AnnotationTarget.VALUE_PARAMETER,
|
||||
AnnotationTarget.FIELD,
|
||||
)
|
||||
annotation class LocalizedAppContext
|
||||
@@ -3,21 +3,17 @@ package org.koitharu.kotatsu.core.backup
|
||||
import org.json.JSONArray
|
||||
|
||||
class BackupEntry(
|
||||
val name: Name,
|
||||
val name: String,
|
||||
val data: JSONArray
|
||||
) {
|
||||
|
||||
enum class Name(
|
||||
val key: String,
|
||||
) {
|
||||
companion object Names {
|
||||
|
||||
INDEX("index"),
|
||||
HISTORY("history"),
|
||||
CATEGORIES("categories"),
|
||||
FAVOURITES("favourites"),
|
||||
SETTINGS("settings"),
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
const val INDEX = "index"
|
||||
const val HISTORY = "history"
|
||||
const val CATEGORIES = "categories"
|
||||
const val FAVOURITES = "favourites"
|
||||
const val SETTINGS = "settings"
|
||||
const val BOOKMARKS = "bookmarks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.Date
|
||||
|
||||
data class BackupFile(
|
||||
val uri: Uri,
|
||||
val dateTime: Date,
|
||||
): Comparable<BackupFile> {
|
||||
|
||||
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.JSONIterator
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGE_SIZE = 10
|
||||
@@ -21,14 +16,13 @@ private const val PAGE_SIZE = 10
|
||||
class BackupRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
) {
|
||||
|
||||
suspend fun dumpHistory(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
||||
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
|
||||
val history = db.historyDao.findAll(offset, PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -47,8 +41,8 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun dumpCategories(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
||||
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
|
||||
val categories = db.favouriteCategoriesDao.findAll()
|
||||
for (item in categories) {
|
||||
entry.data.put(JsonSerializer(item).toJson())
|
||||
}
|
||||
@@ -57,9 +51,9 @@ class BackupRepository @Inject constructor(
|
||||
|
||||
suspend fun dumpFavourites(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
||||
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE)
|
||||
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
@@ -78,8 +72,8 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun dumpBookmarks(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
||||
val all = db.getBookmarksDao().findAll()
|
||||
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
|
||||
val all = db.bookmarksDao.findAll()
|
||||
for ((m, b) in all) {
|
||||
val json = JSONObject()
|
||||
val manga = JsonSerializer(m.manga).toJson()
|
||||
@@ -96,7 +90,7 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
|
||||
fun dumpSettings(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
||||
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray())
|
||||
val settingsDump = settings.getAllValues().toMutableMap()
|
||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||
@@ -107,26 +101,8 @@ class BackupRepository @Inject constructor(
|
||||
return entry
|
||||
}
|
||||
|
||||
fun dumpReaderGridSettings(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
||||
val settingsDump = tapGridSettings.getAllValues()
|
||||
val json = JsonSerializer(settingsDump).toJson()
|
||||
entry.data.put(json)
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpSources(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
||||
val all = db.getSourcesDao().findAll()
|
||||
for (source in all) {
|
||||
val json = JsonSerializer(source).toJson()
|
||||
entry.data.put(json)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
fun createIndex(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
||||
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
|
||||
val json = JSONObject()
|
||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||
@@ -135,16 +111,9 @@ class BackupRepository @Inject constructor(
|
||||
return entry
|
||||
}
|
||||
|
||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
||||
return if (timestamp == 0L) null else Date(timestamp)
|
||||
}
|
||||
|
||||
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -153,32 +122,29 @@ class BackupRepository @Inject constructor(
|
||||
val history = JsonDeserializer(item).toHistoryEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.withTransaction {
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(manga, tags)
|
||||
db.getHistoryDao().upsert(history)
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.historyDao.upsert(history)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.getFavouriteCategoriesDao().upsert(category)
|
||||
db.favouriteCategoriesDao.upsert(category)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
||||
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
val list = entry.data.asTypedList<JSONObject>()
|
||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
||||
for ((index, item) in list.withIndex()) {
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
||||
@@ -187,19 +153,18 @@ class BackupRepository @Inject constructor(
|
||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.withTransaction {
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(manga, tags)
|
||||
db.getFavouritesDao().upsert(favourite)
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.favouritesDao.upsert(favourite)
|
||||
}
|
||||
}
|
||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
||||
val tags = item.getJSONArray("tags").mapJSON {
|
||||
@@ -210,43 +175,22 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
result += runCatchingCancellable {
|
||||
db.withTransaction {
|
||||
db.getTagsDao().upsert(tags)
|
||||
db.getMangaDao().upsert(manga, tags)
|
||||
db.getBookmarksDao().upsert(bookmarks)
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.bookmarksDao.upsert(bookmarks)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
||||
result += runCatchingCancellable {
|
||||
db.getSourcesDao().upsert(source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
for (item in entry.data.JSONIterator()) {
|
||||
result += runCatchingCancellable {
|
||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
result += runCatchingCancellable {
|
||||
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,24 @@ package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import java.util.zip.ZipException
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
||||
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name) ?: return@runInterruptible null
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
|
||||
fun closeAndDelete() {
|
||||
closeQuietly()
|
||||
file.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(file: File): BackupZipInput {
|
||||
var res: BackupZipInput? = null
|
||||
return try {
|
||||
res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
}
|
||||
res
|
||||
} catch (exception: Throwable) {
|
||||
res?.closeQuietly()
|
||||
throw if (exception is ZipException) {
|
||||
BadBackupFormatException(exception)
|
||||
} else {
|
||||
exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.format
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import java.io.File
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.Deflater
|
||||
@@ -19,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
||||
output.put(entry.name.key, entry.data.toString(2))
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
||||
@@ -29,32 +27,20 @@ class BackupZipOutput(val file: File) : Closeable {
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val DIR_BACKUPS = "backups"
|
||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||
|
||||
fun generateFileName(context: Context) = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(dateTimeFormat.format(Date()))
|
||||
append(".bk.zip")
|
||||
}
|
||||
|
||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||
} catch (e: ParseException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
BackupZipOutput(File(dir, generateFileName(context)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ class CompositeResult {
|
||||
}
|
||||
}
|
||||
|
||||
operator fun plusAssign(error: Throwable) {
|
||||
errors.add(error)
|
||||
}
|
||||
|
||||
operator fun plusAssign(other: CompositeResult) {
|
||||
this.successCount += other.successCount
|
||||
this.errors += other.errors
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.jetbrains.annotations.Blocking
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExternalBackupStorage @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun list(): List<BackupFile> = runInterruptible(Dispatchers.IO) {
|
||||
getRootOrThrow().listFiles().mapNotNull {
|
||||
if (it.isFile && it.canRead()) {
|
||||
BackupFile(
|
||||
uri = it.uri,
|
||||
dateTime = it.name?.let { fileName ->
|
||||
BackupZipOutput.parseBackupDateTime(fileName)
|
||||
} ?: return@mapNotNull null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedDescending()
|
||||
}
|
||||
|
||||
suspend fun listOrNull() = runCatchingCancellable {
|
||||
list()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
|
||||
"Cannot create target backup file"
|
||||
}
|
||||
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||
file.source().buffer().use { src ->
|
||||
src.readAll(sink)
|
||||
}
|
||||
}
|
||||
out.uri
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
|
||||
val df = DocumentFile.fromSingleUri(context, victim.uri)
|
||||
df != null && df.delete()
|
||||
}
|
||||
|
||||
suspend fun getLastBackupDate() = listOrNull()?.maxOfOrNull { it.dateTime }
|
||||
|
||||
suspend fun trim(maxCount: Int): Boolean {
|
||||
if (maxCount == Int.MAX_VALUE) {
|
||||
return false
|
||||
}
|
||||
val list = listOrNull()
|
||||
if (list == null || list.size <= maxCount) {
|
||||
return false
|
||||
}
|
||||
var result = false
|
||||
for (i in maxCount until list.size) {
|
||||
if (delete(list[i])) {
|
||||
result = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Blocking
|
||||
private fun getRootOrThrow(): DocumentFile {
|
||||
val uri = checkNotNull(settings.periodicalBackupDirectory) {
|
||||
"Backup directory is not specified"
|
||||
}
|
||||
val root = DocumentFile.fromTreeUri(context, uri)
|
||||
return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
@@ -12,7 +11,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
|
||||
class JsonDeserializer(private val json: JSONObject) {
|
||||
@@ -28,16 +26,15 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
fun toMangaEntity() = MangaEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
altTitles = json.getStringOrNull("alt_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),
|
||||
contentRating = json.getStringOrNull("content_rating"),
|
||||
coverUrl = json.getString("cover_url"),
|
||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||
state = json.getStringOrNull("state"),
|
||||
authors = json.getStringOrNull("author"),
|
||||
author = json.getStringOrNull("author"),
|
||||
source = json.getString("source"),
|
||||
)
|
||||
|
||||
@@ -56,7 +53,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
page = json.getInt("page"),
|
||||
scroll = json.getDouble("scroll").toFloat(),
|
||||
percent = json.getFloatOrDefault("percent", -1f),
|
||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
@@ -82,15 +78,6 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
percent = json.getDouble("percent").toFloat(),
|
||||
)
|
||||
|
||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
||||
source = json.getString("source"),
|
||||
isEnabled = json.getBoolean("enabled"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
addedIn = json.getIntOrDefault("added_in", 0),
|
||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
||||
)
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
val map = mutableMapOf<String, Any?>()
|
||||
val keys = json.keys()
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.backup
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
@@ -41,7 +40,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
put("page", e.page)
|
||||
put("scroll", e.scroll)
|
||||
put("percent", e.percent)
|
||||
put("chapters", e.chaptersCount)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -58,16 +56,15 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
JSONObject().apply {
|
||||
put("id", e.id)
|
||||
put("title", e.title)
|
||||
put("alt_title", e.altTitles)
|
||||
put("alt_title", e.altTitle)
|
||||
put("url", e.url)
|
||||
put("public_url", e.publicUrl)
|
||||
put("rating", e.rating)
|
||||
put("nsfw", e.isNsfw)
|
||||
put("content_rating", e.contentRating)
|
||||
put("cover_url", e.coverUrl)
|
||||
put("large_cover_url", e.largeCoverUrl)
|
||||
put("state", e.state)
|
||||
put("author", e.authors)
|
||||
put("author", e.author)
|
||||
put("source", e.source)
|
||||
},
|
||||
)
|
||||
@@ -85,17 +82,6 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
},
|
||||
)
|
||||
|
||||
constructor(e: MangaSourceEntity) : this(
|
||||
JSONObject().apply {
|
||||
put("source", e.source)
|
||||
put("enabled", e.isEnabled)
|
||||
put("sort_key", e.sortKey)
|
||||
put("added_in", e.addedIn)
|
||||
put("used_at", e.lastUsedAt)
|
||||
put("pinned", e.isPinned)
|
||||
},
|
||||
)
|
||||
|
||||
constructor(m: Map<String, *>) : this(
|
||||
JSONObject(m),
|
||||
)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.CheckResult
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.BaseHttpClient
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
||||
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class TelegramBackupUploader @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
@BaseHttpClient private val client: OkHttpClient,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||
|
||||
suspend fun uploadBackup(file: File) {
|
||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("chat_id", requireChatId())
|
||||
.addFormDataPart("document", file.name, requestBody)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(urlOf("sendDocument").build())
|
||||
.post(multipartBody)
|
||||
.build()
|
||||
client.newCall(request).await().consume()
|
||||
}
|
||||
|
||||
suspend fun sendTestMessage() {
|
||||
val request = Request.Builder()
|
||||
.url(urlOf("getMe").build())
|
||||
.build()
|
||||
client.newCall(request).await().consume()
|
||||
sendMessage(context.getString(R.string.backup_tg_echo))
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
fun openBotInApp(router: AppRouter): Boolean {
|
||||
val botUsername = context.getString(R.string.tg_backup_bot_name)
|
||||
return router.openExternalBrowser("tg://resolve?domain=$botUsername") ||
|
||||
router.openExternalBrowser("https://t.me/$botUsername")
|
||||
}
|
||||
|
||||
private suspend fun sendMessage(message: String) {
|
||||
val url = urlOf("sendMessage")
|
||||
.addQueryParameter("chat_id", requireChatId())
|
||||
.addQueryParameter("text", message)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
client.newCall(request).await().consume()
|
||||
}
|
||||
|
||||
private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) {
|
||||
"Telegram chat ID not set in settings"
|
||||
}
|
||||
|
||||
private fun Response.consume() {
|
||||
if (isSuccessful) {
|
||||
closeQuietly()
|
||||
return
|
||||
}
|
||||
val jo = parseJson()
|
||||
if (!jo.getBooleanOrDefault("ok", true)) {
|
||||
throw RuntimeException(jo.getStringOrNull("description"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun urlOf(method: String) = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("api.telegram.org")
|
||||
.addPathSegment("bot$botToken")
|
||||
.addPathSegment(method)
|
||||
}
|
||||
27
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
27
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ContentCache.kt
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
interface ContentCache {
|
||||
|
||||
val isCachingEnabled: Boolean
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
||||
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
||||
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
||||
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
||||
|
||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.core.util.SynchronizedSieveCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import androidx.collection.LruCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey
|
||||
|
||||
class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
@@ -11,19 +9,18 @@ class ExpiringLruCache<T>(
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
|
||||
private val cache = SynchronizedSieveCache<CacheKey, ExpiringValue<T>>(maxSize)
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
|
||||
operator fun get(key: CacheKey): T? {
|
||||
val value = cache[key] ?: return null
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
val value = cache.get(key) ?: return null
|
||||
if (value.isExpired) {
|
||||
cache.remove(key)
|
||||
}
|
||||
return value.get()
|
||||
}
|
||||
|
||||
operator fun set(key: CacheKey, value: T) {
|
||||
val value = ExpiringValue(value, lifetime, timeUnit)
|
||||
cache.put(key, value)
|
||||
operator fun set(key: ContentCache.Key, value: T) {
|
||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@@ -33,12 +30,4 @@ class ExpiringLruCache<T>(
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
|
||||
fun remove(key: CacheKey) {
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
fun removeAll(source: MangaSource) {
|
||||
cache.removeIf { key, _ -> key.source == source }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,57 +3,45 @@ package org.koitharu.kotatsu.core.cache
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
|
||||
|
||||
private val isLowRam = application.isLowRamDevice()
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache =
|
||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache =
|
||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
||||
|
||||
override val isCachingEnabled: Boolean = true
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[Key(source, url)] = details
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[ContentCache.Key(source, url)] = details
|
||||
}
|
||||
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[Key(source, url)]?.awaitOrNull()
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[Key(source, url)] = pages
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[ContentCache.Key(source, url)] = pages
|
||||
}
|
||||
|
||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||
relatedMangaCache[Key(source, url)] = related
|
||||
}
|
||||
|
||||
fun clear(source: MangaSource) {
|
||||
clearCache(detailsCache, source)
|
||||
clearCache(pagesCache, source)
|
||||
clearCache(relatedMangaCache, source)
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
@@ -79,13 +67,4 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||
cache.removeAll(source)
|
||||
}
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
|
||||
22
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
22
app/src/main/kotlin/org/koitharu/kotatsu/core/cache/StubContentCache.kt
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class StubContentCache : ContentCache {
|
||||
|
||||
override val isCachingEnabled: Boolean = false
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
||||
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||
}
|
||||
@@ -12,13 +12,11 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
||||
import org.koitharu.kotatsu.core.db.dao.ChaptersDao
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaDao
|
||||
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||
import org.koitharu.kotatsu.core.db.dao.PreferencesDao
|
||||
import org.koitharu.kotatsu.core.db.dao.TagsDao
|
||||
import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
|
||||
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
@@ -31,16 +29,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -56,60 +45,50 @@ 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.local.data.index.LocalMangaIndexDao
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
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 = 25
|
||||
const val DATABASE_VERSION = 17
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class,
|
||||
TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class,
|
||||
MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getHistoryDao(): HistoryDao
|
||||
abstract val historyDao: HistoryDao
|
||||
|
||||
abstract fun getTagsDao(): TagsDao
|
||||
abstract val tagsDao: TagsDao
|
||||
|
||||
abstract fun getMangaDao(): MangaDao
|
||||
abstract val mangaDao: MangaDao
|
||||
|
||||
abstract fun getFavouritesDao(): FavouritesDao
|
||||
abstract val favouritesDao: FavouritesDao
|
||||
|
||||
abstract fun getPreferencesDao(): PreferencesDao
|
||||
abstract val preferencesDao: PreferencesDao
|
||||
|
||||
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
|
||||
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
|
||||
|
||||
abstract fun getTracksDao(): TracksDao
|
||||
abstract val tracksDao: TracksDao
|
||||
|
||||
abstract fun getTrackLogsDao(): TrackLogsDao
|
||||
abstract val trackLogsDao: TrackLogsDao
|
||||
|
||||
abstract fun getSuggestionDao(): SuggestionDao
|
||||
abstract val suggestionDao: SuggestionDao
|
||||
|
||||
abstract fun getBookmarksDao(): BookmarksDao
|
||||
abstract val bookmarksDao: BookmarksDao
|
||||
|
||||
abstract fun getScrobblingDao(): ScrobblingDao
|
||||
abstract val scrobblingDao: ScrobblingDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
|
||||
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
|
||||
|
||||
abstract fun getChaptersDao(): ChaptersDao
|
||||
abstract val sourcesDao: MangaSourcesDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -129,15 +108,6 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration14To15(),
|
||||
Migration15To16(),
|
||||
Migration16To17(context),
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
Migration20To21(),
|
||||
Migration21To22(),
|
||||
Migration22To23(),
|
||||
Migration23To24(),
|
||||
Migration24To23(),
|
||||
Migration24To25(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import java.util.LinkedList
|
||||
|
||||
class MangaQueryBuilder(
|
||||
private val table: String,
|
||||
private val conditionCallback: ConditionCallback
|
||||
) {
|
||||
|
||||
private var filterOptions: Collection<ListFilterOption> = emptyList()
|
||||
private var whereConditions = LinkedList<String>()
|
||||
private var orderBy: String? = null
|
||||
private var groupBy: String? = null
|
||||
private var extraJoins: String? = null
|
||||
private var limit: Int = 0
|
||||
|
||||
fun filters(options: Collection<ListFilterOption>) = apply {
|
||||
filterOptions = options
|
||||
}
|
||||
|
||||
fun where(condition: String) = apply {
|
||||
whereConditions.add(condition)
|
||||
}
|
||||
|
||||
fun orderBy(orderBy: String?) = apply {
|
||||
this@MangaQueryBuilder.orderBy = orderBy
|
||||
}
|
||||
|
||||
fun groupBy(groupBy: String?) = apply {
|
||||
this@MangaQueryBuilder.groupBy = groupBy
|
||||
}
|
||||
|
||||
fun limit(limit: Int) = apply {
|
||||
this@MangaQueryBuilder.limit = limit
|
||||
}
|
||||
|
||||
fun join(join: String?) = apply {
|
||||
extraJoins = join
|
||||
}
|
||||
|
||||
fun build() = buildString {
|
||||
append("SELECT * FROM ")
|
||||
append(table)
|
||||
extraJoins?.let {
|
||||
append(' ')
|
||||
append(it)
|
||||
}
|
||||
if (whereConditions.isNotEmpty()) {
|
||||
whereConditions.joinTo(
|
||||
buffer = this,
|
||||
prefix = " WHERE ",
|
||||
separator = " AND ",
|
||||
)
|
||||
}
|
||||
if (filterOptions.isNotEmpty()) {
|
||||
if (whereConditions.isEmpty()) {
|
||||
append(" WHERE")
|
||||
} else {
|
||||
append(" AND")
|
||||
}
|
||||
var isFirst = true
|
||||
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||
for ((_, group) in groupedOptions) {
|
||||
if (group.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
if (isFirst) {
|
||||
isFirst = false
|
||||
append(' ')
|
||||
} else {
|
||||
append(" AND ")
|
||||
}
|
||||
if (group.size > 1) {
|
||||
group.joinTo(
|
||||
buffer = this,
|
||||
separator = " OR ",
|
||||
prefix = "(",
|
||||
postfix = ")",
|
||||
transform = ::getConditionOrThrow,
|
||||
)
|
||||
} else {
|
||||
append(getConditionOrThrow(group.single()))
|
||||
}
|
||||
}
|
||||
}
|
||||
groupBy?.let {
|
||||
append(" GROUP BY ")
|
||||
append(it)
|
||||
}
|
||||
orderBy?.let {
|
||||
append(" ORDER BY ")
|
||||
append(it)
|
||||
}
|
||||
if (limit > 0) {
|
||||
append(" LIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}.let { SimpleSQLiteQuery(it) }
|
||||
|
||||
private fun getConditionOrThrow(option: ListFilterOption): String = when (option) {
|
||||
is ListFilterOption.Inverted -> "NOT(${getConditionOrThrow(option.option)})"
|
||||
else -> requireNotNull(conditionCallback.getCondition(option)) {
|
||||
"Unsupported filter option $option"
|
||||
}
|
||||
}
|
||||
|
||||
fun interface ConditionCallback {
|
||||
|
||||
fun getCondition(option: ListFilterOption): String?
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,3 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
|
||||
const val TABLE_HISTORY = "history"
|
||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||
const val TABLE_SOURCES = "sources"
|
||||
const val TABLE_CHAPTERS = "chapters"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import org.koitharu.kotatsu.core.db.entity.ChapterEntity
|
||||
|
||||
@Dao
|
||||
abstract class ChaptersDao {
|
||||
|
||||
@Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC")
|
||||
abstract suspend fun findAll(mangaId: Long): List<ChapterEntity>
|
||||
|
||||
@Query("DELETE FROM chapters WHERE manga_id = :mangaId")
|
||||
abstract suspend fun deleteAll(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
|
||||
abstract suspend fun gc()
|
||||
|
||||
@Transaction
|
||||
open suspend fun replaceAll(mangaId: Long, entities: Collection<ChapterEntity>) {
|
||||
deleteAll(mangaId)
|
||||
insert(entities)
|
||||
}
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
protected abstract suspend fun insert(entities: Collection<ChapterEntity>)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
@@ -20,20 +19,10 @@ abstract class MangaDao {
|
||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): MangaWithTags?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)")
|
||||
abstract suspend operator fun contains(id: Long): Boolean
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE source = :source")
|
||||
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
||||
|
||||
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
@@ -43,7 +32,7 @@ abstract class MangaDao {
|
||||
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@Upsert
|
||||
protected abstract suspend fun upsert(manga: MangaEntity)
|
||||
abstract suspend fun upsert(manga: MangaEntity)
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun update(manga: MangaEntity): Int
|
||||
@@ -54,23 +43,6 @@ abstract class MangaDao {
|
||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||
|
||||
@Transaction
|
||||
@Delete
|
||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id)
|
||||
AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id)
|
||||
AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id)
|
||||
AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id)
|
||||
AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id)
|
||||
AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id)
|
||||
AND manga.manga_id NOT IN (:idsToKeep)
|
||||
""",
|
||||
)
|
||||
abstract suspend fun cleanup(idsToKeep: Set<Long>)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||
upsert(manga)
|
||||
|
||||
@@ -4,37 +4,26 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||
|
||||
@Dao
|
||||
abstract class MangaSourcesDao {
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||
abstract suspend fun findAllEnabledNames(): List<String>
|
||||
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY used_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLastUsed(limit: Int): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY pinned DESC, sort_key")
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||
abstract fun observeIsEnabled(source: String): Flow<Boolean>
|
||||
|
||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||
abstract suspend fun getMaxSortKey(): Int
|
||||
|
||||
@@ -44,12 +33,6 @@ abstract class MangaSourcesDao {
|
||||
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||
|
||||
@Query("UPDATE sources SET used_at = :value WHERE source = :source")
|
||||
abstract suspend fun setLastUsed(source: String, value: Long)
|
||||
|
||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
@Transaction
|
||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||
@@ -57,15 +40,6 @@ abstract class MangaSourcesDao {
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||
|
||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||
|
||||
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
||||
observeImpl(getQuery(enabledOnly, order))
|
||||
|
||||
suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List<MangaSourceEntity> =
|
||||
findAllImpl(getQuery(enabledOnly, order))
|
||||
|
||||
@Transaction
|
||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||
if (updateIsEnabled(source, isEnabled) == 0) {
|
||||
@@ -73,9 +47,6 @@ abstract class MangaSourcesDao {
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
addedIn = BuildConfig.VERSION_CODE,
|
||||
lastUsedAt = 0,
|
||||
isPinned = false,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
@@ -83,28 +54,4 @@ abstract class MangaSourcesDao {
|
||||
|
||||
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||
|
||||
@RawQuery(observedEntities = [MangaSourceEntity::class])
|
||||
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@RawQuery
|
||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
||||
|
||||
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
|
||||
buildString {
|
||||
append("SELECT * FROM sources ")
|
||||
if (enabledOnly) {
|
||||
append("WHERE enabled = 1 ")
|
||||
}
|
||||
append("ORDER BY pinned DESC, ")
|
||||
append(getOrderBy(order))
|
||||
},
|
||||
)
|
||||
|
||||
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||
SourcesSortOrder.LAST_USED -> "used_at DESC"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
|
||||
@@ -15,9 +13,6 @@ abstract class PreferencesDao {
|
||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||
|
||||
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
||||
abstract suspend fun resetColorFilters()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(pref: MangaPrefsEntity)
|
||||
}
|
||||
|
||||
@@ -31,16 +31,6 @@ abstract class TagsDao {
|
||||
)
|
||||
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) ASC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
|
||||
@@ -1,62 +1,29 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||
|
||||
@Dao
|
||||
abstract class TrackLogsDao : MangaQueryBuilder.ConditionCallback {
|
||||
|
||||
fun observeAll(
|
||||
limit: Int,
|
||||
filterOptions: Set<ListFilterOption>,
|
||||
): Flow<List<TrackLogWithManga>> = observeAllImpl(
|
||||
MangaQueryBuilder("track_logs", this)
|
||||
.filters(filterOptions)
|
||||
.limit(limit)
|
||||
.orderBy("created_at DESC")
|
||||
.build(),
|
||||
)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
|
||||
abstract fun observeUnreadCount(): Flow<Int>
|
||||
|
||||
@Query("DELETE FROM track_logs")
|
||||
abstract suspend fun clear()
|
||||
|
||||
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
|
||||
abstract suspend fun markAsRead(id: Long)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
abstract suspend fun insert(entity: TrackLogEntity): Long
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
abstract suspend fun gc()
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
||||
abstract suspend fun trim(size: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
abstract suspend fun count(): Int
|
||||
interface TrackLogsDao {
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [TrackLogEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
|
||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
|
||||
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${option.category.id})"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = track_logs.manga_id) = 1"
|
||||
else -> null
|
||||
}
|
||||
@Query("DELETE FROM track_logs")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: TrackLogEntity): Long
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
|
||||
suspend fun removeAll(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
suspend fun gc()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_CHAPTERS,
|
||||
primaryKeys = ["manga_id", "chapter_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class ChapterEntity(
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "name") val title: String,
|
||||
@ColumnInfo(name = "number") val number: Float,
|
||||
@ColumnInfo(name = "volume") val volume: Int,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "scanlator") val scanlator: String?,
|
||||
@ColumnInfo(name = "upload_date") val uploadDate: Long,
|
||||
@ColumnInfo(name = "branch") val branch: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
@ColumnInfo(name = "index") val index: Int,
|
||||
)
|
||||
@@ -1,20 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.toArraySet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
private const val VALUES_DIVIDER = '\n'
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
@@ -27,41 +21,23 @@ fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga(
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState(it) },
|
||||
rating = this.rating,
|
||||
contentRating = ContentRating(this.contentRating)
|
||||
?: if (isNsfw) ContentRating.ADULT else null,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
|
||||
author = this.author,
|
||||
source = MangaSource(this.source),
|
||||
tags = tags,
|
||||
chapters = chapters?.toMangaChapters(),
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga(chapters: List<ChapterEntity>? = null) = manga.toManga(tags.toMangaTags(), chapters)
|
||||
|
||||
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
|
||||
|
||||
fun ChapterEntity.toMangaChapter() = MangaChapter(
|
||||
id = chapterId,
|
||||
title = title.nullIfEmpty(),
|
||||
number = number,
|
||||
volume = volume,
|
||||
url = url,
|
||||
scanlator = scanlator,
|
||||
uploadDate = uploadDate,
|
||||
branch = branch,
|
||||
source = MangaSource(source),
|
||||
)
|
||||
|
||||
fun Collection<ChapterEntity>.toMangaChapters() = map { it.toMangaChapter() }
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
// Model to entity
|
||||
|
||||
@@ -71,14 +47,13 @@ fun Manga.toEntity() = MangaEntity(
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl.orEmpty(),
|
||||
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
contentRating = contentRating?.name,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
authors = authors.joinToString(VALUES_DIVIDER.toString()),
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
@@ -90,22 +65,6 @@ fun MangaTag.toEntity() = TagEntity(
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (index, chapter) ->
|
||||
ChapterEntity(
|
||||
chapterId = chapter.id,
|
||||
mangaId = mangaId,
|
||||
title = chapter.title.orEmpty(),
|
||||
number = chapter.number,
|
||||
volume = chapter.volume,
|
||||
url = chapter.url,
|
||||
scanlator = chapter.scanlator,
|
||||
uploadDate = chapter.uploadDate,
|
||||
branch = chapter.branch,
|
||||
source = chapter.source.name,
|
||||
index = index,
|
||||
)
|
||||
}
|
||||
|
||||
// Other
|
||||
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
@@ -115,7 +74,3 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
MangaState.valueOf(name)
|
||||
}.getOrNull()
|
||||
|
||||
fun ContentRating(name: String?): ContentRating? = runCatching {
|
||||
ContentRating.valueOf(name ?: return@runCatching null)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -10,15 +10,14 @@ data class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "alt_title") val altTitles: String?,
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "content_rating") val contentRating: String?,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val authors: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
|
||||
@@ -24,5 +24,4 @@ data class MangaPrefsEntity(
|
||||
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
||||
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,4 @@ data class MangaSourceEntity(
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration10To11 : Migration(10, 11) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
||||
`manga_id` INTEGER NOT NULL,
|
||||
@@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) {
|
||||
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration11To12 : Migration(11, 12) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `scrobblings` (
|
||||
`scrobbler` INTEGER NOT NULL,
|
||||
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration12To13 : Migration(12, 13) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
|
||||
database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration13To14 : Migration(13, 14) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user