Compare commits
1 Commits
devel
...
ui_playgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4f18066 |
@@ -4,7 +4,7 @@ root = true
|
|||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = space
|
indent_style = tab
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
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:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
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
|
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
|
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
|
label: Acknowledgements
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
options:
|
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
|
required: true
|
||||||
BIN
.github/assets/vtuber.png
vendored
BIN
.github/assets/vtuber.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
16
.github/workflows/trigger-site-deploy.yml
vendored
16
.github/workflows/trigger-site-deploy.yml
vendored
@@ -1,16 +0,0 @@
|
|||||||
name: Trigger Site Update
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
trigger-site:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Send repository_dispatch to site-repo
|
|
||||||
uses: peter-evans/repository-dispatch@v3
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.SITE_REPO_TOKEN }}
|
|
||||||
repository: KotatsuApp/website
|
|
||||||
event-type: app-release
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,7 +6,6 @@
|
|||||||
/.idea/dictionaries
|
/.idea/dictionaries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
/.idea/misc.xml
|
/.idea/misc.xml
|
||||||
/.idea/markdown.xml
|
|
||||||
/.idea/discord.xml
|
/.idea/discord.xml
|
||||||
/.idea/compiler.xml
|
/.idea/compiler.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
@@ -26,5 +25,3 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/.idea/deviceManager.xml
|
/.idea/deviceManager.xml
|
||||||
/.kotlin/
|
|
||||||
/.idea/AndroidProjectSystem.xml
|
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -2,6 +2,3 @@
|
|||||||
/shelf/
|
/shelf/
|
||||||
/workspace.xml
|
/workspace.xml
|
||||||
/migrations.xml
|
/migrations.xml
|
||||||
/runConfigurations.xml
|
|
||||||
/appInsightsSettings.xml
|
|
||||||
/kotlinCodeInsightSettings.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>
|
|
||||||
26
.idea/appInsightsSettings.xml
generated
26
.idea/appInsightsSettings.xml
generated
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AppInsightsSettings">
|
|
||||||
<option name="tabSettings">
|
|
||||||
<map>
|
|
||||||
<entry key="Firebase Crashlytics">
|
|
||||||
<value>
|
|
||||||
<InsightsFilterSettings>
|
|
||||||
<option name="connection">
|
|
||||||
<ConnectionSetting>
|
|
||||||
<option name="appId" value="PLACEHOLDER" />
|
|
||||||
<option name="mobileSdkAppId" value="" />
|
|
||||||
<option name="projectId" value="" />
|
|
||||||
<option name="projectNumber" value="" />
|
|
||||||
</ConnectionSetting>
|
|
||||||
</option>
|
|
||||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
|
||||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
|
||||||
<option name="visibilityType" value="ALL" />
|
|
||||||
</InsightsFilterSettings>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
</map>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
74
.idea/codeStyles/Project.xml
generated
74
.idea/codeStyles/Project.xml
generated
@@ -1,7 +1,9 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<option name="OTHER_INDENT_OPTIONS">
|
<option name="OTHER_INDENT_OPTIONS">
|
||||||
<value />
|
<value>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</value>
|
||||||
</option>
|
</option>
|
||||||
<AndroidXmlCodeStyleSettings>
|
<AndroidXmlCodeStyleSettings>
|
||||||
<option name="LAYOUT_SETTINGS">
|
<option name="LAYOUT_SETTINGS">
|
||||||
@@ -20,46 +22,40 @@
|
|||||||
</value>
|
</value>
|
||||||
</option>
|
</option>
|
||||||
</AndroidXmlCodeStyleSettings>
|
</AndroidXmlCodeStyleSettings>
|
||||||
<JavaCodeStyleSettings>
|
|
||||||
<option name="IMPORT_LAYOUT_TABLE">
|
|
||||||
<value>
|
|
||||||
<package name="android" withSubpackages="true" static="true" />
|
|
||||||
<package name="androidx" withSubpackages="true" static="true" />
|
|
||||||
<package name="com" withSubpackages="true" static="true" />
|
|
||||||
<package name="junit" withSubpackages="true" static="true" />
|
|
||||||
<package name="net" withSubpackages="true" static="true" />
|
|
||||||
<package name="org" withSubpackages="true" static="true" />
|
|
||||||
<package name="java" withSubpackages="true" static="true" />
|
|
||||||
<package name="javax" withSubpackages="true" static="true" />
|
|
||||||
<package name="" withSubpackages="true" static="true" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="android" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="androidx" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="com" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="junit" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="net" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="org" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="java" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="javax" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
<package name="" withSubpackages="true" static="false" />
|
|
||||||
<emptyLine />
|
|
||||||
</value>
|
|
||||||
</option>
|
|
||||||
</JavaCodeStyleSettings>
|
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||||
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
|
|
||||||
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="CMake">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Groovy">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="HTML">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JAVA">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JSON">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="ObjectiveC">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="Shell Script">
|
<codeStyleSettings language="Shell Script">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="USE_TAB_CHARACTER" value="true" />
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
@@ -68,6 +64,7 @@
|
|||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
<indentOptions>
|
<indentOptions>
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
<arrangement>
|
<arrangement>
|
||||||
<rules>
|
<rules>
|
||||||
@@ -182,6 +179,9 @@
|
|||||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
||||||
4
.idea/gradle.xml
generated
4
.idea/gradle.xml
generated
@@ -4,7 +4,6 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
@@ -13,7 +12,8 @@
|
|||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="resolveExternalAnnotations" value="false" />
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -10,6 +10,6 @@
|
|||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
114
README.md
114
README.md
@@ -1,115 +1,57 @@
|
|||||||
> [!IMPORTANT]
|
# Kotatsu
|
||||||
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Google’s
|
|
||||||
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — we’ve made the difficult decision to shut down Kotatsu and end its support. We’re deeply grateful
|
|
||||||
> to everyone who contributed and to the amazing community that grew around this project.
|
|
||||||
|
|
||||||
---
|
Kotatsu is a free and open source manga reader for Android.
|
||||||
|
|
||||||
<div align="center">
|
   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
||||||
|
|
||||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
|
### Download
|
||||||
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)
|
- **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
|
### Main Features
|
||||||
|
|
||||||
<div align="left">
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
|
||||||
|
* Search manga by name and genres
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources)
|
* Reading history and bookmarks
|
||||||
* Search manga by name, genres and more filters
|
* Favourites organized by user-defined categories
|
||||||
* Favorites organized by user-defined categories
|
* Downloading manga and reading it offline. Third-party CBZ archives also supported
|
||||||
* Reading history, bookmarks and incognito mode support
|
* Tablet-optimized Material You UI
|
||||||
* Download manga and read it offline. Third-party CBZ archives are also supported
|
* Standard and Webtoon-optimized reader
|
||||||
* Clean and convenient Material You UI, optimized for phones, tablets and desktop
|
* Notifications about new chapters with updates feed
|
||||||
* 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
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password / fingerprint-protected access to the app
|
* Password/fingerprint protect access to the app
|
||||||
* Automatically sync app data with other devices on the same account
|
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
|
||||||
* Support for older devices running Android 6.0+
|
|
||||||
|
|
||||||
</div>
|
### Screenshots
|
||||||
|
|
||||||
### 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
|
### Localization
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
|
||||||
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
|
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
|
||||||
**📌 If you would like to help improve these or add new languages,
|
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
|
||||||
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
|
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
<br>
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
|
||||||
|
|
||||||
<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**
|
|
||||||
|
|
||||||
### Certificate fingerprints
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE
|
|
||||||
```
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
67:E1:51:00:BB:80:93:01:78:3E:DC:B6:34:8F:A3:BB:F8:30:34:D9:1E:62:86:8A:91:05:3D:BD:70:DB:3F:18
|
|
||||||
```
|
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](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
|
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 &
|
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||||
install instructions.
|
install instructions.
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### DMCA disclaimer
|
### DMCA disclaimer
|
||||||
|
|
||||||
<div align="left">
|
The developers of this application does not have any affiliation with the content available in the app.
|
||||||
|
It is collecting from the sources freely available through any web browser.
|
||||||
The developers of this application do not have any affiliation with the content available in the app and does not store
|
|
||||||
or distribute any content. This application should be considered a web browser, all content that can be found using this
|
|
||||||
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
|
|
||||||
where the content is hosted.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
216
app/build.gradle
216
app/build.gradle
@@ -1,43 +1,32 @@
|
|||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'dagger.hilt.android.plugin'
|
id 'dagger.hilt.android.plugin'
|
||||||
id 'androidx.room'
|
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
// enable if needed
|
|
||||||
// id 'dev.reformator.stacktracedecoroutinator'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 36
|
compileSdk = 34
|
||||||
buildToolsVersion = '35.0.0'
|
buildToolsVersion = '34.0.0'
|
||||||
namespace = 'org.koitharu.kotatsu'
|
namespace = 'org.koitharu.kotatsu'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 23
|
minSdk = 21
|
||||||
targetSdk = 36
|
targetSdk = 34
|
||||||
versionCode = 1033
|
versionCode = 642
|
||||||
versionName = '9.4.1'
|
versionName = '7.0.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
arg('room.generateKotlin', 'true')
|
arg('room.generateKotlin', 'true')
|
||||||
|
arg('room.schemaLocation', "$projectDir/schemas")
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
// https://issuetracker.google.com/issues/408030127
|
generateLocaleConfig true
|
||||||
generateLocaleConfig false
|
|
||||||
}
|
}
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localProperties.load(new FileInputStream(localPropertiesFile))
|
|
||||||
}
|
|
||||||
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
|
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
@@ -48,55 +37,33 @@ android {
|
|||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
nightly {
|
|
||||||
initWith release
|
|
||||||
applicationIdSuffix = '.nightly'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
packagingOptions {
|
|
||||||
resources {
|
|
||||||
excludes += [
|
|
||||||
'META-INF/README.md',
|
|
||||||
'META-INF/NOTICE.md'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
main.java.srcDirs += 'src/main/kotlin/'
|
main.java.srcDirs += 'src/main/kotlin/'
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
|
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
|
||||||
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
|
||||||
'-Xjspecify-annotations=strict',
|
|
||||||
'-Xannotation-default-target=first-only',
|
|
||||||
'-Xtype-enhancement-improvements-strict-mode'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
room {
|
|
||||||
schemaDirectory "$projectDir/schemas"
|
|
||||||
}
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError true
|
abortOnError true
|
||||||
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'
|
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources true
|
unitTests.includeAndroidResources true
|
||||||
@@ -105,110 +72,97 @@ android {
|
|||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applicationVariants.configureEach { variant ->
|
}
|
||||||
if (variant.name == 'nightly') {
|
afterEvaluate {
|
||||||
variant.outputs.each { output ->
|
compileDebugKotlin {
|
||||||
def now = LocalDateTime.now()
|
kotlinOptions {
|
||||||
output.versionCodeOverride = now.format("yyMMdd").toInteger()
|
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
||||||
output.versionNameOverride = 'N' + now.format("yyyyMMdd")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
def parsersVersion = libs.versions.parsers.get()
|
//noinspection GradleDependency
|
||||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
||||||
// 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") {
|
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring libs.desugar.jdk.libs
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||||
implementation libs.kotlin.stdlib
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||||
implementation libs.kotlinx.coroutines.android
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
implementation libs.kotlinx.coroutines.guava
|
|
||||||
|
|
||||||
implementation libs.androidx.appcompat
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
implementation libs.androidx.core
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation libs.androidx.activity
|
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||||
implementation libs.androidx.fragment
|
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
||||||
implementation libs.androidx.transition
|
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||||
implementation libs.androidx.collection
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
||||||
implementation libs.lifecycle.viewmodel
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
||||||
implementation libs.lifecycle.service
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
||||||
implementation libs.lifecycle.process
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation libs.androidx.constraintlayout
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation libs.androidx.documentfile
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation libs.androidx.swiperefreshlayout
|
implementation 'androidx.viewpager2:viewpager2:1.1.0'
|
||||||
implementation libs.androidx.recyclerview
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation libs.androidx.viewpager2
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation libs.androidx.preference
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation libs.androidx.biometric
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
||||||
implementation libs.material
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
implementation libs.androidx.lifecycle.common.java8
|
|
||||||
implementation libs.androidx.webkit
|
|
||||||
|
|
||||||
implementation libs.androidx.work.runtime
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
implementation libs.guava
|
//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'
|
||||||
|
}
|
||||||
|
|
||||||
// Foldable/Window layout
|
implementation 'androidx.room:room-runtime:2.6.1'
|
||||||
implementation libs.androidx.window
|
implementation 'androidx.room:room-ktx:2.6.1'
|
||||||
|
ksp 'androidx.room:room-compiler:2.6.1'
|
||||||
|
|
||||||
implementation libs.androidx.room.runtime
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
implementation libs.androidx.room.ktx
|
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
|
||||||
ksp libs.androidx.room.compiler
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
|
implementation 'com.squareup.okio:okio:3.9.0'
|
||||||
|
|
||||||
implementation libs.okhttp
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation libs.okhttp.tls
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
implementation libs.okhttp.dnsoverhttps
|
|
||||||
implementation libs.okio
|
|
||||||
implementation libs.kotlinx.serialization.json
|
|
||||||
|
|
||||||
implementation libs.adapterdelegates
|
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||||
implementation libs.adapterdelegates.viewbinding
|
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||||
|
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||||
|
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||||
|
|
||||||
implementation libs.hilt.android
|
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||||
ksp libs.hilt.compiler
|
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||||
implementation libs.androidx.hilt.work
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||||
ksp libs.androidx.hilt.compiler
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
implementation libs.coil.core
|
implementation 'ch.acra:acra-http:5.11.3'
|
||||||
implementation libs.coil.network
|
implementation 'ch.acra:acra-dialog:5.11.3'
|
||||||
implementation libs.coil.gif
|
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
|
||||||
implementation libs.coil.svg
|
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
|
||||||
implementation libs.avif.decoder
|
|
||||||
implementation libs.ssiv
|
|
||||||
implementation libs.disk.lru.cache
|
|
||||||
implementation libs.markwon
|
|
||||||
implementation libs.kizzyrpc
|
|
||||||
|
|
||||||
implementation libs.acra.http
|
implementation 'org.conscrypt:conscrypt-android:2.5.2'
|
||||||
implementation libs.acra.dialog
|
|
||||||
|
|
||||||
implementation libs.conscrypt.android
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
|
||||||
|
debugImplementation 'com.github.Koitharu:WorkInspector:5778dd1747'
|
||||||
|
|
||||||
debugImplementation libs.leakcanary.android
|
testImplementation 'junit:junit:4.13.2'
|
||||||
nightlyImplementation libs.leakcanary.android
|
testImplementation 'org.json:json:20240303'
|
||||||
debugImplementation libs.workinspector
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
|
|
||||||
testImplementation libs.junit
|
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||||
testImplementation libs.json
|
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||||
testImplementation libs.kotlinx.coroutines.test
|
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||||
|
|
||||||
androidTestImplementation libs.androidx.runner
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
|
||||||
androidTestImplementation libs.androidx.rules
|
|
||||||
androidTestImplementation libs.androidx.test.core
|
|
||||||
androidTestImplementation libs.androidx.junit
|
|
||||||
|
|
||||||
androidTestImplementation libs.kotlinx.coroutines.test
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
|
|
||||||
androidTestImplementation libs.androidx.room.testing
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
||||||
androidTestImplementation libs.moshi.kotlin
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||||
|
|
||||||
androidTestImplementation libs.hilt.android.testing
|
|
||||||
kspAndroidTest libs.hilt.android.compiler
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
app/proguard-rules.pro
vendored
16
app/proguard-rules.pro
vendored
@@ -8,24 +8,16 @@
|
|||||||
public static void checkParameterIsNotNull(...);
|
public static void checkParameterIsNotNull(...);
|
||||||
public static void checkNotNullParameter(...);
|
public static void checkNotNullParameter(...);
|
||||||
}
|
}
|
||||||
|
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
|
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||||
-dontwarn okhttp3.internal.platform.**
|
-dontwarn okhttp3.internal.platform.**
|
||||||
-dontwarn org.conscrypt.**
|
-dontwarn org.conscrypt.**
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-dontwarn org.openjsse.**
|
-dontwarn org.openjsse.**
|
||||||
-dontwarn com.google.j2objc.annotations.**
|
|
||||||
-dontwarn coil3.PlatformContext
|
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
|
||||||
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
|
||||||
|
|
||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-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.core.prefs.ScreenshotsPolicy { *; }
|
||||||
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
||||||
-keep class org.jsoup.parser.Tag
|
-keep class org.jsoup.parser.Tag
|
||||||
-keep class org.jsoup.internal.StringUtil
|
-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
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,15 +29,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 1552943969433540704,
|
"id": 1552943969433540704,
|
||||||
"title": "1 - 1",
|
"name": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -46,9 +43,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540705,
|
"id": 1552943969433540705,
|
||||||
"title": "1 - 2",
|
"name": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -56,9 +52,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540706,
|
"id": 1552943969433540706,
|
||||||
"title": "1 - 3",
|
"name": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -66,9 +61,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540707,
|
"id": 1552943969433540707,
|
||||||
"title": "1 - 4",
|
"name": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -76,9 +70,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540708,
|
"id": 1552943969433540708,
|
||||||
"title": "1 - 5",
|
"name": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -86,9 +79,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541665,
|
"id": 1552943969433541665,
|
||||||
"title": "2 - 1",
|
"name": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -96,9 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541666,
|
"id": 1552943969433541666,
|
||||||
"title": "2 - 2",
|
"name": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -106,9 +97,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541667,
|
"id": 1552943969433541667,
|
||||||
"title": "2 - 3",
|
"name": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -116,9 +106,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541668,
|
"id": 1552943969433541668,
|
||||||
"title": "2 - 4",
|
"name": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -126,9 +115,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541669,
|
"id": 1552943969433541669,
|
||||||
"title": "2 - 5",
|
"name": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -136,9 +124,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541670,
|
"id": 1552943969433541670,
|
||||||
"title": "2 - 6",
|
"name": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -146,9 +133,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542626,
|
"id": 1552943969433542626,
|
||||||
"title": "3 - 1",
|
"name": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -156,9 +142,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542627,
|
"id": 1552943969433542627,
|
||||||
"title": "3 - 2",
|
"name": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -166,9 +151,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542628,
|
"id": 1552943969433542628,
|
||||||
"title": "3 - 3",
|
"name": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -176,4 +160,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,9 +29,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [],
|
"chapters": [],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,15 +29,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"title": "1 - 1",
|
"name": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -46,9 +43,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"title": "1 - 2",
|
"name": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -56,9 +52,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"title": "1 - 3",
|
"name": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -66,9 +61,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"title": "1 - 4",
|
"name": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -76,9 +70,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"title": "1 - 5",
|
"name": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -86,9 +79,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"title": "2 - 1",
|
"name": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -96,9 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"title": "2 - 2",
|
"name": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -106,9 +97,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"title": "2 - 3",
|
"name": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -116,9 +106,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"title": "2 - 4",
|
"name": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -126,9 +115,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"title": "2 - 5",
|
"name": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -136,9 +124,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"title": "2 - 6",
|
"name": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -146,4 +133,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,15 +29,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"title": "1 - 1",
|
"name": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -46,9 +43,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"title": "1 - 2",
|
"name": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -56,9 +52,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"title": "1 - 3",
|
"name": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -66,9 +61,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"title": "1 - 4",
|
"name": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -76,9 +70,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"title": "1 - 5",
|
"name": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -86,9 +79,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"title": "2 - 1",
|
"name": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -96,9 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"title": "2 - 2",
|
"name": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -106,9 +97,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"title": "2 - 3",
|
"name": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -116,9 +106,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"title": "2 - 4",
|
"name": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -126,9 +115,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"title": "2 - 5",
|
"name": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -136,9 +124,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"title": "2 - 6",
|
"name": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -146,9 +133,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"title": "3 - 1",
|
"name": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -156,9 +142,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"title": "3 - 2",
|
"name": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -166,9 +151,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"title": "3 - 3",
|
"name": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -176,4 +160,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,8 +29,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": null,
|
"description": null,
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
"altTitles": [],
|
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -30,15 +29,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
"authors": [],
|
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"title": "1 - 1",
|
"name": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -46,9 +43,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"title": "1 - 2",
|
"name": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -56,9 +52,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"title": "1 - 3",
|
"name": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -66,9 +61,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"title": "1 - 4",
|
"name": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -76,9 +70,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"title": "1 - 5",
|
"name": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -86,9 +79,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"title": "2 - 2",
|
"name": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -96,9 +88,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"title": "2 - 3",
|
"name": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,9 +97,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"title": "2 - 4",
|
"name": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -116,9 +106,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"title": "2 - 5",
|
"name": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -126,9 +115,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"title": "2 - 6",
|
"name": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -136,9 +124,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"title": "3 - 1",
|
"name": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -146,9 +133,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"title": "3 - 2",
|
"name": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -156,9 +142,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"title": "3 - 3",
|
"name": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
"volume": 0,
|
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -166,4 +151,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,19 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.squareup.moshi.FromJson
|
import com.squareup.moshi.*
|
||||||
import com.squareup.moshi.JsonAdapter
|
|
||||||
import com.squareup.moshi.JsonReader
|
|
||||||
import com.squareup.moshi.JsonWriter
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.ToJson
|
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import java.util.*
|
||||||
import java.time.Instant
|
|
||||||
import java.util.Date
|
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
object SampleData {
|
object SampleData {
|
||||||
|
|
||||||
private val moshi = Moshi.Builder()
|
private val moshi = Moshi.Builder()
|
||||||
.add(DateAdapter())
|
.add(DateAdapter())
|
||||||
.add(InstantAdapter())
|
|
||||||
.add(MangaSourceAdapter())
|
|
||||||
.add(KotlinJsonAdapterFactory())
|
.add(KotlinJsonAdapterFactory())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -61,36 +51,4 @@ object SampleData {
|
|||||||
writer.value(value?.time ?: 0L)
|
writer.value(value?.time ?: 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private class MangaSourceAdapter : JsonAdapter<MangaSource>() {
|
|
||||||
|
|
||||||
@FromJson
|
|
||||||
override fun fromJson(reader: JsonReader): MangaSource? {
|
|
||||||
val name = reader.nextString() ?: return null
|
|
||||||
return MangaSource(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToJson
|
|
||||||
override fun toJson(writer: JsonWriter, value: MangaSource?) {
|
|
||||||
writer.value(value?.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class InstantAdapter : JsonAdapter<Instant>() {
|
|
||||||
|
|
||||||
@FromJson
|
|
||||||
override fun fromJson(reader: JsonReader): Instant? {
|
|
||||||
val ms = reader.nextLong()
|
|
||||||
return if (ms == 0L) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
Instant.ofEpochMilli(ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ToJson
|
|
||||||
override fun toJson(writer: JsonWriter, value: Instant?) {
|
|
||||||
writer.value(value?.toEpochMilli() ?: 0L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,7 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koitharu.kotatsu.SampleData
|
import org.koitharu.kotatsu.SampleData
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||||
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
tracker.syncWithHistory(mangaFull, chapter.id)
|
||||||
|
|
||||||
|
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
|
||||||
|
|
||||||
|
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
|
||||||
|
tracker.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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/debug/AndroidManifest.xml
Normal file
12
app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
||||||
|
android:label="@string/check_for_new_chapters" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -1,91 +1,45 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
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)
|
super.attachBaseContext(base)
|
||||||
enableStrictMode()
|
enableStrictMode()
|
||||||
configureLeakCanary()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureLeakCanary() {
|
|
||||||
LeakCanary.config = LeakCanary.config.copy(
|
|
||||||
dumpHeap = isLeakCanaryEnabled,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableStrictMode() {
|
private fun enableStrictMode() {
|
||||||
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
StrictModeNotifier(this)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder().apply {
|
StrictMode.ThreadPolicy.Builder()
|
||||||
detectNetwork()
|
.detectAll()
|
||||||
detectDiskWrites()
|
.penaltyLog()
|
||||||
detectCustomSlowCalls()
|
.build(),
|
||||||
detectResourceMismatches()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
|
||||||
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.setVmPolicy(
|
StrictMode.setVmPolicy(
|
||||||
StrictMode.VmPolicy.Builder().apply {
|
StrictMode.VmPolicy.Builder()
|
||||||
detectActivityLeaks()
|
.detectAll()
|
||||||
detectLeakedSqlLiteObjects()
|
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
||||||
detectLeakedClosableObjects()
|
.setClassInstanceLimit(PagesCache::class.java, 1)
|
||||||
detectLeakedRegistrationObjects()
|
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
.setClassInstanceLimit(PageLoader::class.java, 1)
|
||||||
detectContentUriWithoutPermission()
|
.penaltyLog()
|
||||||
}
|
.build(),
|
||||||
detectFileUriExposure()
|
|
||||||
penaltyLog()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
|
||||||
penaltyListener(notifier.executor, notifier)
|
|
||||||
}
|
|
||||||
}.build(),
|
|
||||||
)
|
)
|
||||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
detectWrongFragmentContainer()
|
.penaltyDeath()
|
||||||
detectFragmentTagUsage()
|
.detectFragmentReuse()
|
||||||
detectRetainInstanceUsage()
|
.detectWrongFragmentContainer()
|
||||||
detectSetUserVisibleHint()
|
.detectRetainInstanceUsage()
|
||||||
detectWrongNestedHierarchy()
|
.detectSetUserVisibleHint()
|
||||||
detectFragmentReuse()
|
.detectFragmentTagUsage()
|
||||||
penaltyLog()
|
.build()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 android.util.Log
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
|
||||||
@@ -13,11 +12,8 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
private val escapeRegex = Regex("([\\[\\]\"])")
|
private val escapeRegex = Regex("([\\[\\]\"])")
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
logRequest(it.networkResponse?.request ?: it.request)
|
val request = chain.request()
|
||||||
}
|
|
||||||
|
|
||||||
private fun logRequest(request: Request) {
|
|
||||||
var isCompressed = false
|
var isCompressed = false
|
||||||
|
|
||||||
val curlCmd = StringBuilder()
|
val curlCmd = StringBuilder()
|
||||||
@@ -50,11 +46,16 @@ class CurlLoggingInterceptor(
|
|||||||
|
|
||||||
log("---cURL (" + request.url + ")")
|
log("---cURL (" + request.url + ")")
|
||||||
log(curlCmd.toString())
|
log(curlCmd.toString())
|
||||||
|
|
||||||
|
return chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.escape() = replace(escapeRegex) { match ->
|
private fun String.escape() = replace(escapeRegex) { match ->
|
||||||
"\\" + match.value
|
"\\" + match.value
|
||||||
}
|
}
|
||||||
|
// .replace("\"", "\\\"")
|
||||||
|
// .replace("[", "\\[")
|
||||||
|
// .replace("]", "\\]")
|
||||||
|
|
||||||
private fun log(msg: String) {
|
private fun log(msg: String) {
|
||||||
Log.d("CURL", msg)
|
Log.d("CURL", msg)
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
|
||||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import java.util.EnumSet
|
|
||||||
|
|
||||||
/*
|
|
||||||
This class is for parser development and testing purposes
|
|
||||||
You can open it in the app via Settings -> Debug
|
|
||||||
*/
|
|
||||||
class TestMangaRepository(
|
|
||||||
@Suppress("unused") private val loaderContext: MangaLoaderContext,
|
|
||||||
cache: MemoryContentCache
|
|
||||||
) : CachingMangaRepository(cache) {
|
|
||||||
|
|
||||||
override val source = TestMangaSource
|
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
|
|
||||||
|
|
||||||
override var defaultSortOrder: SortOrder
|
|
||||||
get() = sortOrders.first()
|
|
||||||
set(value) = Unit
|
|
||||||
|
|
||||||
override val filterCapabilities = MangaListFilterCapabilities()
|
|
||||||
|
|
||||||
override suspend fun getFilterOptions() = MangaListFilterOptions()
|
|
||||||
|
|
||||||
override suspend fun getList(
|
|
||||||
offset: Int,
|
|
||||||
order: SortOrder?,
|
|
||||||
filter: MangaListFilter?
|
|
||||||
): List<Manga> = TODO("Get manga list by filter")
|
|
||||||
|
|
||||||
override suspend fun getDetailsImpl(
|
|
||||||
manga: Manga
|
|
||||||
): Manga = TODO("Fetch manga details")
|
|
||||||
|
|
||||||
override suspend fun getPagesImpl(
|
|
||||||
chapter: MangaChapter
|
|
||||||
): List<MangaPage> = TODO("Get pages for specific chapter")
|
|
||||||
|
|
||||||
override suspend fun getPageUrl(
|
|
||||||
page: MangaPage
|
|
||||||
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
|
|
||||||
|
|
||||||
override suspend fun getRelatedMangaImpl(
|
|
||||||
seed: Manga
|
|
||||||
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
|
|
||||||
}
|
|
||||||
@@ -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,72 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import leakcanary.LeakCanary
|
|
||||||
import org.koitharu.kotatsu.KotatsuApp
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
|
||||||
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
|
||||||
import org.koitharu.workinspector.WorkInspector
|
|
||||||
|
|
||||||
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
|
|
||||||
Preference.OnPreferenceClickListener {
|
|
||||||
|
|
||||||
private val application
|
|
||||||
get() = requireContext().applicationContext as KotatsuApp
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
addPreferencesFromResource(R.xml.pref_debug)
|
|
||||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
|
|
||||||
pref.isChecked = application.isLeakCanaryEnabled
|
|
||||||
pref.onPreferenceChangeListener = this
|
|
||||||
pref.onContainerClickListener = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
|
||||||
KEY_WORK_INSPECTOR -> {
|
|
||||||
startActivity(WorkInspector.getIntent(preference.context))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
KEY_TEST_PARSER -> {
|
|
||||||
router.openList(TestMangaSource, null, null)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
|
|
||||||
KEY_LEAK_CANARY -> {
|
|
||||||
startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onPreferenceTreeClick(preference)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
|
|
||||||
KEY_LEAK_CANARY -> {
|
|
||||||
application.isLeakCanaryEnabled = newValue as Boolean
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val KEY_LEAK_CANARY = "leak_canary"
|
|
||||||
const val KEY_WORK_INSPECTOR = "work_inspector"
|
|
||||||
const val KEY_TEST_PARSER = "test_parser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,24 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.text.bold
|
import androidx.core.text.bold
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.color
|
import androidx.core.text.color
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
||||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
|
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import androidx.appcompat.R as appcompatR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
fun trackDebugAD(
|
fun trackDebugAD(
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
coil: ImageLoader,
|
||||||
clickListener: OnListItemClickListener<TrackDebugItem>,
|
clickListener: OnListItemClickListener<TrackDebugItem>,
|
||||||
) = adapterDelegateViewBinding<TrackDebugItem, TrackDebugItem, ItemTrackDebugBinding>(
|
) = adapterDelegateViewBinding<TrackDebugItem, TrackDebugItem, ItemTrackDebugBinding>(
|
||||||
{ layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) },
|
{ layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) },
|
||||||
@@ -27,25 +35,30 @@ fun trackDebugAD(
|
|||||||
}
|
}
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
allowRgb565(true)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
binding.textViewTitle.text = item.manga.title
|
binding.textViewTitle.text = item.manga.title
|
||||||
binding.textViewSummary.text = buildSpannedString {
|
binding.textViewSummary.text = buildSpannedString {
|
||||||
append(
|
item.lastCheckTime?.let {
|
||||||
item.lastCheckTime?.let {
|
append(
|
||||||
DateUtils.getRelativeDateTimeString(
|
DateUtils.getRelativeDateTimeString(
|
||||||
context,
|
context,
|
||||||
it.toEpochMilli(),
|
it.toEpochMilli(),
|
||||||
DateUtils.MINUTE_IN_MILLIS,
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
DateUtils.WEEK_IN_MILLIS,
|
DateUtils.WEEK_IN_MILLIS,
|
||||||
0,
|
0,
|
||||||
)
|
),
|
||||||
} ?: getString(R.string.never),
|
)
|
||||||
)
|
}
|
||||||
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
if (item.lastResult == TrackEntity.RESULT_FAILED) {
|
||||||
append(" - ")
|
append(" - ")
|
||||||
bold {
|
bold {
|
||||||
color(context.getThemeColor(appcompatR.attr.colorError, Color.RED)) {
|
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
|
||||||
append(item.lastError ?: getString(R.string.error))
|
append(getString(R.string.error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ data class TrackDebugItem(
|
|||||||
val lastCheckTime: Instant?,
|
val lastCheckTime: Instant?,
|
||||||
val lastChapterDate: Instant?,
|
val lastChapterDate: Instant?,
|
||||||
val lastResult: Int,
|
val lastResult: Int,
|
||||||
val lastError: String?,
|
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
@@ -3,55 +3,55 @@ package org.koitharu.kotatsu.tracker.ui.debug
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
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.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
|
|
||||||
import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding
|
import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnListItemClickListener<TrackDebugItem> {
|
class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnListItemClickListener<TrackDebugItem> {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
private val viewModel by viewModels<TrackerDebugViewModel>()
|
private val viewModel by viewModels<TrackerDebugViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater))
|
setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater))
|
||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
|
||||||
.addDelegate(ListItemType.FEED, trackDebugAD(this))
|
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
|
||||||
with(viewBinding.recyclerView) {
|
with(viewBinding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
|
||||||
adapter = tracksAdapter
|
adapter = tracksAdapter
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||||
}
|
}
|
||||||
viewModel.content.observe(this, tracksAdapter)
|
viewModel.content.observe(this, tracksAdapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
val barsInsets = insets.systemBarsInsets
|
val rv = viewBinding.recyclerView
|
||||||
viewBinding.recyclerView.updatePadding(
|
rv.updatePadding(
|
||||||
left = barsInsets.left,
|
left = insets.left + rv.paddingTop,
|
||||||
right = barsInsets.right,
|
right = insets.right + rv.paddingTop,
|
||||||
bottom = barsInsets.bottom,
|
bottom = insets.bottom,
|
||||||
)
|
)
|
||||||
viewBinding.appbar.updatePadding(
|
viewBinding.toolbar.updatePadding(
|
||||||
left = barsInsets.left,
|
left = insets.left,
|
||||||
right = barsInsets.right,
|
right = insets.right,
|
||||||
top = barsInsets.top,
|
|
||||||
)
|
)
|
||||||
return insets.consumeAllSystemBarsInsets()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: TrackDebugItem, view: View) {
|
override fun onItemClick(item: TrackDebugItem, view: View) {
|
||||||
router.openDetails(item.manga)
|
startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,23 +16,21 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TrackerDebugViewModel @Inject constructor(
|
class TrackerDebugViewModel @Inject constructor(
|
||||||
db: MangaDatabase
|
private val db: MangaDatabase
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val content = db.getTracksDao().observeAll()
|
val content = db.getTracksDao().observeAll()
|
||||||
.map { it.toUiList() }
|
.map { it.toUiList() }
|
||||||
.withErrorHandling()
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
|
||||||
TrackDebugItem(
|
TrackDebugItem(
|
||||||
manga = it.manga.toManga(emptySet(), null),
|
manga = it.manga.toManga(emptySet()),
|
||||||
lastChapterId = it.track.lastChapterId,
|
lastChapterId = it.track.lastChapterId,
|
||||||
newChapters = it.track.newChapters,
|
newChapters = it.track.newChapters,
|
||||||
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
|
||||||
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
|
||||||
lastResult = it.track.lastResult,
|
lastResult = it.track.lastResult,
|
||||||
lastError = it.track.lastError,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#000"
|
|
||||||
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
android:id="@+id/appbar"
|
android:id="@+id/appbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fitsSystemWindows="false">
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
android:id="@+id/collapsingToolbarLayout"
|
android:id="@+id/collapsingToolbarLayout"
|
||||||
@@ -4,17 +4,19 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="72dp"
|
||||||
android:background="@drawable/list_selector"
|
android:background="@drawable/list_selector"
|
||||||
android:clipChildren="false"
|
android:clipChildren="false">
|
||||||
android:minHeight="72dp">
|
|
||||||
|
|
||||||
<org.koitharu.kotatsu.image.ui.CoverImageView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/imageView_cover"
|
android:id="@+id/imageView_cover"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
|
||||||
@@ -40,14 +43,14 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="2dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:paddingBottom="16dp"
|
android:maxLines="1"
|
||||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
app:layout_constraintTop_toBottomOf="@+id/textView_title"
|
||||||
tools:text="@tools:sample/lorem[2]" />
|
tools:text="@tools:sample/lorem/random" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
21
app/src/debug/res/menu/opt_settings.xml
Normal file
21
app/src/debug/res/menu/opt_settings.xml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@id/action_leaks"
|
||||||
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@id/action_tracker"
|
||||||
|
android:title="@string/check_for_new_chapters"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@id/action_works"
|
||||||
|
android:title="Works"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
<string name="app_name" translatable="false">Kotatsu Dev</string>
|
||||||
<string name="strict_mode">Strict mode</string>
|
</resources>
|
||||||
</resources>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.preference.PreferenceScreen
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
|
|
||||||
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
|
||||||
android:key="leak_canary"
|
|
||||||
android:persistent="false"
|
|
||||||
android:title="LeakCanary" />
|
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:key="work_inspector"
|
|
||||||
android:persistent="false"
|
|
||||||
android:title="@string/wi_lib_name" />
|
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:key="test_parser"
|
|
||||||
android:persistent="false"
|
|
||||||
android:title="@string/test_parser"
|
|
||||||
app:allowDividerAbove="true" />
|
|
||||||
|
|
||||||
|
|
||||||
</androidx.preference.PreferenceScreen>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.preference.PreferenceScreen
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<PreferenceScreen
|
|
||||||
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
|
|
||||||
android:icon="@drawable/ic_debug"
|
|
||||||
android:key="debug"
|
|
||||||
android:title="@string/debug" />
|
|
||||||
|
|
||||||
</androidx.preference.PreferenceScreen>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,77 +3,83 @@ package org.koitharu.kotatsu.alternatives.domain
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MAX_PARALLELISM = 4
|
private const val MAX_PARALLELISM = 4
|
||||||
|
private const val MATCH_THRESHOLD = 0.2f
|
||||||
|
|
||||||
class AlternativesUseCase @Inject constructor(
|
class AlternativesUseCase @Inject constructor(
|
||||||
private val sourcesRepository: MangaSourcesRepository,
|
private val sourcesRepository: MangaSourcesRepository,
|
||||||
private val searchHelperFactory: SearchV2Helper.Factory,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
|
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||||
val sources = getSources(manga.source, throughDisabledSources)
|
val sources = getSources(manga.source)
|
||||||
if (sources.isEmpty()) {
|
if (sources.isEmpty()) {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
}
|
}
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
return channelFlow {
|
return channelFlow {
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
|
val repository = mangaRepositoryFactory.create(source)
|
||||||
|
if (!repository.isSearchSupported) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
val searchHelper = searchHelperFactory.create(source)
|
|
||||||
val list = runCatchingCancellable {
|
val list = runCatchingCancellable {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrDefault(emptyList())
|
||||||
list?.forEach { m ->
|
for (item in list) {
|
||||||
if (m.id != manga.id) {
|
if (item.matches(manga)) {
|
||||||
launch {
|
send(item)
|
||||||
val details = runCatchingCancellable {
|
|
||||||
mangaRepositoryFactory.create(m.source).getDetails(m)
|
|
||||||
}.getOrDefault(m)
|
|
||||||
send(details)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.map {
|
||||||
|
runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||||
|
}.getOrDefault(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
|
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||||
sourcesRepository.getDisabledSources()
|
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
||||||
} else {
|
result.addAll(sourcesRepository.getEnabledSources())
|
||||||
sourcesRepository.getEnabledSources()
|
result.sortByDescending { it.priority(ref) }
|
||||||
}.sortedByDescending { it.priority(ref) }
|
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Manga.matches(ref: Manga): Boolean {
|
||||||
|
return matchesTitles(title, ref.title) ||
|
||||||
|
matchesTitles(title, ref.altTitle) ||
|
||||||
|
matchesTitles(altTitle, ref.title) ||
|
||||||
|
matchesTitles(altTitle, ref.altTitle)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||||
|
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||||
|
}
|
||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
var res = 0
|
var res = 0
|
||||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
if (locale == ref.locale) res += 2
|
||||||
if (locale == ref.locale) {
|
if (contentType == ref.contentType) res++
|
||||||
res += 4
|
|
||||||
} else if (locale.toLocale() == Locale.getDefault()) {
|
|
||||||
res += 2
|
|
||||||
}
|
|
||||||
if (contentType == ref.contentType) {
|
|
||||||
res++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 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()
|
|
||||||
}
|
|
||||||
@@ -7,29 +7,22 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
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.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
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 org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MigrateUseCase
|
class MigrateUseCase @Inject constructor(
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val database: MangaDatabase,
|
private val database: MangaDatabase,
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
|
||||||
oldManga: Manga,
|
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||||
newManga: Manga,
|
|
||||||
) {
|
|
||||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
@@ -42,7 +35,7 @@ constructor(
|
|||||||
} else {
|
} else {
|
||||||
newManga
|
newManga
|
||||||
}
|
}
|
||||||
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
mangaDataRepository.storeManga(newDetails)
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
val favoritesDao = database.getFavouritesDao()
|
val favoritesDao = database.getFavouritesDao()
|
||||||
@@ -50,69 +43,36 @@ constructor(
|
|||||||
if (oldFavourites.isNotEmpty()) {
|
if (oldFavourites.isNotEmpty()) {
|
||||||
favoritesDao.delete(oldManga.id)
|
favoritesDao.delete(oldManga.id)
|
||||||
for (f in oldFavourites) {
|
for (f in oldFavourites) {
|
||||||
val e =
|
val e = f.copy(
|
||||||
f.copy(
|
mangaId = newManga.id,
|
||||||
mangaId = newManga.id,
|
)
|
||||||
)
|
|
||||||
favoritesDao.upsert(e)
|
favoritesDao.upsert(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// replace history
|
// replace history
|
||||||
val historyDao = database.getHistoryDao()
|
val historyDao = database.getHistoryDao()
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
val newHistory =
|
if (oldHistory != null) {
|
||||||
if (oldHistory != null) {
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
historyDao.delete(oldDetails.id)
|
||||||
historyDao.delete(oldDetails.id)
|
historyDao.upsert(newHistory)
|
||||||
historyDao.upsert(newHistory)
|
}
|
||||||
newHistory
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
// track
|
// track
|
||||||
val tracksDao = database.getTracksDao()
|
val tracksDao = database.getTracksDao()
|
||||||
val oldTrack = tracksDao.find(oldDetails.id)
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
if (oldTrack != null) {
|
if (oldTrack != null) {
|
||||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
val newTrack =
|
val newTrack = TrackEntity(
|
||||||
TrackEntity(
|
mangaId = newDetails.id,
|
||||||
mangaId = newDetails.id,
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
lastChapterId = lastChapter?.id ?: 0L,
|
newChapters = 0,
|
||||||
newChapters = 0,
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
lastCheckTime = System.currentTimeMillis(),
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
)
|
||||||
lastError = null,
|
|
||||||
)
|
|
||||||
tracksDao.delete(oldDetails.id)
|
tracksDao.delete(oldDetails.id)
|
||||||
tracksDao.upsert(newTrack)
|
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)
|
progressUpdateUseCase(newManga)
|
||||||
}
|
}
|
||||||
@@ -125,53 +85,48 @@ constructor(
|
|||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
val branch = newManga.getPreferredBranch(null)
|
val branch = newManga.getPreferredBranch(null)
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
val currentChapter =
|
val currentChapter = if (history.percent in 0f..1f) {
|
||||||
if (history.percent in 0f..1f) {
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
} else {
|
||||||
} else {
|
chapters.first()
|
||||||
chapters.first()
|
}
|
||||||
}
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = history.updatedAt,
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = currentChapter.id,
|
chapterId = currentChapter.id,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
percent = history.percent,
|
percent = history.percent,
|
||||||
deletedAt = 0,
|
deletedAt = 0,
|
||||||
chaptersCount = chapters.count { it.branch == currentChapter.branch },
|
chaptersCount = chapters.size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
index =
|
index = if (history.percent in 0f..1f) {
|
||||||
if (history.percent in 0f..1f) {
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
} else {
|
||||||
} else {
|
0
|
||||||
0
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
val newBranch =
|
val newBranch = if (newChapters.containsKey(branch)) {
|
||||||
if (newChapters.containsKey(branch)) {
|
branch
|
||||||
branch
|
} else {
|
||||||
} else {
|
newManga.getPreferredBranch(null)
|
||||||
newManga.getPreferredBranch(null)
|
}
|
||||||
}
|
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||||
val newChapterId =
|
val oldChapter = oldChapters[index]
|
||||||
checkNotNull(newChapters[newBranch])
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
.let {
|
}.id
|
||||||
val oldChapter = oldChapters[index]
|
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = history.updatedAt,
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = newChapterId,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
@@ -181,13 +136,11 @@ constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(
|
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||||
volume: Int,
|
return if (number <= 0f) {
|
||||||
number: Float,
|
|
||||||
): MangaChapter? =
|
|
||||||
if (number <= 0f) {
|
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,22 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil3.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil3.request.allowRgb565
|
import coil.transform.CircleCropTransformation
|
||||||
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 com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
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.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
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.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
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.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
@@ -48,22 +42,10 @@ fun alternativeAD(
|
|||||||
binding.chipSource.setOnClickListener(clickListener)
|
binding.chipSource.setOnClickListener(clickListener)
|
||||||
|
|
||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
binding.textViewTitle.text = item.mangaModel.title
|
binding.textViewTitle.text = item.manga.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 {
|
binding.textViewSubtitle.text = buildSpannedString {
|
||||||
if (item.chaptersCount > 0) {
|
if (item.chaptersCount > 0) {
|
||||||
append(
|
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||||
context.resources.getQuantityStringSafe(
|
|
||||||
R.plurals.chapters,
|
|
||||||
item.chaptersCount,
|
|
||||||
item.chaptersCount,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
append(context.getString(R.string.no_chapters))
|
append(context.getString(R.string.no_chapters))
|
||||||
}
|
}
|
||||||
@@ -79,12 +61,9 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.progressView.setProgress(
|
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
item.mangaModel.progress,
|
|
||||||
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
|
|
||||||
)
|
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.getTitle(chip.context)
|
chip.text = item.manga.source.title
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(item.manga.source.faviconUri())
|
.data(item.manga.source.faviconUri())
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
@@ -94,11 +73,19 @@ fun alternativeAD(
|
|||||||
.placeholder(R.drawable.ic_web)
|
.placeholder(R.drawable.ic_web)
|
||||||
.fallback(R.drawable.ic_web)
|
.fallback(R.drawable.ic_web)
|
||||||
.error(R.drawable.ic_web)
|
.error(R.drawable.ic_web)
|
||||||
.mangaSourceExtra(item.manga.source)
|
.source(item.manga.source)
|
||||||
.transformations(RoundedCornersTransformation(context.resources.getDimension(R.dimen.chip_icon_corner)))
|
.transformations(CircleCropTransformation())
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||||
|
size(CoverSizeResolver(binding.imageViewCover))
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
transformations(TrimTransformation())
|
||||||
|
allowRgb565(true)
|
||||||
|
tag(item.manga)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil3.ImageLoader
|
import coil.ImageLoader
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
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.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
|
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
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.databinding.ActivityAlternativesBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
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.TypedListSpacingDecoration
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||||
ListStateHolderListener,
|
|
||||||
OnListItemClickListener<MangaAlternativeModel> {
|
OnListItemClickListener<MangaAlternativeModel> {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -51,10 +51,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
val listAdapter = BaseListAdapter<ListModel>()
|
val listAdapter = BaseListAdapter<ListModel>()
|
||||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
|
||||||
with(viewBinding.recyclerView) {
|
with(viewBinding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||||
@@ -62,63 +61,54 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
viewModel.list.observe(this, listAdapter)
|
viewModel.content.observe(this, listAdapter)
|
||||||
viewModel.onMigrated.observeEvent(this) {
|
viewModel.onMigrated.observeEvent(this) {
|
||||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||||
router.openDetails(it)
|
startActivity(DetailsActivity.newIntent(this, it))
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onApplyWindowInsets(
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
v: View,
|
viewBinding.root.updatePadding(
|
||||||
insets: WindowInsetsCompat
|
left = insets.left,
|
||||||
): WindowInsetsCompat {
|
right = insets.right,
|
||||||
val barsInsets = insets.systemBarsInsets
|
)
|
||||||
viewBinding.recyclerView.updatePadding(
|
viewBinding.recyclerView.updatePadding(
|
||||||
left = barsInsets.left,
|
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||||
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) {
|
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||||
when (view.id) {
|
when (view.id) {
|
||||||
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)
|
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||||
R.id.button_migrate -> confirmMigration(item.manga)
|
R.id.button_migrate -> confirmMigration(item.manga)
|
||||||
else -> router.openDetails(item.manga)
|
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = viewModel.retry()
|
|
||||||
|
|
||||||
override fun onEmptyActionClick() = Unit
|
|
||||||
|
|
||||||
override fun onFooterButtonClick() = viewModel.continueSearch()
|
|
||||||
|
|
||||||
private fun confirmMigration(target: Manga) {
|
private fun confirmMigration(target: Manga) {
|
||||||
buildAlertDialog(this, isCentered = true) {
|
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||||
setIcon(R.drawable.ic_replace)
|
.setIcon(R.drawable.ic_replace)
|
||||||
setTitle(R.string.manga_migration)
|
.setTitle(R.string.manga_migration)
|
||||||
setMessage(
|
.setMessage(
|
||||||
getString(
|
getString(
|
||||||
R.string.migrate_confirmation,
|
R.string.migrate_confirmation,
|
||||||
viewModel.manga.title,
|
viewModel.manga.title,
|
||||||
viewModel.manga.source.getTitle(context),
|
viewModel.manga.source.title,
|
||||||
target.title,
|
target.title,
|
||||||
target.source.getTitle(context),
|
target.source.title,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
setPositiveButton(R.string.migrate) { _, _ ->
|
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||||
viewModel.migrate(target)
|
viewModel.migrate(target)
|
||||||
}
|
}.show()
|
||||||
}.show()
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||||
|
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,31 @@
|
|||||||
package org.koitharu.kotatsu.alternatives.ui
|
package org.koitharu.kotatsu.alternatives.ui
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.onEmpty
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.runningFold
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
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.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
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.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||||
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
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.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -43,67 +34,45 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val alternativesUseCase: AlternativesUseCase,
|
private val alternativesUseCase: AlternativesUseCase,
|
||||||
private val migrateUseCase: MigrateUseCase,
|
private val migrateUseCase: MigrateUseCase,
|
||||||
private val mangaListMapper: MangaListMapper,
|
private val extraProvider: ListExtraProvider,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
|
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.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 onMigrated = MutableEventFlow<Manga>()
|
||||||
|
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||||
val list: StateFlow<List<ListModel>> = combine(
|
private var migrationJob: Job? = null
|
||||||
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 {
|
init {
|
||||||
doSearch(throughDisabledSources = false)
|
launchJob(Dispatchers.Default) {
|
||||||
}
|
val ref = runCatchingCancellable {
|
||||||
|
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||||
fun retry() {
|
}.getOrDefault(manga)
|
||||||
searchJob?.cancel()
|
val refCount = ref.chaptersCount()
|
||||||
results.value = emptyList()
|
alternativesUseCase(ref)
|
||||||
includeDisabledSources.value = false
|
.map {
|
||||||
doSearch(throughDisabledSources = false)
|
MangaAlternativeModel(
|
||||||
}
|
manga = it,
|
||||||
|
progress = extraProvider.getProgress(it.id),
|
||||||
fun continueSearch() {
|
referenceChapters = refCount,
|
||||||
if (includeDisabledSources.value) {
|
)
|
||||||
return
|
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||||
}
|
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||||
val prevJob = searchJob
|
}.onEmpty {
|
||||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
emit(
|
||||||
includeDisabledSources.value = true
|
listOf(
|
||||||
prevJob?.join()
|
EmptyState(
|
||||||
doSearch(throughDisabledSources = true)
|
icon = R.drawable.ic_empty_common,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_search_holder_secondary,
|
||||||
|
actionStringRes = 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.collect {
|
||||||
|
content.value = it
|
||||||
|
}
|
||||||
|
content.value = content.value.filterNot { it is LoadingFooter }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,20 +86,13 @@ class AlternativesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun doSearch(throughDisabledSources: Boolean) {
|
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||||
val prevJob = searchJob
|
return list.map {
|
||||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
MangaAlternativeModel(
|
||||||
prevJob?.cancelAndJoin()
|
manga = it,
|
||||||
val ref = mangaDetails.getOrDefault(manga)
|
progress = extraProvider.getProgress(it.id),
|
||||||
val refCount = ref.chaptersCount()
|
referenceChapters = refCount,
|
||||||
alternativesUseCase.invoke(ref, throughDisabledSources)
|
)
|
||||||
.collect {
|
|
||||||
val model = MangaAlternativeModel(
|
|
||||||
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
|
|
||||||
referenceChapters = refCount,
|
|
||||||
)
|
|
||||||
results.append(model)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,203 +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.alternatives.domain.AutoFixUseCase.NoAlternativesException
|
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
|
||||||
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.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(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val notification = buildNotification(startId, result)
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
|
||||||
if (checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
private fun startForeground(jobContext: IntentJobContext) {
|
|
||||||
val title = 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(this, 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,
|
|
||||||
getString(android.R.string.cancel),
|
|
||||||
jobContext.getCancelIntent(),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
jobContext.setForeground(
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
|
||||||
val notification = NotificationCompat.Builder(this, 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(this)
|
|
||||||
.data(replacement.coverUrl)
|
|
||||||
.mangaSourceExtra(replacement.source)
|
|
||||||
.build(),
|
|
||||||
).toBitmapOrNull(),
|
|
||||||
)
|
|
||||||
notification.setSubText(replacement.title)
|
|
||||||
val intent = AppRouter.detailsIntent(this, replacement)
|
|
||||||
notification.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
this,
|
|
||||||
replacement.id.toInt(),
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
).setVisibility(
|
|
||||||
if (replacement.isNsfw()) {
|
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
|
||||||
} else {
|
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
|
||||||
},
|
|
||||||
)
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.fixed))
|
|
||||||
.setContentText(
|
|
||||||
getString(
|
|
||||||
R.string.manga_replaced,
|
|
||||||
seed.title,
|
|
||||||
seed.source.getTitle(this),
|
|
||||||
replacement.title,
|
|
||||||
replacement.source.getTitle(this),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
} else {
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.fixing_manga))
|
|
||||||
.setContentText(getString(R.string.no_fix_required, seed.title))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.error_occurred))
|
|
||||||
.setContentText(
|
|
||||||
if (error is NoAlternativesException) {
|
|
||||||
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
|
||||||
} else {
|
|
||||||
error.getDisplayMessage(resources)
|
|
||||||
},
|
|
||||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
ErrorReporterReceiver.getNotificationAction(
|
|
||||||
context = this,
|
|
||||||
e = error,
|
|
||||||
notificationId = startId,
|
|
||||||
notificationTag = TAG,
|
|
||||||
)?.let { action ->
|
|
||||||
notification.addAction(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,14 @@ package org.koitharu.kotatsu.alternatives.ui
|
|||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
data class MangaAlternativeModel(
|
data class MangaAlternativeModel(
|
||||||
val mangaModel: MangaGridModel,
|
val manga: Manga,
|
||||||
|
val progress: Float,
|
||||||
private val referenceChapters: Int,
|
private val referenceChapters: Int,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val manga: Manga
|
|
||||||
get() = mangaModel.manga
|
|
||||||
|
|
||||||
val chaptersCount = manga.chaptersCount()
|
val chaptersCount = manga.chaptersCount()
|
||||||
|
|
||||||
val chaptersDiff: Int
|
val chaptersDiff: Int
|
||||||
@@ -21,10 +18,4 @@ data class MangaAlternativeModel(
|
|||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import dagger.Reusable
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
import kotlinx.coroutines.flow.asFlow
|
|
||||||
import kotlinx.coroutines.flow.collectIndexed
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.serialization.DeserializationStrategy
|
|
||||||
import kotlinx.serialization.SerializationStrategy
|
|
||||||
import kotlinx.serialization.json.DecodeSequenceMode
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.decodeToSequence
|
|
||||||
import kotlinx.serialization.json.encodeToStream
|
|
||||||
import kotlinx.serialization.serializer
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
|
||||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
|
||||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class BackupRepository @Inject constructor(
|
|
||||||
private val database: MangaDatabase,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
private val tapGridSettings: TapGridSettings,
|
|
||||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
|
||||||
private val savedFiltersRepository: SavedFiltersRepository,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val json = Json {
|
|
||||||
allowSpecialFloatingPointValues = true
|
|
||||||
coerceInputValues = true
|
|
||||||
encodeDefaults = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
useAlternativeNames = false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createBackup(
|
|
||||||
output: ZipOutputStream,
|
|
||||||
progress: FlowCollector<Progress>?,
|
|
||||||
) {
|
|
||||||
progress?.emit(Progress.INDETERMINATE)
|
|
||||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
|
||||||
for (section in BackupSection.entries) {
|
|
||||||
when (section) {
|
|
||||||
BackupSection.INDEX -> output.writeJsonArray(
|
|
||||||
section = BackupSection.INDEX,
|
|
||||||
data = flowOf(BackupIndex()),
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.HISTORY -> output.writeJsonArray(
|
|
||||||
section = BackupSection.HISTORY,
|
|
||||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
|
||||||
section = BackupSection.CATEGORIES,
|
|
||||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
|
||||||
section = BackupSection.FAVOURITES,
|
|
||||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.SETTINGS -> output.writeString(
|
|
||||||
section = BackupSection.SETTINGS,
|
|
||||||
data = dumpSettings(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
|
||||||
section = BackupSection.SETTINGS_READER_GRID,
|
|
||||||
data = dumpReaderGridSettings(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
|
||||||
section = BackupSection.BOOKMARKS,
|
|
||||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.SOURCES -> output.writeJsonArray(
|
|
||||||
section = BackupSection.SOURCES,
|
|
||||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.SCROBBLING -> output.writeJsonArray(
|
|
||||||
section = BackupSection.SCROBBLING,
|
|
||||||
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.STATS -> output.writeJsonArray(
|
|
||||||
section = BackupSection.STATS,
|
|
||||||
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
BackupSection.SAVED_FILTERS -> {
|
|
||||||
val sources = mangaSourcesRepository.getEnabledSources()
|
|
||||||
val filters = sources.flatMap { source ->
|
|
||||||
savedFiltersRepository.getAll(source)
|
|
||||||
}
|
|
||||||
output.writeJsonArray(
|
|
||||||
section = BackupSection.SAVED_FILTERS,
|
|
||||||
data = filters.asFlow(),
|
|
||||||
serializer = serializer(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
commonProgress++
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBackup(
|
|
||||||
input: ZipInputStream,
|
|
||||||
sections: Set<BackupSection>,
|
|
||||||
progress: FlowCollector<Progress>?,
|
|
||||||
): CompositeResult {
|
|
||||||
progress?.emit(Progress.INDETERMINATE)
|
|
||||||
var commonProgress = Progress(0, sections.size)
|
|
||||||
var entry = input.nextEntry
|
|
||||||
var result = CompositeResult.EMPTY
|
|
||||||
while (entry != null) {
|
|
||||||
val section = BackupSection.of(entry)
|
|
||||||
if (section in sections) {
|
|
||||||
result += when (section) {
|
|
||||||
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
|
||||||
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
|
||||||
upsertManga(it.manga)
|
|
||||||
getHistoryDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
|
||||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
|
||||||
upsertManga(it.manga)
|
|
||||||
getFavouritesDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.SETTINGS -> input.readMap().let {
|
|
||||||
settings.upsertAll(it)
|
|
||||||
CompositeResult.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
|
||||||
tapGridSettings.upsertAll(it)
|
|
||||||
CompositeResult.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
|
||||||
upsertManga(it.manga)
|
|
||||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
|
||||||
getSourcesDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
|
||||||
getScrobblingDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
|
||||||
getStatsDao().upsert(it.toEntity())
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
|
|
||||||
.restoreWithoutTransaction {
|
|
||||||
savedFiltersRepository.save(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
null -> CompositeResult.EMPTY // skip unknown entries
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
commonProgress++
|
|
||||||
}
|
|
||||||
input.closeEntry()
|
|
||||||
entry = input.nextEntry
|
|
||||||
}
|
|
||||||
progress?.emit(commonProgress)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
|
||||||
section: BackupSection,
|
|
||||||
data: Flow<T>,
|
|
||||||
serializer: SerializationStrategy<T>,
|
|
||||||
) {
|
|
||||||
data.onStart {
|
|
||||||
putNextEntry(ZipEntry(section.entryName))
|
|
||||||
write("[")
|
|
||||||
}.onCompletion { error ->
|
|
||||||
if (error == null) {
|
|
||||||
write("]")
|
|
||||||
}
|
|
||||||
closeEntry()
|
|
||||||
flush()
|
|
||||||
}.collectIndexed { index, value ->
|
|
||||||
if (index > 0) {
|
|
||||||
write(",")
|
|
||||||
}
|
|
||||||
json.encodeToStream(serializer, value, this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> InputStream.readJsonArray(
|
|
||||||
serializer: DeserializationStrategy<T>,
|
|
||||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
|
||||||
|
|
||||||
private fun InputStream.readMap(): Map<String, Any?> {
|
|
||||||
val jo = JSONArray(readString()).getJSONObject(0)
|
|
||||||
val map = ArrayMap<String, Any?>(jo.length())
|
|
||||||
val keys = jo.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
map[key] = jo.get(key)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ZipOutputStream.writeString(
|
|
||||||
section: BackupSection,
|
|
||||||
data: String,
|
|
||||||
) {
|
|
||||||
putNextEntry(ZipEntry(section.entryName))
|
|
||||||
try {
|
|
||||||
write("[")
|
|
||||||
write(data)
|
|
||||||
write("]")
|
|
||||||
} finally {
|
|
||||||
closeEntry()
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
|
||||||
|
|
||||||
private fun InputStream.readString(): String = readBytes().decodeToString()
|
|
||||||
|
|
||||||
private fun dumpSettings(): String {
|
|
||||||
val map = settings.getAllValues().toMutableMap()
|
|
||||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
|
||||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
|
||||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
|
||||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
|
||||||
return JSONObject(map).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dumpReaderGridSettings(): String {
|
|
||||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
|
||||||
val tags = manga.tags.map { it.toEntity() }
|
|
||||||
getTagsDao().upsert(tags)
|
|
||||||
getMangaDao().upsert(manga.toEntity(), tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
|
||||||
return fold(CompositeResult.EMPTY) { result, item ->
|
|
||||||
result + runCatchingCancellable {
|
|
||||||
database.withTransaction {
|
|
||||||
database.block(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
|
|
||||||
return fold(CompositeResult.EMPTY) { result, item ->
|
|
||||||
result + runCatchingCancellable {
|
|
||||||
block(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class BackupIndex(
|
|
||||||
@SerialName("app_id") val appId: String,
|
|
||||||
@SerialName("app_version") val appVersion: Int,
|
|
||||||
@SerialName("created_at") val createdAt: Long,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor() : this(
|
|
||||||
appId = BuildConfig.APPLICATION_ID,
|
|
||||||
appVersion = BuildConfig.VERSION_CODE,
|
|
||||||
createdAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class BookmarkBackup(
|
|
||||||
@SerialName("manga") val manga: MangaBackup,
|
|
||||||
@SerialName("tags") val tags: Set<TagBackup>,
|
|
||||||
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class Bookmark(
|
|
||||||
@SerialName("manga_id") val mangaId: Long,
|
|
||||||
@SerialName("page_id") val pageId: Long,
|
|
||||||
@SerialName("chapter_id") val chapterId: Long,
|
|
||||||
@SerialName("page") val page: Int,
|
|
||||||
@SerialName("scroll") val scroll: Int,
|
|
||||||
@SerialName("image_url") val imageUrl: String,
|
|
||||||
@SerialName("created_at") val createdAt: Long,
|
|
||||||
@SerialName("percent") val percent: Float,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun toEntity() = BookmarkEntity(
|
|
||||||
mangaId = mangaId,
|
|
||||||
pageId = pageId,
|
|
||||||
chapterId = chapterId,
|
|
||||||
page = page,
|
|
||||||
scroll = scroll,
|
|
||||||
imageUrl = imageUrl,
|
|
||||||
createdAt = createdAt,
|
|
||||||
percent = percent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(manga: MangaWithTags, entities: List<BookmarkEntity>) : this(
|
|
||||||
manga = MangaBackup(manga.copy(tags = emptyList())),
|
|
||||||
tags = manga.tags.mapToSet { TagBackup(it) },
|
|
||||||
bookmarks = entities.map {
|
|
||||||
Bookmark(
|
|
||||||
mangaId = it.mangaId,
|
|
||||||
pageId = it.pageId,
|
|
||||||
chapterId = it.chapterId,
|
|
||||||
page = it.page,
|
|
||||||
scroll = it.scroll,
|
|
||||||
imageUrl = it.imageUrl,
|
|
||||||
createdAt = it.createdAt,
|
|
||||||
percent = it.percent,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class CategoryBackup(
|
|
||||||
@SerialName("category_id") val categoryId: Int,
|
|
||||||
@SerialName("created_at") val createdAt: Long,
|
|
||||||
@SerialName("sort_key") val sortKey: Int,
|
|
||||||
@SerialName("title") val title: String,
|
|
||||||
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
|
|
||||||
@SerialName("track") val track: Boolean = true,
|
|
||||||
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: FavouriteCategoryEntity) : this(
|
|
||||||
categoryId = entity.categoryId,
|
|
||||||
createdAt = entity.createdAt,
|
|
||||||
sortKey = entity.sortKey,
|
|
||||||
title = entity.title,
|
|
||||||
order = entity.order,
|
|
||||||
track = entity.track,
|
|
||||||
isVisibleInLibrary = entity.isVisibleInLibrary,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = FavouriteCategoryEntity(
|
|
||||||
categoryId = categoryId,
|
|
||||||
createdAt = createdAt,
|
|
||||||
sortKey = sortKey,
|
|
||||||
title = title,
|
|
||||||
order = order,
|
|
||||||
track = track,
|
|
||||||
isVisibleInLibrary = isVisibleInLibrary,
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class FavouriteBackup(
|
|
||||||
@SerialName("manga_id") val mangaId: Long,
|
|
||||||
@SerialName("category_id") val categoryId: Long,
|
|
||||||
@SerialName("sort_key") val sortKey: Int = 0,
|
|
||||||
@SerialName("pinned") val isPinned: Boolean = false,
|
|
||||||
@SerialName("created_at") val createdAt: Long,
|
|
||||||
@SerialName("manga") val manga: MangaBackup,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: FavouriteManga) : this(
|
|
||||||
mangaId = entity.manga.id,
|
|
||||||
categoryId = entity.favourite.categoryId,
|
|
||||||
sortKey = entity.favourite.sortKey,
|
|
||||||
isPinned = entity.favourite.isPinned,
|
|
||||||
createdAt = entity.favourite.createdAt,
|
|
||||||
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = FavouriteEntity(
|
|
||||||
mangaId = mangaId,
|
|
||||||
categoryId = categoryId,
|
|
||||||
sortKey = sortKey,
|
|
||||||
isPinned = isPinned,
|
|
||||||
createdAt = createdAt,
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class HistoryBackup(
|
|
||||||
@SerialName("manga_id") val mangaId: Long,
|
|
||||||
@SerialName("created_at") val createdAt: Long,
|
|
||||||
@SerialName("updated_at") val updatedAt: Long,
|
|
||||||
@SerialName("chapter_id") val chapterId: Long,
|
|
||||||
@SerialName("page") val page: Int,
|
|
||||||
@SerialName("scroll") val scroll: Float,
|
|
||||||
@SerialName("percent") val percent: Float = PROGRESS_NONE,
|
|
||||||
@SerialName("chapters") val chaptersCount: Int = 0,
|
|
||||||
@SerialName("manga") val manga: MangaBackup,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: HistoryWithManga) : this(
|
|
||||||
mangaId = entity.manga.id,
|
|
||||||
createdAt = entity.history.createdAt,
|
|
||||||
updatedAt = entity.history.updatedAt,
|
|
||||||
chapterId = entity.history.chapterId,
|
|
||||||
page = entity.history.page,
|
|
||||||
scroll = entity.history.scroll,
|
|
||||||
percent = entity.history.percent,
|
|
||||||
chaptersCount = entity.history.chaptersCount,
|
|
||||||
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = HistoryEntity(
|
|
||||||
mangaId = mangaId,
|
|
||||||
createdAt = createdAt,
|
|
||||||
updatedAt = updatedAt,
|
|
||||||
chapterId = chapterId,
|
|
||||||
page = page,
|
|
||||||
scroll = scroll,
|
|
||||||
percent = percent,
|
|
||||||
deletedAt = 0L,
|
|
||||||
chaptersCount = chaptersCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
|
||||||
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class MangaBackup(
|
|
||||||
@SerialName("id") val id: Long,
|
|
||||||
@SerialName("title") val title: String,
|
|
||||||
@SerialName("alt_title") val altTitles: String? = null,
|
|
||||||
@SerialName("url") val url: String,
|
|
||||||
@SerialName("public_url") val publicUrl: String,
|
|
||||||
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
|
|
||||||
@SerialName("nsfw") val isNsfw: Boolean = false,
|
|
||||||
@SerialName("content_rating") val contentRating: String? = null,
|
|
||||||
@SerialName("cover_url") val coverUrl: String,
|
|
||||||
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
|
|
||||||
@SerialName("state") val state: String? = null,
|
|
||||||
@SerialName("author") val authors: String? = null,
|
|
||||||
@SerialName("source") val source: String,
|
|
||||||
@SerialName("tags") val tags: Set<TagBackup> = emptySet(),
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: MangaWithTags) : this(
|
|
||||||
id = entity.manga.id,
|
|
||||||
title = entity.manga.title,
|
|
||||||
altTitles = entity.manga.altTitles,
|
|
||||||
url = entity.manga.url,
|
|
||||||
publicUrl = entity.manga.publicUrl,
|
|
||||||
rating = entity.manga.rating,
|
|
||||||
isNsfw = entity.manga.isNsfw,
|
|
||||||
contentRating = entity.manga.contentRating,
|
|
||||||
coverUrl = entity.manga.coverUrl,
|
|
||||||
largeCoverUrl = entity.manga.largeCoverUrl,
|
|
||||||
state = entity.manga.state,
|
|
||||||
authors = entity.manga.authors,
|
|
||||||
source = entity.manga.source,
|
|
||||||
tags = entity.tags.mapToSet { TagBackup(it) },
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = MangaEntity(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
altTitles = altTitles,
|
|
||||||
url = url,
|
|
||||||
publicUrl = publicUrl,
|
|
||||||
rating = rating,
|
|
||||||
isNsfw = isNsfw,
|
|
||||||
contentRating = contentRating,
|
|
||||||
coverUrl = coverUrl,
|
|
||||||
largeCoverUrl = largeCoverUrl,
|
|
||||||
state = state,
|
|
||||||
authors = authors,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class ScrobblingBackup(
|
|
||||||
@SerialName("scrobbler") val scrobbler: Int,
|
|
||||||
@SerialName("id") val id: Int,
|
|
||||||
@SerialName("manga_id") val mangaId: Long,
|
|
||||||
@SerialName("target_id") val targetId: Long,
|
|
||||||
@SerialName("status") val status: String?,
|
|
||||||
@SerialName("chapter") val chapter: Int,
|
|
||||||
@SerialName("comment") val comment: String?,
|
|
||||||
@SerialName("rating") val rating: Float,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: ScrobblingEntity) : this(
|
|
||||||
scrobbler = entity.scrobbler,
|
|
||||||
id = entity.id,
|
|
||||||
mangaId = entity.mangaId,
|
|
||||||
targetId = entity.targetId,
|
|
||||||
status = entity.status,
|
|
||||||
chapter = entity.chapter,
|
|
||||||
comment = entity.comment,
|
|
||||||
rating = entity.rating,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = ScrobblingEntity(
|
|
||||||
scrobbler = scrobbler,
|
|
||||||
id = id,
|
|
||||||
mangaId = mangaId,
|
|
||||||
targetId = targetId,
|
|
||||||
status = status,
|
|
||||||
chapter = chapter,
|
|
||||||
comment = comment,
|
|
||||||
rating = rating,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class SourceBackup(
|
|
||||||
@SerialName("source") val source: String,
|
|
||||||
@SerialName("sort_key") val sortKey: Int,
|
|
||||||
@SerialName("used_at") val lastUsedAt: Long,
|
|
||||||
@SerialName("added_in") val addedIn: Int,
|
|
||||||
@SerialName("pinned") val isPinned: Boolean = false,
|
|
||||||
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: MangaSourceEntity) : this(
|
|
||||||
source = entity.source,
|
|
||||||
sortKey = entity.sortKey,
|
|
||||||
lastUsedAt = entity.lastUsedAt,
|
|
||||||
addedIn = entity.addedIn,
|
|
||||||
isPinned = entity.isPinned,
|
|
||||||
isEnabled = entity.isEnabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = MangaSourceEntity(
|
|
||||||
source = source,
|
|
||||||
isEnabled = isEnabled,
|
|
||||||
sortKey = sortKey,
|
|
||||||
addedIn = addedIn,
|
|
||||||
lastUsedAt = lastUsedAt,
|
|
||||||
isPinned = isPinned,
|
|
||||||
cfState = 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class StatisticBackup(
|
|
||||||
@SerialName("manga_id") val mangaId: Long,
|
|
||||||
@SerialName("started_at") val startedAt: Long,
|
|
||||||
@SerialName("duration") val duration: Long,
|
|
||||||
@SerialName("pages") val pages: Int,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: StatsEntity) : this(
|
|
||||||
mangaId = entity.mangaId,
|
|
||||||
startedAt = entity.startedAt,
|
|
||||||
duration = entity.duration,
|
|
||||||
pages = entity.pages,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = StatsEntity(
|
|
||||||
mangaId = mangaId,
|
|
||||||
startedAt = startedAt,
|
|
||||||
duration = duration,
|
|
||||||
pages = pages,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.data.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class TagBackup(
|
|
||||||
@SerialName("id") val id: Long,
|
|
||||||
@SerialName("title") val title: String,
|
|
||||||
@SerialName("key") val key: String,
|
|
||||||
@SerialName("source") val source: String,
|
|
||||||
@SerialName("pinned") val isPinned: Boolean = false,
|
|
||||||
) {
|
|
||||||
|
|
||||||
constructor(entity: TagEntity) : this(
|
|
||||||
id = entity.id,
|
|
||||||
title = entity.title,
|
|
||||||
key = entity.key,
|
|
||||||
source = entity.source,
|
|
||||||
isPinned = entity.isPinned,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toEntity() = TagEntity(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
key = key,
|
|
||||||
source = source,
|
|
||||||
isPinned = isPinned,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.domain
|
|
||||||
|
|
||||||
import android.app.backup.BackupAgent
|
|
||||||
import android.app.backup.BackupDataInput
|
|
||||||
import android.app.backup.BackupDataOutput
|
|
||||||
import android.app.backup.FullBackupDataOutput
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import com.google.common.io.ByteStreams
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
|
||||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileDescriptor
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
|
|
||||||
class AppBackupAgent : BackupAgent() {
|
|
||||||
|
|
||||||
override fun onBackup(
|
|
||||||
oldState: ParcelFileDescriptor?,
|
|
||||||
data: BackupDataOutput?,
|
|
||||||
newState: ParcelFileDescriptor?
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun onRestore(
|
|
||||||
data: BackupDataInput?,
|
|
||||||
appVersionCode: Int,
|
|
||||||
newState: ParcelFileDescriptor?
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
|
||||||
super.onFullBackup(data)
|
|
||||||
val file = createBackupFile(
|
|
||||||
this,
|
|
||||||
BackupRepository(
|
|
||||||
database = MangaDatabase(context = applicationContext),
|
|
||||||
settings = AppSettings(applicationContext),
|
|
||||||
tapGridSettings = TapGridSettings(applicationContext),
|
|
||||||
mangaSourcesRepository = MangaSourcesRepository(
|
|
||||||
context = applicationContext,
|
|
||||||
db = MangaDatabase(context = applicationContext),
|
|
||||||
settings = AppSettings(applicationContext),
|
|
||||||
),
|
|
||||||
savedFiltersRepository = SavedFiltersRepository(
|
|
||||||
context = applicationContext,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
fullBackupFile(file, data)
|
|
||||||
} finally {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreFile(
|
|
||||||
data: ParcelFileDescriptor,
|
|
||||||
size: Long,
|
|
||||||
destination: File?,
|
|
||||||
type: Int,
|
|
||||||
mode: Long,
|
|
||||||
mtime: Long
|
|
||||||
) {
|
|
||||||
if (destination?.name?.endsWith(".bk.zip") == true) {
|
|
||||||
restoreBackupFile(
|
|
||||||
data.fileDescriptor,
|
|
||||||
size,
|
|
||||||
BackupRepository(
|
|
||||||
database = MangaDatabase(applicationContext),
|
|
||||||
settings = AppSettings(applicationContext),
|
|
||||||
tapGridSettings = TapGridSettings(applicationContext),
|
|
||||||
mangaSourcesRepository = MangaSourcesRepository(
|
|
||||||
context = applicationContext,
|
|
||||||
db = MangaDatabase(context = applicationContext),
|
|
||||||
settings = AppSettings(applicationContext),
|
|
||||||
),
|
|
||||||
savedFiltersRepository = SavedFiltersRepository(
|
|
||||||
context = applicationContext,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
destination.delete()
|
|
||||||
} else {
|
|
||||||
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
fun createBackupFile(context: Context, repository: BackupRepository): File {
|
|
||||||
val file = BackupUtils.createTempFile(context)
|
|
||||||
ZipOutputStream(file.outputStream()).use { output ->
|
|
||||||
runBlocking {
|
|
||||||
repository.createBackup(output, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
|
||||||
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
|
||||||
val sections = EnumSet.allOf(BackupSection::class.java)
|
|
||||||
// managed externally
|
|
||||||
sections.remove(BackupSection.SETTINGS)
|
|
||||||
sections.remove(BackupSection.SETTINGS_READER_GRID)
|
|
||||||
runBlocking {
|
|
||||||
repository.restoreBackup(input, sections, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.domain
|
|
||||||
|
|
||||||
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,30 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.domain
|
|
||||||
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
|
|
||||||
enum class BackupSection(
|
|
||||||
val entryName: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
|
||||||
HISTORY("history"),
|
|
||||||
CATEGORIES("categories"),
|
|
||||||
FAVOURITES("favourites"),
|
|
||||||
SETTINGS("settings"),
|
|
||||||
SETTINGS_READER_GRID("reader_grid"),
|
|
||||||
BOOKMARKS("bookmarks"),
|
|
||||||
SOURCES("sources"),
|
|
||||||
SCROBBLING("scrobbling"),
|
|
||||||
STATS("statistics"),
|
|
||||||
SAVED_FILTERS("saved_filters"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun of(entry: ZipEntry): BackupSection? {
|
|
||||||
val name = entry.name.lowercase(Locale.ROOT)
|
|
||||||
return entries.find { x -> x.entryName == name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.domain
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import java.io.File
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
object BackupUtils {
|
|
||||||
|
|
||||||
private const val DIR_BACKUPS = "backups"
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
fun createTempFile(context: Context): File {
|
|
||||||
val dir = getAppBackupDir(context)
|
|
||||||
dir.mkdirs()
|
|
||||||
return File(dir, generateFileName(context))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAppBackupDir(context: Context) = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
|
||||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateFileName(context: Context) = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(dateTimeFormat.format(Date()))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.domain
|
|
||||||
|
|
||||||
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 ->
|
|
||||||
BackupUtils.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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.app.ShareCompat
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
|
||||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
|
|
||||||
abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
|
||||||
|
|
||||||
protected abstract val notificationTag: String
|
|
||||||
protected abstract val isRestoreService: Boolean
|
|
||||||
|
|
||||||
protected lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
|
||||||
createNotificationChannel(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
|
||||||
showResultNotification(null, CompositeResult.failure(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun IntentJobContext.showResultNotification(
|
|
||||||
fileUri: Uri?,
|
|
||||||
result: CompositeResult,
|
|
||||||
) {
|
|
||||||
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setSubText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
|
||||||
when {
|
|
||||||
result.isAllSuccess -> {
|
|
||||||
if (isRestoreService) {
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.restoring_backup))
|
|
||||||
.setContentText(getString(R.string.data_restored_success))
|
|
||||||
} else {
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.backup_saved))
|
|
||||||
.setContentText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
|
||||||
.setSubText(null)
|
|
||||||
|
|
||||||
}
|
|
||||||
notification.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.isAllFailed || !isRestoreService -> {
|
|
||||||
val title = getString(if (isRestoreService) R.string.data_not_restored else R.string.error_occurred)
|
|
||||||
val message = result.failures.joinToString("\n") {
|
|
||||||
it.getDisplayMessage(applicationContext.resources)
|
|
||||||
}
|
|
||||||
notification
|
|
||||||
.setContentText(if (isRestoreService) getString(R.string.data_not_restored_text) else message)
|
|
||||||
.setBigText(title, message)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
result.failures.firstNotNullOfOrNull { error ->
|
|
||||||
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, notificationTag)
|
|
||||||
}?.let { action ->
|
|
||||||
notification.addAction(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
notification
|
|
||||||
.setContentTitle(getString(R.string.restoring_backup))
|
|
||||||
.setContentText(getString(R.string.data_restored_with_errors))
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notification.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
applicationContext,
|
|
||||||
0,
|
|
||||||
AppRouter.homeIntent(this@BaseBackupRestoreService),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!isRestoreService && fileUri != null) {
|
|
||||||
val shareIntent = ShareCompat.IntentBuilder(this@BaseBackupRestoreService)
|
|
||||||
.setStream(fileUri)
|
|
||||||
.setType("application/zip")
|
|
||||||
.setChooserTitle(R.string.share_backup)
|
|
||||||
.createChooserIntent()
|
|
||||||
notification.addAction(
|
|
||||||
appcompatR.drawable.abc_ic_menu_share_mtrl_alpha,
|
|
||||||
getString(R.string.share),
|
|
||||||
PendingIntentCompat.getActivity(this@BaseBackupRestoreService, 0, shareIntent, 0, false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
notificationManager.notify(notificationTag, startId, notification.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun NotificationCompat.Builder.setBigText(title: String, text: CharSequence) = setStyle(
|
|
||||||
NotificationCompat.BigTextStyle()
|
|
||||||
.bigText(text)
|
|
||||||
.setSummaryText(text)
|
|
||||||
.setBigContentTitle(title),
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val CHANNEL_ID = "backup_restore"
|
|
||||||
|
|
||||||
fun createNotificationChannel(context: Context) {
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
|
||||||
.setName(context.getString(R.string.backup_restore))
|
|
||||||
.setShowBadge(true)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.backup
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.Uri
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
class BackupService : BaseBackupRestoreService() {
|
|
||||||
|
|
||||||
override val notificationTag = TAG
|
|
||||||
override val isRestoreService = false
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: BackupRepository
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
val notification = buildNotification(Progress.INDETERMINATE)
|
|
||||||
setForeground(
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
|
||||||
powerManager.withPartialWakeLock(TAG) {
|
|
||||||
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
|
||||||
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
launch {
|
|
||||||
progress.collect {
|
|
||||||
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
|
|
||||||
repository.createBackup(output, progress)
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
try {
|
|
||||||
DocumentFile.fromSingleUri(applicationContext, destination)?.delete()
|
|
||||||
} catch (e2: Throwable) {
|
|
||||||
e.addSuppressed(e2)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
progressUpdateJob?.cancelAndJoin()
|
|
||||||
contentResolver.notifyChange(destination, null)
|
|
||||||
showResultNotification(destination, CompositeResult.success())
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(this@BackupService, R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
|
||||||
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.creating_backup))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setProgress(
|
|
||||||
progress.total.coerceAtLeast(0),
|
|
||||||
progress.progress.coerceAtLeast(0),
|
|
||||||
progress.isIndeterminate,
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
if (progress.isIndeterminate) {
|
|
||||||
getString(R.string.processing_)
|
|
||||||
} else {
|
|
||||||
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
||||||
.addAction(
|
|
||||||
appcompatR.drawable.abc_ic_clear_material,
|
|
||||||
applicationContext.getString(android.R.string.cancel),
|
|
||||||
getCancelIntent(),
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "BACKUP"
|
|
||||||
private const val FOREGROUND_NOTIFICATION_ID = 33
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
fun start(context: Context, uri: Uri): Boolean = try {
|
|
||||||
val intent = Intent(context, BackupService::class.java)
|
|
||||||
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.backup
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import java.util.zip.Deflater
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BackupViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val repository: BackupRepository,
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
|
||||||
val onBackupDone = MutableEventFlow<Uri>()
|
|
||||||
|
|
||||||
private val destination = savedStateHandle.require<Uri>(AppRouter.KEY_DATA)
|
|
||||||
private val contentResolver: ContentResolver = context.contentResolver
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
|
|
||||||
it.setLevel(Deflater.BEST_COMPRESSION)
|
|
||||||
repository.createBackup(it, progress)
|
|
||||||
}
|
|
||||||
onBackupDone.call(destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.periodical
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
|
||||||
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
|
||||||
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class PeriodicalBackupService : CoroutineIntentService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var externalBackupStorage: ExternalBackupStorage
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var telegramBackupUploader: TelegramBackupUploader
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: BackupRepository
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settings: AppSettings
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val lastBackupDate = externalBackupStorage.getLastBackupDate()
|
|
||||||
if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val output = BackupUtils.createTempFile(applicationContext)
|
|
||||||
try {
|
|
||||||
ZipOutputStream(output.outputStream()).use {
|
|
||||||
repository.createBackup(it, null)
|
|
||||||
}
|
|
||||||
externalBackupStorage.put(output)
|
|
||||||
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
|
||||||
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
|
||||||
telegramBackupUploader.uploadBackup(output)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
output.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
|
||||||
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
BaseBackupRestoreService.createNotificationChannel(applicationContext)
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
val title = getString(R.string.periodic_backups)
|
|
||||||
val message = getString(
|
|
||||||
R.string.inline_preference_pattern,
|
|
||||||
getString(R.string.packup_creation_failed),
|
|
||||||
error.getDisplayMessage(resources),
|
|
||||||
)
|
|
||||||
notification
|
|
||||||
.setContentText(message)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
.setStyle(
|
|
||||||
NotificationCompat.BigTextStyle()
|
|
||||||
.bigText(message)
|
|
||||||
.setSummaryText(getString(R.string.packup_creation_failed))
|
|
||||||
.setBigContentTitle(title),
|
|
||||||
)
|
|
||||||
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
|
|
||||||
notification.addAction(action)
|
|
||||||
}
|
|
||||||
notification.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
applicationContext,
|
|
||||||
0,
|
|
||||||
AppRouter.periodicBackupSettingsIntent(applicationContext),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
|
|
||||||
const val TAG = "periodical_backup"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.periodical
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.result.ActivityResultCallback
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.preference.EditTextPreference
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import androidx.preference.PreferenceCategory
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
|
||||||
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
|
||||||
import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
|
|
||||||
ActivityResultCallback<Uri?> {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var telegramBackupUploader: TelegramBackupUploader
|
|
||||||
|
|
||||||
private val viewModel by viewModels<PeriodicalBackupSettingsViewModel>()
|
|
||||||
|
|
||||||
private val outputSelectCall = OpenDocumentTreeHelper(this, this)
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
|
||||||
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
|
||||||
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
|
||||||
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo)
|
|
||||||
viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary)
|
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this))
|
|
||||||
viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) {
|
|
||||||
findPreference<Preference>(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
|
||||||
val result = when (preference.key) {
|
|
||||||
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
|
|
||||||
AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router)
|
|
||||||
AppSettings.KEY_BACKUP_TG_TEST -> {
|
|
||||||
viewModel.checkTelegram()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> return super.onPreferenceTreeClick(preference)
|
|
||||||
}
|
|
||||||
if (!result) {
|
|
||||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(result: Uri?) {
|
|
||||||
if (result != null) {
|
|
||||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|
||||||
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
|
|
||||||
settings.periodicalBackupDirectory = result
|
|
||||||
viewModel.updateSummaryData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindOutputSummary(path: String?) {
|
|
||||||
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
|
|
||||||
preference.summary = when (path) {
|
|
||||||
null -> getString(R.string.invalid_value_message)
|
|
||||||
"" -> null
|
|
||||||
else -> path
|
|
||||||
}
|
|
||||||
preference.icon = if (path == null) {
|
|
||||||
getWarningIcon()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
|
||||||
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
|
|
||||||
preference.summary = lastBackupDate?.let {
|
|
||||||
preference.context.getString(
|
|
||||||
R.string.last_successful_backup,
|
|
||||||
DateUtils.getRelativeTimeSpanString(it.time),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
preference.isVisible = lastBackupDate != null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.periodical
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
|
||||||
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class PeriodicalBackupSettingsViewModel @Inject constructor(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
private val telegramUploader: TelegramBackupUploader,
|
|
||||||
private val backupStorage: ExternalBackupStorage,
|
|
||||||
@ApplicationContext private val appContext: Context,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val isTelegramAvailable
|
|
||||||
get() = telegramUploader.isAvailable
|
|
||||||
|
|
||||||
val lastBackupDate = MutableStateFlow<Date?>(null)
|
|
||||||
val backupsDirectory = MutableStateFlow<String?>("")
|
|
||||||
val isTelegramCheckLoading = MutableStateFlow(false)
|
|
||||||
val onActionDone = MutableEventFlow<ReversibleAction>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
updateSummaryData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkTelegram() {
|
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
isTelegramCheckLoading.value = true
|
|
||||||
telegramUploader.sendTestMessage()
|
|
||||||
onActionDone.call(ReversibleAction(R.string.connection_ok, null))
|
|
||||||
} finally {
|
|
||||||
isTelegramCheckLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSummaryData() {
|
|
||||||
updateBackupsDirectory()
|
|
||||||
updateLastBackupDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) {
|
|
||||||
val dir = settings.periodicalBackupDirectory
|
|
||||||
backupsDirectory.value = if (dir != null) {
|
|
||||||
dir.toUserFriendlyString()
|
|
||||||
} else {
|
|
||||||
BackupUtils.getAppBackupDir(appContext).path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateLastBackupDate() = launchJob(Dispatchers.Default) {
|
|
||||||
lastBackupDate.value = backupStorage.getLastBackupDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Uri.toUserFriendlyString(): String? {
|
|
||||||
val df = DocumentFile.fromTreeUri(appContext, this)
|
|
||||||
if (df?.canWrite() != true) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return resolveFile(appContext)?.path ?: toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.periodical
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
val isAvailable: Boolean
|
|
||||||
get() = botToken.isNotEmpty()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.restore
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
data class BackupSectionModel(
|
|
||||||
val section: BackupSection,
|
|
||||||
val isChecked: Boolean,
|
|
||||||
val isEnabled: Boolean,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val titleResId: Int
|
|
||||||
get() = when (section) {
|
|
||||||
BackupSection.INDEX -> 0 // should not appear here
|
|
||||||
BackupSection.HISTORY -> R.string.history
|
|
||||||
BackupSection.CATEGORIES -> R.string.favourites_categories
|
|
||||||
BackupSection.FAVOURITES -> R.string.favourites
|
|
||||||
BackupSection.SETTINGS -> R.string.settings
|
|
||||||
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
|
||||||
BackupSection.BOOKMARKS -> R.string.bookmarks
|
|
||||||
BackupSection.SOURCES -> R.string.remote_sources
|
|
||||||
BackupSection.SCROBBLING -> R.string.tracking
|
|
||||||
BackupSection.STATS -> R.string.statistics
|
|
||||||
BackupSection.SAVED_FILTERS -> R.string.saved_filters
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is BackupSectionModel && other.section == section
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
if (previousState !is BackupSectionModel) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return if (previousState.isEnabled != isEnabled) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
|
||||||
} else if (previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.restore
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
|
||||||
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
class RestoreService : BaseBackupRestoreService() {
|
|
||||||
|
|
||||||
override val notificationTag = TAG
|
|
||||||
override val isRestoreService = true
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: BackupRepository
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
val notification = buildNotification(Progress.INDETERMINATE)
|
|
||||||
setForeground(
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
|
||||||
val sections =
|
|
||||||
requireNotNull(intent.getSerializableExtraCompat<Array<BackupSection>>(AppRouter.KEY_ENTRIES)?.toSet())
|
|
||||||
powerManager.withPartialWakeLock(TAG) {
|
|
||||||
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
|
||||||
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
launch {
|
|
||||||
progress.collect {
|
|
||||||
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val result = ZipInputStream(contentResolver.openInputStream(source)).use { input ->
|
|
||||||
repository.restoreBackup(input, sections, progress)
|
|
||||||
}
|
|
||||||
progressUpdateJob?.cancelAndJoin()
|
|
||||||
showResultNotification(source, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
|
||||||
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.restoring_backup))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setProgress(
|
|
||||||
progress.total.coerceAtLeast(0),
|
|
||||||
progress.progress.coerceAtLeast(0),
|
|
||||||
progress.isIndeterminate,
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
if (progress.isIndeterminate) {
|
|
||||||
getString(R.string.processing_)
|
|
||||||
} else {
|
|
||||||
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
||||||
.addAction(
|
|
||||||
appcompatR.drawable.abc_ic_clear_material,
|
|
||||||
applicationContext.getString(android.R.string.cancel),
|
|
||||||
getCancelIntent(),
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "RESTORE"
|
|
||||||
private const val FOREGROUND_NOTIFICATION_ID = 39
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
fun start(context: Context, uri: Uri, sections: Set<BackupSection>): Boolean = try {
|
|
||||||
val intent = Intent(context, RestoreService::class.java)
|
|
||||||
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
|
||||||
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.backups.ui.restore
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
|
||||||
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.EnumMap
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class RestoreViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
|
||||||
private val contentResolver = context.contentResolver
|
|
||||||
|
|
||||||
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
|
|
||||||
val backupDate = MutableStateFlow<Date?>(null)
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
loadBackupInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadBackupInfo() {
|
|
||||||
val sections = runInterruptible(Dispatchers.IO) {
|
|
||||||
if (uri == null) throw FileNotFoundException()
|
|
||||||
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
|
|
||||||
val result = EnumSet.noneOf(BackupSection::class.java)
|
|
||||||
var entry = stream.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
val s = BackupSection.of(entry)
|
|
||||||
if (s != null) {
|
|
||||||
result.add(s)
|
|
||||||
if (s == BackupSection.INDEX) {
|
|
||||||
backupDate.value = stream.readDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stream.closeEntry()
|
|
||||||
entry = stream.nextEntry
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
|
|
||||||
if (entry == BackupSection.INDEX || entry !in sections) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
BackupSectionModel(
|
|
||||||
section = entry,
|
|
||||||
isChecked = true,
|
|
||||||
isEnabled = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onItemClick(item: BackupSectionModel) {
|
|
||||||
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
|
|
||||||
map[item.section] = item.copy(isChecked = !item.isChecked)
|
|
||||||
map.validate()
|
|
||||||
availableEntries.value = map.values.sortedBy { it.section.ordinal }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCheckedSections(): Set<BackupSection> = availableEntries.value
|
|
||||||
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
|
|
||||||
if (it.isChecked) it.section else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for inconsistent user selection
|
|
||||||
* Favorites cannot be restored without categories
|
|
||||||
*/
|
|
||||||
private fun MutableMap<BackupSection, BackupSectionModel>.validate() {
|
|
||||||
val favorites = this[BackupSection.FAVOURITES] ?: return
|
|
||||||
val categories = this[BackupSection.CATEGORIES]
|
|
||||||
if (categories?.isChecked == true) {
|
|
||||||
if (!favorites.isEnabled) {
|
|
||||||
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (favorites.isEnabled) {
|
|
||||||
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun InputStream.readDate(): Date? = runCatching {
|
|
||||||
val index = Json.decodeFromStream<List<BackupIndex>>(this)
|
|
||||||
Date(index.single().createdAt)
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,7 @@ import androidx.room.Insert
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -20,9 +17,9 @@ abstract class BookmarksDao {
|
|||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent LIMIT :limit OFFSET :offset",
|
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||||
)
|
)
|
||||||
abstract suspend fun findAll(offset: Int, limit: Int): Map<MangaWithTags, List<BookmarkEntity>>
|
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||||
@@ -50,17 +47,4 @@ abstract class BookmarksDao {
|
|||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
||||||
|
|
||||||
fun dump(): Flow<Pair<MangaWithTags, List<BookmarkEntity>>> = flow {
|
|
||||||
val window = 4
|
|
||||||
var offset = 0
|
|
||||||
while (currentCoroutineContext().isActive) {
|
|
||||||
val list = findAll(offset, window)
|
|
||||||
if (list.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += window
|
|
||||||
list.forEach { emit(it.key to it.value) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isImage
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.local.data.hasImageExtension
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -18,6 +17,12 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
|
val directImageUrl: String?
|
||||||
|
get() = if (isImageUrlDirect()) imageUrl else null
|
||||||
|
|
||||||
|
val imageLoadData: Any
|
||||||
|
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is Bookmark &&
|
return other is Bookmark &&
|
||||||
manga.id == other.manga.id &&
|
manga.id == other.manga.id &&
|
||||||
@@ -28,9 +33,11 @@ data class Bookmark(
|
|||||||
fun toMangaPage() = MangaPage(
|
fun toMangaPage() = MangaPage(
|
||||||
id = pageId,
|
id = pageId,
|
||||||
url = imageUrl,
|
url = imageUrl,
|
||||||
preview = imageUrl.takeIf {
|
preview = null,
|
||||||
MimeTypes.getMimeTypeFromUrl(it)?.isImage == true
|
|
||||||
},
|
|
||||||
source = manga.source,
|
source = manga.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun isImageUrlDirect(): Boolean {
|
||||||
|
return hasImageExtension(imageUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui
|
package org.koitharu.kotatsu.bookmarks.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
|
||||||
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
|
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
|
||||||
|
|
||||||
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)
|
@AndroidEntryPoint
|
||||||
|
class AllBookmarksActivity :
|
||||||
|
BaseActivity<ActivityContainerBinding>(),
|
||||||
|
AppBarOwner,
|
||||||
|
SnackbarOwner {
|
||||||
|
|
||||||
|
override val appBar: AppBarLayout
|
||||||
|
get() = viewBinding.appbar
|
||||||
|
|
||||||
|
override val snackbarHost: CoordinatorLayout
|
||||||
|
get() = viewBinding.root
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityContainerBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
val fm = supportFragmentManager
|
||||||
|
if (fm.findFragmentById(R.id.container) == null) {
|
||||||
|
fm.commit {
|
||||||
|
setReorderingAllowed(true)
|
||||||
|
replace(R.id.container, AllBookmarksFragment::class.java, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,34 +3,33 @@ package org.koitharu.kotatsu.bookmarks.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.view.ActionMode
|
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.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
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.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
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.findAppCompatDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
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.databinding.FragmentListSimpleBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
import org.koitharu.kotatsu.list.ui.GridSpanResolver
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
@@ -39,7 +38,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -47,25 +46,19 @@ class AllBookmarksFragment :
|
|||||||
BaseFragment<FragmentListSimpleBinding>(),
|
BaseFragment<FragmentListSimpleBinding>(),
|
||||||
ListStateHolderListener,
|
ListStateHolderListener,
|
||||||
OnListItemClickListener<Bookmark>,
|
OnListItemClickListener<Bookmark>,
|
||||||
ListSelectionController.Callback,
|
ListSelectionController.Callback2,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
|
||||||
|
|
||||||
private lateinit var pageSaveHelper: PageSaveHelper
|
|
||||||
private val viewModel by viewModels<AllBookmarksViewModel>()
|
private val viewModel by viewModels<AllBookmarksViewModel>()
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
private var selectionController: ListSelectionController? = null
|
private var selectionController: ListSelectionController? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
pageSaveHelper = pageSaveHelperFactory.create(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -85,6 +78,8 @@ class AllBookmarksFragment :
|
|||||||
callback = this,
|
callback = this,
|
||||||
)
|
)
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
bookmarksAdapter = BookmarksAdapter(
|
||||||
|
lifecycleOwner = viewLifecycleOwner,
|
||||||
|
coil = coil,
|
||||||
clickListener = this,
|
clickListener = this,
|
||||||
headerClickListener = this,
|
headerClickListener = this,
|
||||||
)
|
)
|
||||||
@@ -111,18 +106,6 @@ class AllBookmarksFragment :
|
|||||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
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() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
bookmarksAdapter = null
|
bookmarksAdapter = null
|
||||||
@@ -131,26 +114,22 @@ class AllBookmarksFragment :
|
|||||||
|
|
||||||
override fun onItemClick(item: Bookmark, view: View) {
|
override fun onItemClick(item: Bookmark, view: View) {
|
||||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
if (selectionController?.onItemClick(item.pageId) != true) {
|
||||||
val intent = ReaderIntent.Builder(view.context)
|
val intent = ReaderActivity.IntentBuilder(view.context)
|
||||||
.bookmark(item)
|
.bookmark(item)
|
||||||
.incognito()
|
.incognito(true)
|
||||||
.build()
|
.build()
|
||||||
router.openReader(intent)
|
startActivity(intent)
|
||||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onListHeaderClick(item: ListHeader, view: View) {
|
override fun onListHeaderClick(item: ListHeader, view: View) {
|
||||||
val manga = item.payload as? Manga ?: return
|
val manga = item.payload as? Manga ?: return
|
||||||
router.openDetails(manga)
|
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||||
return selectionController?.onItemLongClick(view, item.pageId) == true
|
return selectionController?.onItemLongClick(item.pageId) ?: false
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
|
|
||||||
return selectionController?.onItemContextClick(view, item.pageId) == true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
@@ -169,29 +148,23 @@ class AllBookmarksFragment :
|
|||||||
|
|
||||||
override fun onCreateActionMode(
|
override fun onCreateActionMode(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
menuInflater: MenuInflater,
|
mode: ActionMode,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActionItemClicked(
|
override fun onActionItemClicked(
|
||||||
controller: ListSelectionController,
|
controller: ListSelectionController,
|
||||||
mode: ActionMode?,
|
mode: ActionMode,
|
||||||
item: MenuItem,
|
item: MenuItem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_remove -> {
|
R.id.action_remove -> {
|
||||||
val ids = selectionController?.snapshot() ?: return false
|
val ids = selectionController?.snapshot() ?: return false
|
||||||
viewModel.removeBookmarks(ids)
|
viewModel.removeBookmarks(ids)
|
||||||
mode?.finish()
|
mode.finish()
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.action_save -> {
|
|
||||||
viewModel.savePages(pageSaveHelper, selectionController?.snapshot() ?: return false)
|
|
||||||
mode?.finish()
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +172,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 {
|
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -220,4 +203,16 @@ class AllBookmarksFragment :
|
|||||||
invalidateSpanIndexCache()
|
invalidateSpanIndexCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@Deprecated(
|
||||||
|
"",
|
||||||
|
ReplaceWith(
|
||||||
|
"BookmarksFragment()",
|
||||||
|
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fun newInstance() = AllBookmarksFragment()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
|||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -57,23 +56,6 @@ class AllBookmarksViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun savePages(pageSaveHelper: PageSaveHelper, ids: Set<Long>) {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
val tasks = content.value.mapNotNull {
|
|
||||||
if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null
|
|
||||||
PageSaveHelper.Task(
|
|
||||||
manga = it.manga,
|
|
||||||
chapterId = it.chapterId,
|
|
||||||
pageNumber = it.page + 1,
|
|
||||||
page = it.toMangaPage(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val dest = pageSaveHelper.save(tasks)
|
|
||||||
val msg = if (dest.size == 1) R.string.page_saved else R.string.pages_saved
|
|
||||||
onActionDone.call(ReversibleAction(msg, null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
||||||
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
||||||
for ((manga, bookmarks) in data) {
|
for ((manga, bookmarks) in data) {
|
||||||
|
|||||||
@@ -1,21 +1,42 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
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.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
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.databinding.ItemBookmarkLargeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
fun bookmarkLargeAD(
|
fun bookmarkLargeAD(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ 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 {
|
bind {
|
||||||
binding.imageViewThumb.setImageAsync(item)
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
binding.progressView.setProgress(item.percent, false)
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
allowRgb565(true)
|
||||||
|
tag(item)
|
||||||
|
decodeRegion(item.scroll)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.progressView.percent = item.percent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
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.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.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))
|
||||||
|
defaultPlaceholders(context)
|
||||||
|
allowRgb565(true)
|
||||||
|
tag(item)
|
||||||
|
decodeRegion(item.scroll)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -8,24 +10,24 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
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.listHeaderAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
headerClickListener: ListHeaderClickListener?,
|
headerClickListener: ListHeaderClickListener?,
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class AdListUpdateService : CoroutineIntentService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var updater: AdBlock.Updater
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
updater.updateList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) = Unit
|
|
||||||
}
|
|
||||||
@@ -1,111 +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.network.webview.adblock.AdBlock
|
|
||||||
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
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var adBlock: AdBlock
|
|
||||||
|
|
||||||
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,49 +1,69 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.core.graphics.Insets
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BrowserActivity : BaseBrowserActivity() {
|
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this, adBlock)
|
@Inject
|
||||||
lifecycleScope.launch {
|
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||||
try {
|
|
||||||
proxyProvider.applyWebViewConfig()
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
} catch (e: Exception) {
|
super.onCreate(savedInstanceState)
|
||||||
e.printStackTraceDebug()
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
return
|
||||||
}
|
}
|
||||||
if (savedInstanceState == null) {
|
supportActionBar?.run {
|
||||||
val url = intent?.dataString
|
setDisplayHomeAsUpEnabled(true)
|
||||||
if (url.isNullOrEmpty()) {
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
finishAfterTransition()
|
}
|
||||||
} else {
|
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
||||||
onTitleChanged(
|
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||||
intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
|
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||||
url,
|
}
|
||||||
)
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
viewBinding.webView.loadUrl(url)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +81,14 @@ class BrowserActivity : BaseBrowserActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_browser -> {
|
R.id.action_browser -> {
|
||||||
if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) {
|
val url = viewBinding.webView.url?.toUriOrNull()
|
||||||
Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
if (url != null) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.data = url
|
||||||
|
try {
|
||||||
|
startActivity(Intent.createChooser(intent, item.title))
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -70,22 +96,56 @@ class BrowserActivity : BaseBrowserActivity() {
|
|||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<InteractiveActionRequiredException, Unit>() {
|
override fun onPause() {
|
||||||
override fun createIntent(
|
viewBinding.webView.onPause()
|
||||||
context: Context,
|
super.onPause()
|
||||||
input: InteractiveActionRequiredException
|
}
|
||||||
): Intent = AppRouter.browserIntent(
|
|
||||||
context = context,
|
|
||||||
url = input.url,
|
|
||||||
source = input.source,
|
|
||||||
title = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit
|
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 {
|
companion object {
|
||||||
|
|
||||||
const val TAG = "BrowserActivity"
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_SOURCE = "source"
|
||||||
|
|
||||||
|
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
||||||
|
return Intent(context, BrowserActivity::class.java)
|
||||||
|
.setData(Uri.parse(url))
|
||||||
|
.putExtra(EXTRA_TITLE, title)
|
||||||
|
.putExtra(EXTRA_SOURCE, source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Looper
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebResourceResponse
|
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.annotation.AnyThread
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
|
|
||||||
open class BrowserClient(
|
open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
|
||||||
private val callback: BrowserCallback,
|
|
||||||
private val adBlock: AdBlock?,
|
|
||||||
) : WebViewClient() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
|
|
||||||
*/
|
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
@@ -33,7 +16,7 @@ open class BrowserClient(
|
|||||||
callback.onLoadingStateChanged(isLoading = true)
|
callback.onLoadingStateChanged(isLoading = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||||
super.onPageCommitVisible(view, url)
|
super.onPageCommitVisible(view, url)
|
||||||
callback.onTitleChanged(view.title.orEmpty(), url)
|
callback.onTitleChanged(view.title.orEmpty(), url)
|
||||||
}
|
}
|
||||||
@@ -42,39 +25,4 @@ open class BrowserClient(
|
|||||||
super.doUpdateVisitedHistory(view, url, isReload)
|
super.doUpdateVisitedHistory(view, url, isReload)
|
||||||
callback.onHistoryChanged()
|
callback.onHistoryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun shouldInterceptRequest(
|
|
||||||
view: WebView?,
|
|
||||||
url: String?
|
|
||||||
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
|
|
||||||
super.shouldInterceptRequest(view, url)
|
|
||||||
} else {
|
|
||||||
emptyResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
override fun shouldInterceptRequest(
|
|
||||||
view: WebView?,
|
|
||||||
request: WebResourceRequest?
|
|
||||||
): WebResourceResponse? =
|
|
||||||
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
|
|
||||||
super.shouldInterceptRequest(view, request)
|
|
||||||
} else {
|
|
||||||
emptyResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emptyResponse(): WebResourceResponse =
|
|
||||||
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
|
||||||
|
|
||||||
@SuppressLint("WrongThread")
|
|
||||||
@AnyThread
|
|
||||||
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
||||||
url
|
|
||||||
} else {
|
|
||||||
runBlocking(Dispatchers.Main.immediate) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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 coil.EventListener
|
||||||
|
import coil.request.ErrorResult
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class CaptchaNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : EventListener {
|
||||||
|
|
||||||
|
fun notify(exception: CloudFlareProtectedException) {
|
||||||
|
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||||
|
.setName(context.getString(R.string.captcha_required))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val intent = CloudFlareActivity.newIntent(context, exception)
|
||||||
|
.setData(exception.url.toUri())
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setContentTitle(channel.name)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setGroup(GROUP_CAPTCHA)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setVisibility(
|
||||||
|
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
context.getString(
|
||||||
|
R.string.captcha_required_summary,
|
||||||
|
exception.source?.title ?: context.getString(R.string.app_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
|
super.onError(request, result)
|
||||||
|
val e = result.throwable
|
||||||
|
if (e is CloudFlareProtectedException && request.parameters.value<Boolean>(PARAM_IGNORE_CAPTCHA) != true) {
|
||||||
|
notify(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter(
|
||||||
|
key = PARAM_IGNORE_CAPTCHA,
|
||||||
|
value = true,
|
||||||
|
memoryCacheKey = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
|
||||||
|
private const val CHANNEL_ID = "captcha"
|
||||||
|
private const val TAG = CHANNEL_ID
|
||||||
|
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||||
|
private const val SETTINGS_ACTION_CODE = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,66 +5,83 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.webkit.CookieManager
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
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.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
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.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.parsers.util.runCatchingCancellable
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCallback {
|
||||||
|
|
||||||
private var pendingResult = RESULT_CANCELED
|
private var pendingResult = RESULT_CANCELED
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var captchaHandler: CaptchaHandler
|
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
private lateinit var cfClient: CloudFlareClient
|
||||||
|
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||||
|
|
||||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
super.onCreate(savedInstanceState)
|
||||||
val url = intent?.dataString
|
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||||
if (url.isNullOrEmpty()) {
|
|
||||||
finishAfterTransition()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfClient = CloudFlareClient(cookieJar, this, adBlock, url)
|
supportActionBar?.run {
|
||||||
viewBinding.webView.webViewClient = cfClient
|
setDisplayHomeAsUpEnabled(true)
|
||||||
lifecycleScope.launch {
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val url = intent?.dataString.orEmpty()
|
||||||
|
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||||
|
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||||
|
viewBinding.webView.webViewClient = cfClient
|
||||||
|
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||||
|
onBackPressedDispatcher.addCallback(it)
|
||||||
|
}
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
finishAfterTransition()
|
||||||
|
} else {
|
||||||
|
onTitleChanged(getString(R.string.loading_), url)
|
||||||
|
viewBinding.webView.loadUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
runCatching {
|
||||||
|
viewBinding.webView
|
||||||
|
}.onSuccess {
|
||||||
|
it.stopLoading()
|
||||||
|
it.destroy()
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
@@ -72,6 +89,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
viewBinding.appbar.updatePadding(
|
||||||
|
top = insets.top,
|
||||||
|
)
|
||||||
|
viewBinding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
viewBinding.webView.stopLoading()
|
viewBinding.webView.stopLoading()
|
||||||
@@ -87,13 +115,21 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
viewBinding.webView.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
viewBinding.webView.onPause()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun finish() {
|
override fun finish() {
|
||||||
setResult(pendingResult)
|
setResult(pendingResult)
|
||||||
super.finish()
|
super.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
|
||||||
|
|
||||||
override fun onPageLoaded() {
|
override fun onPageLoaded() {
|
||||||
viewBinding.progressBar.isInvisible = true
|
viewBinding.progressBar.isInvisible = true
|
||||||
}
|
}
|
||||||
@@ -104,22 +140,25 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
lifecycleScope.launch {
|
val source = intent?.getStringExtra(ARG_SOURCE)
|
||||||
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
if (source != null) {
|
||||||
if (source != null) {
|
CaptchaNotifier(this).dismiss(MangaSource(source))
|
||||||
runCatchingCancellable {
|
|
||||||
captchaHandler.discard(MangaSource(source))
|
|
||||||
}.onFailure {
|
|
||||||
it.printStackTraceDebug()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finishAfterTransition()
|
|
||||||
}
|
}
|
||||||
|
finishAfterTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
viewBinding.progressBar.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHistoryChanged() {
|
||||||
|
onBackPressedCallback?.onHistoryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
supportActionBar?.subtitle = subtitle?.toString()?.toHttpUrlOrNull()?.host.ifNullOrEmpty { subtitle }
|
supportActionBar?.subtitle =
|
||||||
|
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restartCheck() {
|
private fun restartCheck() {
|
||||||
@@ -137,22 +176,45 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
|
|
||||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||||
cookieJar.removeCookies(url) { cookie ->
|
cookieJar.removeCookies(url) { cookie ->
|
||||||
CloudFlareHelper.isCloudFlareCookie(cookie.name)
|
val name = cookie.name
|
||||||
|
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Contract : ActivityResultContract<CloudFlareProtectedException, Boolean>() {
|
class Contract : ActivityResultContract<CloudFlareProtectedException, TaggedActivityResult>() {
|
||||||
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent {
|
||||||
return AppRouter.cloudFlareResolveIntent(context, input)
|
return newIntent(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
override fun parseResult(resultCode: Int, intent: Intent?): TaggedActivityResult {
|
||||||
return resultCode == RESULT_OK
|
return TaggedActivityResult(TAG, resultCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val TAG = "CloudFlareActivity"
|
const val TAG = "CloudFlareActivity"
|
||||||
|
private const val ARG_UA = "ua"
|
||||||
|
private const val ARG_SOURCE = "_source"
|
||||||
|
|
||||||
|
fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent(
|
||||||
|
context = context,
|
||||||
|
url = exception.url,
|
||||||
|
source = exception.source,
|
||||||
|
headers = exception.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun newIntent(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
source: MangaSource?,
|
||||||
|
headers: Headers?,
|
||||||
|
) = Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
|
data = url.toUri()
|
||||||
|
putExtra(ARG_SOURCE, source?.name)
|
||||||
|
headers?.get(CommonHeaders.USER_AGENT)?.let {
|
||||||
|
putExtra(ARG_UA, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import org.koitharu.kotatsu.browser.BrowserCallback
|
|||||||
|
|
||||||
interface CloudFlareCallback : BrowserCallback {
|
interface CloudFlareCallback : BrowserCallback {
|
||||||
|
|
||||||
|
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
|
||||||
|
|
||||||
fun onPageLoaded()
|
fun onPageLoaded()
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ package org.koitharu.kotatsu.browser.cloudflare
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
|
||||||
|
|
||||||
|
private const val CF_CLEARANCE = "cf_clearance"
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
|
|
||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
private val callback: CloudFlareCallback,
|
private val callback: CloudFlareCallback,
|
||||||
adBlock: AdBlock,
|
|
||||||
private val targetUrl: String,
|
private val targetUrl: String,
|
||||||
) : BrowserClient(callback, adBlock) {
|
) : BrowserClient(callback) {
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
private val oldClearance = getClearance()
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
@@ -24,7 +23,7 @@ class CloudFlareClient(
|
|||||||
checkClearance()
|
checkClearance()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageCommitVisible(view: WebView, url: String) {
|
override fun onPageCommitVisible(view: WebView, url: String?) {
|
||||||
super.onPageCommitVisible(view, url)
|
super.onPageCommitVisible(view, url)
|
||||||
callback.onPageLoaded()
|
callback.onPageLoaded()
|
||||||
}
|
}
|
||||||
@@ -51,5 +50,8 @@ class CloudFlareClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.provider.SearchRecentSuggestions
|
import android.provider.SearchRecentSuggestions
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import androidx.collection.arraySetOf
|
import androidx.collection.arraySetOf
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import coil3.ImageLoader
|
import coil.ComponentRegistry
|
||||||
import coil3.disk.DiskCache
|
import coil.ImageLoader
|
||||||
import coil3.disk.directory
|
import coil.decode.SvgDecoder
|
||||||
import coil3.gif.AnimatedImageDecoder
|
import coil.disk.DiskCache
|
||||||
import coil3.gif.GifDecoder
|
import coil.util.DebugLogger
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import coil3.svg.SvgDecoder
|
|
||||||
import coil3.util.DebugLogger
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -31,38 +25,32 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.backups.domain.BackupObserver
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
||||||
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.MangaHttpClient
|
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.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
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.parser.favicon.FaviconFetcher
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||||
import org.koitharu.kotatsu.core.util.FileSize
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
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.MangaPageFetcher
|
||||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.FaviconCache
|
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.PageCache
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
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.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
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.sync.domain.SyncController
|
||||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@@ -80,12 +68,6 @@ interface AppModule {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Provides
|
|
||||||
@LocalizedAppContext
|
|
||||||
fun provideLocalizedContext(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): Context = ContextCompat.getContextForLanguage(context)
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideNetworkState(
|
fun provideNetworkState(
|
||||||
@@ -97,19 +79,19 @@ interface AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideMangaDatabase(
|
fun provideMangaDatabase(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): MangaDatabase = MangaDatabase(context)
|
): MangaDatabase {
|
||||||
|
return MangaDatabase(context)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCoil(
|
fun provideCoil(
|
||||||
@LocalizedAppContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||||
faviconFetcherFactory: FaviconFetcher.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
imageProxyInterceptor: ImageProxyInterceptor,
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
networkStateProvider: Provider<NetworkState>,
|
|
||||||
captchaHandler: CaptchaHandler,
|
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -121,39 +103,34 @@ interface AppModule {
|
|||||||
okHttpClientProvider.get().newBuilder().cache(null).build()
|
okHttpClientProvider.get().newBuilder().cache(null).build()
|
||||||
}
|
}
|
||||||
return ImageLoader.Builder(context)
|
return ImageLoader.Builder(context)
|
||||||
.interceptorCoroutineContext(Dispatchers.Default)
|
.okHttpClient { okHttpClientLazy.value }
|
||||||
|
.interceptorDispatcher(Dispatchers.Default)
|
||||||
|
.fetcherDispatcher(Dispatchers.Default)
|
||||||
|
.decoderDispatcher(Dispatchers.IO)
|
||||||
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(captchaHandler)
|
.eventListener(CaptchaNotifier(context))
|
||||||
.components {
|
.components(
|
||||||
add(
|
ComponentRegistry.Builder()
|
||||||
OkHttpNetworkFetcherFactory(
|
.add(SvgDecoder.Factory())
|
||||||
callFactory = okHttpClientLazy::value,
|
.add(CbzFetcher.Factory())
|
||||||
connectivityChecker = { networkStateProvider.get() },
|
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
|
||||||
),
|
.add(MangaPageKeyer())
|
||||||
)
|
.add(pageFetcherFactory)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
.add(imageProxyInterceptor)
|
||||||
add(AnimatedImageDecoder.Factory())
|
.add(coverRestoreInterceptor)
|
||||||
} else {
|
.build(),
|
||||||
add(GifDecoder.Factory())
|
).build()
|
||||||
}
|
|
||||||
add(SvgDecoder.Factory())
|
|
||||||
add(CbzFetcher.Factory())
|
|
||||||
add(AvifImageDecoder.Factory())
|
|
||||||
add(faviconFetcherFactory)
|
|
||||||
add(MangaPageKeyer())
|
|
||||||
add(pageFetcherFactory)
|
|
||||||
add(imageProxyInterceptor)
|
|
||||||
add(coverRestoreInterceptor)
|
|
||||||
add(MangaSourceHeaderInterceptor())
|
|
||||||
}.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideSearchSuggestions(
|
fun provideSearchSuggestions(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): SearchRecentSuggestions = MangaSuggestionsProvider.createSuggestions(context)
|
): SearchRecentSuggestions {
|
||||||
|
return MangaSuggestionsProvider.createSuggestions(context)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@ElementsIntoSet
|
@ElementsIntoSet
|
||||||
@@ -175,12 +152,10 @@ interface AppModule {
|
|||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
screenshotPolicyHelper,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -198,29 +173,5 @@ interface AppModule {
|
|||||||
fun provideWorkManager(
|
fun provideWorkManager(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
): WorkManager = WorkManager.getInstance(context)
|
): WorkManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@PageCache
|
|
||||||
fun providePageCache(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) = LocalStorageCache(
|
|
||||||
context = context,
|
|
||||||
dir = CacheDir.PAGES,
|
|
||||||
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
|
||||||
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@FaviconCache
|
|
||||||
fun provideFaviconCache(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) = LocalStorageCache(
|
|
||||||
context = context,
|
|
||||||
dir = CacheDir.FAVICONS,
|
|
||||||
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
|
|
||||||
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user