mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-27 11:37:51 +02:00
Compare commits
357 Commits
Author | SHA1 | Date | |
---|---|---|---|
0dc4862d79 | |||
a3f1b72126 | |||
5ff10799e4 | |||
a82e5f5452 | |||
e10cb0e632 | |||
c7e07a6df0 | |||
2e0c778090 | |||
592050c668 | |||
02c9191525 | |||
d421401626 | |||
b2d4e5ab84 | |||
84e023607c | |||
f145fd0dec | |||
42a9f911d8 | |||
9567d55312 | |||
531cd99247 | |||
f3660d88dd | |||
3accb9a08b | |||
63ce7371bb | |||
01c3498dbf | |||
b3471234ad | |||
b2d697131c | |||
ef49fc91d8 | |||
6222b47a4f | |||
f58e3c390a | |||
7504621a24 | |||
88e49a9b8b | |||
5b23f29d06 | |||
c1bdebee78 | |||
ddd4cc10ff | |||
0ca62a4acc | |||
4f1275ac01 | |||
b2fee7035f | |||
e15d7cb548 | |||
3257cbe21f | |||
1237af1ff3 | |||
68600b337e | |||
dac2072eaa | |||
1b921f9845 | |||
a3992d9fbe | |||
efd2a0cb7b | |||
fba428257b | |||
ff36901007 | |||
940d8389b5 | |||
f7a6cbe5e2 | |||
7aa379a857 | |||
443024cebb | |||
1657f04d55 | |||
407e798fdb | |||
4054f2a6a0 | |||
468cdf603c | |||
988ec6a224 | |||
bdbdf211e2 | |||
0437703cbf | |||
71aa592111 | |||
d501c02f8b | |||
9daf0e78b8 | |||
dfa07a5f35 | |||
437c995d12 | |||
cc6ae9d1a8 | |||
c58e4f4dee | |||
c87b0e77de | |||
355d5af8ae | |||
3d99a8ebdb | |||
c4b975b777 | |||
2911fe7a1a | |||
14c114756d | |||
e7a8107279 | |||
bff73b1b40 | |||
c255f57d95 | |||
64c47bbaed | |||
e0b7698d40 | |||
a01792ac9a | |||
3ba078f64c | |||
a16240f123 | |||
e5a120e778 | |||
2ba60e9114 | |||
472ce5a5e4 | |||
99ba84c810 | |||
78285bdf37 | |||
5a7f2684b3 | |||
d912a42249 | |||
6d8c4fb8b1 | |||
a63cecbfcb | |||
4a5bceb4e4 | |||
86541445b7 | |||
4e826aa8e7 | |||
b6e6f490e9 | |||
2145e878a4 | |||
355f6db255 | |||
bc7632bf02 | |||
609d8c9685 | |||
2f08515455 | |||
7f450e185d | |||
747879b4ec | |||
4193870fa6 | |||
cdc5de3f1b | |||
bc34d4fa88 | |||
6fd4af8736 | |||
b5c2934270 | |||
94f5117941 | |||
112e233498 | |||
18b1326f3a | |||
1e58b05ead | |||
938919bd9b | |||
b6b78994d8 | |||
fddd8ce305 | |||
ccff337975 | |||
fde6b7af4f | |||
0657db7dcb | |||
d1c2eaf6d5 | |||
91bb6b9016 | |||
90351c6e9e | |||
dd4740e54f | |||
48e7cbd76c | |||
f51e32f39b | |||
ae42f59102 | |||
5c8006f9b7 | |||
aa5861d3ca | |||
7a64bf55cb | |||
d4c9ab793f | |||
48d2849d97 | |||
776610d0e6 | |||
3a790f3d66 | |||
7382042288 | |||
33992d80bf | |||
a92b0e567b | |||
829a65e515 | |||
03ad48c055 | |||
89837e4ced | |||
ace1db21d1 | |||
8bb69c455b | |||
2dae706198 | |||
3eda2a220a | |||
61e5440b7c | |||
2e2663bad9 | |||
f4dd150b70 | |||
2b35d22e25 | |||
f590378761 | |||
f5f592be91 | |||
7a373fb43a | |||
aded11e599 | |||
41d7cee020 | |||
f2ef6a20e6 | |||
a398c3fb81 | |||
2a454b44cc | |||
7b66ece895 | |||
b5017eebbf | |||
aa67229daf | |||
5af68186d6 | |||
545bc0e605 | |||
291168f4de | |||
9facb51f22 | |||
5b7d8c5e37 | |||
5945937e4b | |||
9f9f9872eb | |||
3566072f4a | |||
b85cd86b24 | |||
79c3767fff | |||
cf1609a429 | |||
3aeac7e7b5 | |||
1557f713f4 | |||
b63d24ac1a | |||
348c1ff29d | |||
717e55497f | |||
d84b5e8b46 | |||
5f9ddf9ff5 | |||
bbee093c63 | |||
e8c35ae4e1 | |||
1607658c30 | |||
2e9ef373f3 | |||
ec6eef6d37 | |||
45a19d15ec | |||
7191552126 | |||
cfa07490e5 | |||
ae40990eb9 | |||
9f2fe33ce0 | |||
33660de6b1 | |||
13d25e0849 | |||
6662e2002f | |||
d4081dc899 | |||
62dffb8226 | |||
cb6aa18480 | |||
d5cfbef42b | |||
535abcbb8b | |||
c34b548a3e | |||
9bf452856c | |||
17109ab760 | |||
6bc6e1a1d1 | |||
7eef4f7fbf | |||
75bec6a8e3 | |||
0a10f66053 | |||
58860b51a2 | |||
3ee652b61a | |||
426ed7308b | |||
0ecfef3f70 | |||
5f7e34b6a1 | |||
34cb24fe34 | |||
1490112135 | |||
c4716a3f4c | |||
0a54901eb0 | |||
fea2e0a265 | |||
d3c087375b | |||
a93c0577ac | |||
e4dc35674d | |||
8a668ba7b9 | |||
ee9a68b040 | |||
78e8d40649 | |||
660e13b701 | |||
0685382083 | |||
04a993c997 | |||
7cae3095c4 | |||
e288bf902b | |||
a083e1f71a | |||
86b9d7e843 | |||
628bd5d6b4 | |||
00285a782c | |||
16be469ecb | |||
fdcbc4cffa | |||
fc548304cf | |||
7c7ff8165e | |||
496a476c13 | |||
441fc6e45b | |||
cf7ec6aa76 | |||
db2dd4b6c6 | |||
a68417a0b0 | |||
2a5102a457 | |||
837d8f5f30 | |||
1a5858e99b | |||
4044427d93 | |||
f667f85fa5 | |||
5cddc0c387 | |||
cbc01dd6f1 | |||
b820c7debf | |||
2bee072cba | |||
80710b0b94 | |||
3319ccfd41 | |||
878008e93b | |||
0cd551d4fd | |||
f85194ec46 | |||
271489bdfd | |||
bd5f22a049 | |||
189f18b112 | |||
df166184ea | |||
ce42cba096 | |||
9670863a41 | |||
1ae52bd33f | |||
c9cf9cfff0 | |||
2ffbee3db2 | |||
96b8beb9cd | |||
365b849046 | |||
8e613d03e3 | |||
b18a794eca | |||
c620c924f9 | |||
9db81a5a49 | |||
6fb7a85e8a | |||
36f81b4a62 | |||
2caecc01b2 | |||
dedb8d2d68 | |||
7192b26402 | |||
762f5bdc33 | |||
bebb52b4e8 | |||
2c9f8bb9ce | |||
efbefabb01 | |||
990fb22d3e | |||
9b2c22b2d9 | |||
df7e0d2f2f | |||
5cfda1b1bf | |||
ac9bf1f3ff | |||
7eb0868791 | |||
8a792e6d76 | |||
d8a3692d92 | |||
95ce0e39ef | |||
17b70ab38c | |||
07e76f35fa | |||
a4cab9876a | |||
c06a932c95 | |||
7d713b87b1 | |||
b1167146c5 | |||
2d0a5eb02c | |||
8d68859c2a | |||
444cefc9a2 | |||
d0deceabbd | |||
175c1df0b8 | |||
9cc6491c2a | |||
710179f4b4 | |||
d11c72fd48 | |||
0af505828e | |||
135cf9960f | |||
3bf7c74f93 | |||
cea4911c4d | |||
54dc01253d | |||
4db9a90da2 | |||
d69e9034ab | |||
71ece73d99 | |||
3bb2102eb4 | |||
b7914909d0 | |||
63398fe491 | |||
bf32bf28da | |||
dcb6bfb18d | |||
8f605dc0f6 | |||
47e770948b | |||
9ab29f5b7f | |||
10bf430ce6 | |||
67eb4e8180 | |||
141f9b7730 | |||
139a589ad6 | |||
591873a185 | |||
97a308b114 | |||
430714e67f | |||
a49adbd09c | |||
3df98d576e | |||
8135136c86 | |||
cef1c4b8a1 | |||
2e8791a101 | |||
0e2b8b10d1 | |||
3cb64669e4 | |||
bc0d32f330 | |||
0db17beacc | |||
931efed784 | |||
6378a41b6d | |||
23bf7faf9f | |||
01ff3af63f | |||
8f98055e9e | |||
84ae61f72c | |||
6dd280205b | |||
1365d553a4 | |||
61a594493c | |||
62ab70f889 | |||
eaccfdde59 | |||
a8e536478c | |||
e94d5626dd | |||
be3e31ddc4 | |||
b92b6520cb | |||
ea33179a95 | |||
6fcf6ae1f5 | |||
f2a9247b68 | |||
dc3ed7fffc | |||
271de31d51 | |||
1268caf3e0 | |||
c0cef58e39 | |||
d363d205c3 | |||
2fd5a9e883 | |||
e7ef974a39 | |||
0b62fa8b76 | |||
2d28750782 | |||
e2054a0ab7 | |||
7ae5c3b2e7 | |||
6e7fefb8b2 | |||
450bef278b | |||
0affc0d58b | |||
3d153b6c8e | |||
04fff91e23 | |||
28a23452f2 | |||
6d403851cf | |||
395a749bce | |||
2cc2a90941 |
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@ -1,33 +0,0 @@
|
|||||||
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
|
|
||||||
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
|
||||||
3. What is your type of issue?
|
|
||||||
* [Catalogue request](#catalogue-requests)
|
|
||||||
* [Bugs](#bugs)
|
|
||||||
* [Feature requests](#feature-requests)
|
|
||||||
* [Translations](https://tachiyomi.org/help/contribution/#translation)
|
|
||||||
4. After following 1. and 3. you can [open your issue](https://github.com/inorichi/tachiyomi/issues/new)
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
# Catalogue requests
|
|
||||||
|
|
||||||
* Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions#readme, not here
|
|
||||||
|
|
||||||
# Bugs
|
|
||||||
* Include version (More > About > Version)
|
|
||||||
* If not latest, try updating, it may have already been solved
|
|
||||||
* Preview version is equal to the number of commits as seen in the main page
|
|
||||||
* Include steps to reproduce (if not obvious from description)
|
|
||||||
* Include screenshot (if needed)
|
|
||||||
* If it could be device-dependent, try reproducing on another device (if possible)
|
|
||||||
* For large logs use http://pastebin.com/ (or similar)
|
|
||||||
* Don't group unrelated requests into one issue
|
|
||||||
|
|
||||||
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
|
|
||||||
|
|
||||||
DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
|
||||||
|
|
||||||
# Feature requests
|
|
||||||
|
|
||||||
* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does"
|
|
||||||
* Include screenshot (if needed)
|
|
6
.github/ISSUE_TEMPLATE.md
vendored
6
.github/ISSUE_TEMPLATE.md
vendored
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
I acknowledge that:
|
I acknowledge that:
|
||||||
|
|
||||||
- I have updated to the latest version of the app (stable is v0.10.7)
|
- I have updated to the latest version of the app (stable is v0.10.10)
|
||||||
- I have updated all extensions
|
- I have updated all extensions
|
||||||
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
|
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||||
|
|
||||||
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
||||||
|
|
||||||
@ -24,3 +24,5 @@ I acknowledge that:
|
|||||||
|
|
||||||
## Other details
|
## Other details
|
||||||
Additional details and attachments.
|
Additional details and attachments.
|
||||||
|
|
||||||
|
If you're experiencing crashes, share the crash logs from More → Settings → Advanced → Dump crash logs.
|
||||||
|
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -9,9 +9,9 @@ labels: "bug"
|
|||||||
|
|
||||||
I acknowledge that:
|
I acknowledge that:
|
||||||
|
|
||||||
- I have updated to the latest version of the app (stable is v0.10.7)
|
- I have updated to the latest version of the app (stable is v0.10.10)
|
||||||
- I have updated all extensions
|
- I have updated all extensions
|
||||||
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
|
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||||
|
|
||||||
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
||||||
|
|
||||||
@ -34,3 +34,5 @@ This happened instead.
|
|||||||
|
|
||||||
## Other details
|
## Other details
|
||||||
Additional details and attachments.
|
Additional details and attachments.
|
||||||
|
|
||||||
|
If you're experiencing crashes, share the crash logs from More → Settings → Advanced → Dump crash logs.
|
||||||
|
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -4,5 +4,5 @@ contact_links:
|
|||||||
url: https://tachiyomi.org/help/
|
url: https://tachiyomi.org/help/
|
||||||
about: Common questions are answered here.
|
about: Common questions are answered here.
|
||||||
- name: Tachiyomi extensions GitHub repository
|
- name: Tachiyomi extensions GitHub repository
|
||||||
url: https://github.com/inorichi/tachiyomi-extensions
|
url: https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||||
about: Issues about an extension/source/catalogue should be opened here instead.
|
about: Issues about an extension/source/catalogue should be opened here instead.
|
||||||
|
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -9,9 +9,9 @@ labels: "feature"
|
|||||||
|
|
||||||
I acknowledge that:
|
I acknowledge that:
|
||||||
|
|
||||||
- I have updated to the latest version of the app (stable is v0.10.7)
|
- I have updated to the latest version of the app (stable is v0.10.10)
|
||||||
- I have updated all extensions
|
- I have updated all extensions
|
||||||
- If this is an issue with an extension, that I should be opening an issue in https://github.com/inorichi/tachiyomi-extensions
|
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||||
|
|
||||||
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
|
||||||
|
|
||||||
|
6
.github/ISSUE_TEMPLATE/source_issue.md
vendored
6
.github/ISSUE_TEMPLATE/source_issue.md
vendored
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: "Extension/source/catalogue issue"
|
name: "Extension/source/catalogue issue"
|
||||||
about: "Do not open an issue here. See https://github.com/inorichi/tachiyomi-extensions"
|
about: "Do not open an issue here. See https://github.com/tachiyomiorg/tachiyomi-extensions"
|
||||||
title: "THIS ISSUE IS IN THE WRONG REPO; SEE https://github.com/inorichi/tachiyomi-extensions"
|
title: "THIS ISSUE IS IN THE WRONG REPO; SEE https://github.com/tachiyomiorg/tachiyomi-extensions"
|
||||||
labels: "catalog, invalid"
|
labels: "catalog, invalid"
|
||||||
---
|
---
|
||||||
|
|
||||||
DO NOT OPEN AN ISSUE IN THIS REPO. SEE https://github.com/inorichi/tachiyomi-extensions
|
DO NOT OPEN AN ISSUE IN THIS REPO. SEE https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||||
|
43
.github/workflows/build.yml
vendored
43
.github/workflows/build.yml
vendored
@ -2,7 +2,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- master
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
pull_request:
|
pull_request:
|
||||||
@ -34,10 +34,10 @@ jobs:
|
|||||||
- name: Clone repo
|
- name: Clone repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up JDK 1.8
|
- name: Set up JDK 11
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v1
|
||||||
with:
|
with:
|
||||||
java-version: 1.8
|
java-version: 11
|
||||||
|
|
||||||
- name: Copy CI gradle.properties
|
- name: Copy CI gradle.properties
|
||||||
run: |
|
run: |
|
||||||
@ -55,14 +55,14 @@ jobs:
|
|||||||
# Sign APK and create release for tags
|
# Sign APK and create release for tags
|
||||||
|
|
||||||
- name: Get tag name
|
- name: Get tag name
|
||||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
|
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||||
id: get_tag_name
|
id: get_tag_name
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
|
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
with:
|
with:
|
||||||
releaseDirectory: app/build/outputs/apk/standard/release
|
releaseDirectory: app/build/outputs/apk/standard/release
|
||||||
@ -71,25 +71,24 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Clean up build artifacts
|
||||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
|
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||||
id: create_release
|
run: |
|
||||||
uses: actions/create-release@v1
|
cp ${{ env.SIGNED_RELEASE_FILE }} tachiyomi-${{ env.VERSION_TAG }}.apk
|
||||||
env:
|
md5=`md5sum tachiyomi-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
echo "APK_MD5=$md5" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ env.VERSION_TAG }}
|
tag_name: ${{ env.VERSION_TAG }}
|
||||||
release_name: Tachiyomi ${{ env.VERSION_TAG }}
|
name: Tachiyomi ${{ env.VERSION_TAG }}
|
||||||
draft: true
|
body: |
|
||||||
|
MD5: ${{ env.APK_MD5 }}
|
||||||
|
files: |
|
||||||
|
tachiyomi-${{ env.VERSION_TAG }}.apk
|
||||||
|
draft: ${{ github.event.inputs.dry-run != '' }}
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
- name: Upload APK to release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'inorichi/tachiyomi'
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ${{ env.SIGNED_RELEASE_FILE }}
|
|
||||||
asset_name: tachiyomi-${{ env.VERSION_TAG }}.apk
|
|
||||||
asset_content_type: application/vnd.android.package-archive
|
|
||||||
|
55
.github/workflows/issue_closer.yml
vendored
55
.github/workflows/issue_closer.yml
vendored
@ -7,31 +7,30 @@ jobs:
|
|||||||
autoclose:
|
autoclose:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Autoclose when created in wrong repo
|
- name: Autoclose issues
|
||||||
uses: arkon/issue-closer-action@v1.1
|
uses: arkon/issue-closer-action@v3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
type: title
|
rules: |
|
||||||
regex: ".*THIS ISSUE IS IN THE WRONG REPO.*"
|
[
|
||||||
message: "@${issue.user.login} this issue was automatically closed because it was not opened in the correct repo, as the template mentioned."
|
{
|
||||||
- name: Autoclose when no short description provided
|
"type": "title",
|
||||||
uses: arkon/issue-closer-action@v1.1
|
"regex": ".*THIS ISSUE IS IN THE WRONG REPO.*",
|
||||||
with:
|
"message": "It was not opened in the correct repo, as the template mentioned."
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
},
|
||||||
type: title
|
{
|
||||||
regex: ".*<Write short description here>*"
|
"type": "title",
|
||||||
message: "@${issue.user.login} this issue was automatically closed because you did not fill out the description in the title."
|
"regex": ".*<Write short description here>*",
|
||||||
- name: Autoclose when body acknowledgement section not removed
|
"message": "The description in the title was not filled out."
|
||||||
uses: arkon/issue-closer-action@v1.1
|
},
|
||||||
with:
|
{
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
"type": "body",
|
||||||
type: body
|
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
|
||||||
regex: ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*"
|
"message": "The acknowledgment section was not removed."
|
||||||
message: "@${issue.user.login} this issue was automatically closed because the acknowledgment section was not removed."
|
},
|
||||||
- name: Autoclose when body requested information not filled out
|
{
|
||||||
uses: arkon/issue-closer-action@v1.1
|
"type": "body",
|
||||||
with:
|
"regex": ".*\\* (Tachiyomi version|Android version|Device): \\?.*",
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
"message": "Requested information in the template was not filled out."
|
||||||
type: body
|
}
|
||||||
regex: ".*\\* (Tachiyomi version|Android version|Device): \\?.*"
|
]
|
||||||
message: "@${issue.user.login} this issue was automatically closed because the requested information was not filled out."
|
|
||||||
|
19
.github/workflows/lock.yml
vendored
Normal file
19
.github/workflows/lock.yml
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
name: Lock threads
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Daily
|
||||||
|
schedule:
|
||||||
|
- cron: '0 * * * *'
|
||||||
|
# Manual trigger
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/lock-threads@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
issue-lock-inactive-days: '2'
|
||||||
|
pr-lock-inactive-days: '2'
|
76
CODE_OF_CONDUCT.md
Normal file
76
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
In the interest of fostering an open and welcoming environment, we as
|
||||||
|
contributors and maintainers pledge to making participation in our project and
|
||||||
|
our community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||||
|
level of experience, education, socio-economic status, nationality, personal
|
||||||
|
appearance, race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to creating a positive environment
|
||||||
|
include:
|
||||||
|
|
||||||
|
* Using welcoming and inclusive language
|
||||||
|
* Being respectful of differing viewpoints and experiences
|
||||||
|
* Gracefully accepting constructive criticism
|
||||||
|
* Focusing on what is best for the community
|
||||||
|
* Showing empathy towards other community members
|
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||||
|
advances
|
||||||
|
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or electronic
|
||||||
|
address, without explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Our Responsibilities
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying the standards of acceptable
|
||||||
|
behavior and are expected to take appropriate and fair corrective action in
|
||||||
|
response to any instances of unacceptable behavior.
|
||||||
|
|
||||||
|
Project maintainers have the right and responsibility to remove, edit, or
|
||||||
|
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||||
|
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||||
|
permanently any contributor for other behaviors that they deem inappropriate,
|
||||||
|
threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies both within project spaces and in public spaces
|
||||||
|
when an individual is representing the project or its community. Examples of
|
||||||
|
representing a project or community include using an official project e-mail
|
||||||
|
address, posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event. Representation of a project may be
|
||||||
|
further defined and clarified by project maintainers.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported by contacting the project team at the Tachiyomi [Discord server](https://discord.gg/tachiyomi). All
|
||||||
|
complaints will be reviewed and investigated and will result in a response that
|
||||||
|
is deemed necessary and appropriate to the circumstances. The project team is
|
||||||
|
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||||
|
Further details of specific enforcement policies may be posted separately.
|
||||||
|
|
||||||
|
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||||
|
faith may face temporary or permanent repercussions as determined by other
|
||||||
|
members of the project's leadership.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||||
|
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see
|
||||||
|
https://www.contributor-covenant.org/faq
|
34
CONTRIBUTING.md
Normal file
34
CONTRIBUTING.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
Looking to report an issue/bug or make a feature request? Please refer to the [README file](https://github.com/tachiyomiorg/tachiyomi#issues-feature-requests-and-contributing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thanks for your interest in contributing to Tachiyomi!
|
||||||
|
|
||||||
|
|
||||||
|
# Code contributions
|
||||||
|
|
||||||
|
Pull requests are welcome!
|
||||||
|
|
||||||
|
If you're interested in taking on [an open issue](https://github.com/tachiyomiorg/tachiyomi/issues), please comment on it so others are aware.
|
||||||
|
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
|
||||||
|
Translations are done externally via Weblate. See [our website](https://tachiyomi.org/help/contribution/#translation) for more details.
|
||||||
|
|
||||||
|
|
||||||
|
# Forks
|
||||||
|
|
||||||
|
Forks are allowed so long as they abide by [the project's LICENSE](https://github.com/tachiyomiorg/tachiyomi/blob/master/LICENSE).
|
||||||
|
|
||||||
|
When creating a fork, remember to:
|
||||||
|
|
||||||
|
- To avoid confusion with the main app:
|
||||||
|
- Change the app name
|
||||||
|
- Change the app icon
|
||||||
|
- Change or disable the [app update checker](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubUpdateChecker.kt)
|
||||||
|
- To avoid installation conflicts:
|
||||||
|
- Change the `applicationId` in [`build.gradle.kts`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/build.gradle.kts)
|
||||||
|
- To avoid having your data polluting the main app's analytics and crash report services:
|
||||||
|
- If you want to use Firebase analytics, replace [`google-services.json`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/standard/google-services.json) with your own
|
||||||
|
- If you want to use ACRA crash reporting, replace the `ACRA_URI` endpoint in [`build.gradle.kts`](https://github.com/tachiyomiorg/tachiyomi/blob/master/app/build.gradle.kts) with your own
|
26
LICENSE
26
LICENSE
@ -174,29 +174,3 @@
|
|||||||
of your accepting any such warranty or additional liability.
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright {yyyy} {name of copyright owner}
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
### r2903
|
||||||
|
- The MyAnimeList tracker was rewritten. You will need to log out and log in again.
|
||||||
|
|
||||||
### r1810
|
### r1810
|
||||||
- Background jobs were migrated to a new system. You may need to toggle the settings to ensure they
|
- Background jobs were migrated to a new system. You may need to toggle the settings to ensure they
|
||||||
run properly. This includes app updates, library updates, and automatic backups.
|
run properly. This includes app updates, library updates, and automatic backups.
|
||||||
|
24
README.md
24
README.md
@ -1,6 +1,6 @@
|
|||||||
| Build | Stable | Weekly Preview | Contribute | Support Server |
|
| Build | Stable | Weekly Preview | Contribute | Support Server |
|
||||||
|-------|----------|---------|------------|---------|
|
|-------|----------|---------|------------|---------|
|
||||||
|  | [](https://github.com/inorichi/tachiyomi/releases) | [](https://github.com/tachiyomiorg/android-app-preview/releases) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
|  | [](https://github.com/tachiyomiorg/tachiyomi/releases) | [](https://github.com/tachiyomiorg/tachiyomi-preview/releases) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
||||||
|
|
||||||
|
|
||||||
# Tachiyomi
|
# Tachiyomi
|
||||||
@ -11,7 +11,7 @@ Tachiyomi is a free and open source manga reader for Android 5.0 and above.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
Features include:
|
Features include:
|
||||||
* Online reading from sources such as MangaDex, MangaSee, Mangakakalot, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
* Online reading from sources such as MangaDex, MangaSee, Mangakakalot, [and more](https://github.com/tachiyomiorg/tachiyomi-extensions)
|
||||||
* Local reading of downloaded manga
|
* Local reading of downloaded manga
|
||||||
* A configurable reader with multiple viewers, reading directions and other settings.
|
* A configurable reader with multiple viewers, reading directions and other settings.
|
||||||
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) support
|
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) support
|
||||||
@ -21,7 +21,7 @@ Features include:
|
|||||||
* Create backups locally to read offline or to your desired cloud service
|
* Create backups locally to read offline or to your desired cloud service
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
|
Get the app from our [releases page](https://github.com/tachiyomiorg/tachiyomi/releases).
|
||||||
|
|
||||||
If you want to try new features before they get to the stable release, you can download the preview version [here](https://github.com/tachiyomiorg/android-app-preview/releases).
|
If you want to try new features before they get to the stable release, you can download the preview version [here](https://github.com/tachiyomiorg/android-app-preview/releases).
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
|
|||||||
|
|
||||||
<details><summary>Issues</summary>
|
<details><summary>Issues</summary>
|
||||||
|
|
||||||
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/inorichi/tachiyomi/releases) and the already opened [issues](https://github.com/inorichi/tachiyomi/issues).**
|
1. **Before reporting a new issue, take a look at the [FAQ](https://tachiyomi.org/help/faq/), the [changelog](https://github.com/tachiyomiorg/tachiyomi/releases) and the already opened [issues](https://github.com/tachiyomiorg/tachiyomi/issues).**
|
||||||
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
2. If you are unsure, ask here: [](https://discord.gg/tachiyomi)
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@ -47,9 +47,9 @@ Please make sure to read the full guidelines. Your issue may be closed without w
|
|||||||
* For large logs use http://pastebin.com/ (or similar)
|
* For large logs use http://pastebin.com/ (or similar)
|
||||||
* Don't group unrelated requests into one issue
|
* Don't group unrelated requests into one issue
|
||||||
|
|
||||||
DO: https://github.com/inorichi/tachiyomi/issues/24 https://github.com/inorichi/tachiyomi/issues/71
|
DO: https://github.com/tachiyomiorg/tachiyomi/issues/24 https://github.com/tachiyomiorg/tachiyomi/issues/71
|
||||||
|
|
||||||
DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
DON'T: https://github.com/tachiyomiorg/tachiyomi/issues/75
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@ -58,7 +58,17 @@ DON'T: https://github.com/inorichi/tachiyomi/issues/75
|
|||||||
* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does"
|
* Write a detailed issue, explaining what it should do or how. Avoid writing just "like X app does"
|
||||||
* Include screenshot (if needed)
|
* Include screenshot (if needed)
|
||||||
|
|
||||||
Source requests should be created at https://github.com/inorichi/tachiyomi-extensions, they do not belong in this repository.
|
Source requests should be created at https://github.com/tachiyomiorg/tachiyomi-extensions, they do not belong in this repository.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>Contributing</summary>
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>Code of Conduct</summary>
|
||||||
|
|
||||||
|
See [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md).
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
323
app/build.gradle
323
app/build.gradle
@ -1,323 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile
|
|
||||||
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'kotlin-kapt'
|
|
||||||
apply plugin: 'kotlin-parcelize'
|
|
||||||
apply plugin: 'kotlinx-serialization'
|
|
||||||
apply plugin: 'com.github.zellius.shortcut-helper'
|
|
||||||
|
|
||||||
shortcutHelper.filePath = './shortcuts.xml'
|
|
||||||
|
|
||||||
ext {
|
|
||||||
// Git is needed in your system PATH for these commands to work.
|
|
||||||
// If it's not installed, you can return a random value as a workaround
|
|
||||||
getCommitCount = {
|
|
||||||
return 'git rev-list --count HEAD'.execute().text.trim()
|
|
||||||
// return "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
getGitSha = {
|
|
||||||
return 'git rev-parse --short HEAD'.execute().text.trim()
|
|
||||||
// return "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
getBuildTime = {
|
|
||||||
def df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
|
|
||||||
df.setTimeZone(TimeZone.getTimeZone("UTC"))
|
|
||||||
return df.format(new Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdkVersion AndroidConfig.compileSdk
|
|
||||||
buildToolsVersion AndroidConfig.buildTools
|
|
||||||
ndkVersion AndroidConfig.ndk
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "eu.kanade.tachiyomi"
|
|
||||||
minSdkVersion AndroidConfig.minSdk
|
|
||||||
targetSdkVersion AndroidConfig.targetSdk
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
versionCode 53
|
|
||||||
versionName "0.10.7"
|
|
||||||
|
|
||||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
|
||||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
|
||||||
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
|
||||||
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
|
|
||||||
|
|
||||||
multiDexEnabled true
|
|
||||||
|
|
||||||
ndk {
|
|
||||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
versionNameSuffix "-${getCommitCount()}"
|
|
||||||
applicationIdSuffix ".debug"
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
// postprocessing {
|
|
||||||
// obfuscate false
|
|
||||||
// optimizeCode true
|
|
||||||
// removeUnusedCode false
|
|
||||||
// removeUnusedResources true
|
|
||||||
// proguardFiles 'proguard-rules.pro'
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flavorDimensions "default"
|
|
||||||
|
|
||||||
productFlavors {
|
|
||||||
standard {
|
|
||||||
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
|
|
||||||
dimension "default"
|
|
||||||
}
|
|
||||||
fdroid {
|
|
||||||
dimension "default"
|
|
||||||
}
|
|
||||||
dev {
|
|
||||||
resConfigs "en", "xxhdpi"
|
|
||||||
dimension "default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packagingOptions {
|
|
||||||
exclude 'META-INF/DEPENDENCIES'
|
|
||||||
exclude 'LICENSE.txt'
|
|
||||||
exclude 'META-INF/LICENSE'
|
|
||||||
exclude 'META-INF/LICENSE.txt'
|
|
||||||
exclude 'META-INF/NOTICE'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependenciesInfo {
|
|
||||||
includeInApk = false
|
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
disable 'MissingTranslation'
|
|
||||||
disable 'ExtraTranslation'
|
|
||||||
|
|
||||||
abortOnError false
|
|
||||||
checkReleaseBuilds false
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
|
|
||||||
// Source models and interfaces from Tachiyomi 1.x
|
|
||||||
implementation 'tachiyomi.sourceapi:source-api:1.1'
|
|
||||||
|
|
||||||
// AndroidX libraries
|
|
||||||
implementation 'androidx.annotation:annotation:1.2.0-alpha01'
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.3.0-alpha02'
|
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha01'
|
|
||||||
implementation 'androidx.browser:browser:1.3.0'
|
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.0-alpha1'
|
|
||||||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
|
|
||||||
implementation 'androidx.core:core-ktx:1.5.0-alpha05'
|
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
|
||||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.2.0-beta01'
|
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01'
|
|
||||||
|
|
||||||
final lifecycle_version = '2.3.0-beta01'
|
|
||||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
|
||||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
|
||||||
|
|
||||||
// Job scheduling
|
|
||||||
implementation "androidx.work:work-runtime-ktx:2.5.0-beta02"
|
|
||||||
|
|
||||||
// UI library
|
|
||||||
implementation 'com.google.android.material:material:1.3.0-alpha04'
|
|
||||||
|
|
||||||
standardImplementation 'com.google.firebase:firebase-core:18.0.0'
|
|
||||||
|
|
||||||
// ReactiveX
|
|
||||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
|
||||||
implementation 'io.reactivex:rxjava:1.3.8'
|
|
||||||
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
|
||||||
implementation 'com.github.pwittchen:reactivenetwork:0.13.0'
|
|
||||||
|
|
||||||
// Network client
|
|
||||||
final okhttp_version = '4.10.0-RC1'
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
|
||||||
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
|
|
||||||
implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttp_version"
|
|
||||||
implementation 'com.squareup.okio:okio:2.9.0'
|
|
||||||
|
|
||||||
// TLS 1.3 support for Android < 10
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.5.1'
|
|
||||||
|
|
||||||
// REST
|
|
||||||
final retrofit_version = '2.9.0'
|
|
||||||
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
|
|
||||||
implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
|
|
||||||
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
|
||||||
implementation "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
|
||||||
|
|
||||||
// JSON
|
|
||||||
final kotlin_serialization_version = '1.0.1'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_serialization_version"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlin_serialization_version"
|
|
||||||
implementation 'com.google.code.gson:gson:2.8.6'
|
|
||||||
implementation 'com.github.salomonbrys.kotson:kotson:2.5.0'
|
|
||||||
|
|
||||||
// JavaScript engine
|
|
||||||
implementation 'com.squareup.duktape:duktape-android:1.3.0'
|
|
||||||
|
|
||||||
// Disk
|
|
||||||
implementation 'com.jakewharton:disklrucache:2.0.2'
|
|
||||||
implementation 'com.github.inorichi:unifile:e9ee588'
|
|
||||||
implementation 'com.github.junrar:junrar:7.4.0'
|
|
||||||
|
|
||||||
// HTML parser
|
|
||||||
implementation 'org.jsoup:jsoup:1.13.1'
|
|
||||||
|
|
||||||
// Database
|
|
||||||
implementation 'androidx.sqlite:sqlite-ktx:2.1.0'
|
|
||||||
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
|
|
||||||
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
|
|
||||||
implementation 'io.requery:sqlite-android:3.33.0'
|
|
||||||
|
|
||||||
// Preferences
|
|
||||||
implementation 'com.github.tfcporciuncula.flow-preferences:flow-preferences:1.3.3'
|
|
||||||
|
|
||||||
// Model View Presenter
|
|
||||||
final nucleus_version = '3.0.0'
|
|
||||||
implementation "info.android15.nucleus:nucleus:$nucleus_version"
|
|
||||||
implementation "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
|
|
||||||
|
|
||||||
// Dependency injection
|
|
||||||
implementation "com.github.inorichi.injekt:injekt-core:65b0440"
|
|
||||||
|
|
||||||
// Image library
|
|
||||||
final glide_version = '4.11.0'
|
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
|
||||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
|
||||||
|
|
||||||
implementation 'com.github.tachiyomiorg:subsampling-scale-image-view:6caf219'
|
|
||||||
|
|
||||||
// Logging
|
|
||||||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
|
||||||
|
|
||||||
// Crash reports
|
|
||||||
implementation 'ch.acra:acra-http:5.7.0'
|
|
||||||
|
|
||||||
// Sort
|
|
||||||
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
|
||||||
|
|
||||||
// UI
|
|
||||||
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
|
||||||
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
|
||||||
implementation 'eu.davidea:flexible-adapter:5.1.0'
|
|
||||||
implementation 'eu.davidea:flexible-adapter-ui:1.0.0'
|
|
||||||
implementation 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0'
|
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
|
|
||||||
implementation 'com.github.carlosesco:DirectionalViewPager:a844dbca0a'
|
|
||||||
|
|
||||||
// 3.2.0+ introduces weird UI blinking or cut off issues on some devices
|
|
||||||
final material_dialogs_version = '3.1.1'
|
|
||||||
implementation "com.afollestad.material-dialogs:core:$material_dialogs_version"
|
|
||||||
implementation "com.afollestad.material-dialogs:input:$material_dialogs_version"
|
|
||||||
implementation "com.afollestad.material-dialogs:datetime:$material_dialogs_version"
|
|
||||||
|
|
||||||
// Conductor
|
|
||||||
implementation 'com.bluelinelabs:conductor:2.1.5'
|
|
||||||
implementation("com.bluelinelabs:conductor-support:2.1.5") {
|
|
||||||
exclude group: "com.android.support"
|
|
||||||
}
|
|
||||||
implementation 'com.github.tachiyomiorg:conductor-support-preference:1.1.1'
|
|
||||||
|
|
||||||
// FlowBinding
|
|
||||||
final flowbinding_version = '0.12.0'
|
|
||||||
implementation "io.github.reactivecircus.flowbinding:flowbinding-android:$flowbinding_version"
|
|
||||||
implementation "io.github.reactivecircus.flowbinding:flowbinding-appcompat:$flowbinding_version"
|
|
||||||
implementation "io.github.reactivecircus.flowbinding:flowbinding-recyclerview:$flowbinding_version"
|
|
||||||
implementation "io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbinding_version"
|
|
||||||
implementation "io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbinding_version"
|
|
||||||
|
|
||||||
// Licenses
|
|
||||||
implementation "com.mikepenz:aboutlibraries:$BuildPluginsVersion.ABOUTLIB_PLUGIN"
|
|
||||||
|
|
||||||
// Tests
|
|
||||||
testImplementation 'junit:junit:4.13'
|
|
||||||
testImplementation 'org.assertj:assertj-core:3.16.1'
|
|
||||||
testImplementation 'org.mockito:mockito-core:1.10.19'
|
|
||||||
|
|
||||||
final robolectric_version = '3.1.4'
|
|
||||||
testImplementation "org.robolectric:robolectric:$robolectric_version"
|
|
||||||
testImplementation "org.robolectric:shadows-multidex:$robolectric_version"
|
|
||||||
testImplementation "org.robolectric:shadows-play-services:$robolectric_version"
|
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$BuildPluginsVersion.KOTLIN"
|
|
||||||
|
|
||||||
final coroutines_version = '1.4.2'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
|
||||||
|
|
||||||
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
|
||||||
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.4'
|
|
||||||
}
|
|
||||||
|
|
||||||
buildscript {
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$BuildPluginsVersion.KOTLIN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
jcenter()
|
|
||||||
}
|
|
||||||
|
|
||||||
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
|
|
||||||
tasks.withType(AbstractKotlinCompile).all {
|
|
||||||
kotlinOptions.freeCompilerArgs += [
|
|
||||||
"-Xopt-in=kotlin.Experimental",
|
|
||||||
"-Xopt-in=kotlin.RequiresOptIn",
|
|
||||||
"-Xuse-experimental=kotlin.ExperimentalStdlibApi",
|
|
||||||
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
|
|
||||||
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
|
||||||
"-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicating Hebrew string assets due to some locale code issues on different devices
|
|
||||||
task copyHebrewStrings(type: Copy) {
|
|
||||||
from './src/main/res/values-he'
|
|
||||||
into './src/main/res/values-iw'
|
|
||||||
include '**/*'
|
|
||||||
}
|
|
||||||
|
|
||||||
preBuild.dependsOn(formatKotlin, copyHebrewStrings)
|
|
||||||
|
|
||||||
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Standard")) {
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
}
|
|
333
app/build.gradle.kts
Normal file
333
app/build.gradle.kts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("com.mikepenz.aboutlibraries.plugin")
|
||||||
|
kotlin("android")
|
||||||
|
kotlin("kapt")
|
||||||
|
kotlin("plugin.serialization")
|
||||||
|
id("com.github.zellius.shortcut-helper")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradle.startParameter.taskRequests.toString().contains("Standard")) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutHelper.setFilePath("./shortcuts.xml")
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion(AndroidConfig.compileSdk)
|
||||||
|
buildToolsVersion(AndroidConfig.buildTools)
|
||||||
|
ndkVersion = AndroidConfig.ndk
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "eu.kanade.tachiyomi"
|
||||||
|
minSdkVersion(AndroidConfig.minSdk)
|
||||||
|
targetSdkVersion(AndroidConfig.targetSdk)
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
versionCode = 57
|
||||||
|
versionName = "0.10.10"
|
||||||
|
|
||||||
|
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||||
|
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||||
|
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
|
||||||
|
buildConfigField("boolean", "INCLUDE_UPDATER", "false")
|
||||||
|
|
||||||
|
// Please disable ACRA or use your own instance in forked versions of the project
|
||||||
|
buildConfigField("String", "ACRA_URI", "\"https://tachiyomi.kanade.eu/crash_report\"")
|
||||||
|
|
||||||
|
multiDexEnabled = true
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += setOf("armeabi-v7a", "arm64-v8a", "x86")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
named("debug") {
|
||||||
|
versionNameSuffix = "-${getCommitCount()}"
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
|
named("release") {
|
||||||
|
/*named("postprocessing") {
|
||||||
|
postprocessing {
|
||||||
|
isObfuscate = false
|
||||||
|
isOptimizeCode = true
|
||||||
|
isRemoveUnusedCode = false
|
||||||
|
isRemoveUnusedResources = true
|
||||||
|
}
|
||||||
|
setProguardFiles(listOf(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"))
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions("default")
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("standard") {
|
||||||
|
buildConfigField("boolean", "INCLUDE_UPDATER", "true")
|
||||||
|
dimension = "default"
|
||||||
|
}
|
||||||
|
create("fdroid") {
|
||||||
|
dimension = "default"
|
||||||
|
}
|
||||||
|
create("dev") {
|
||||||
|
resConfigs("en", "xxhdpi")
|
||||||
|
dimension = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
exclude("META-INF/DEPENDENCIES")
|
||||||
|
exclude("LICENSE.txt")
|
||||||
|
exclude("META-INF/LICENSE")
|
||||||
|
exclude("META-INF/LICENSE.txt")
|
||||||
|
exclude("META-INF/NOTICE")
|
||||||
|
exclude("META-INF/*.kotlin_module")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
includeInApk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
disable("MissingTranslation", "ExtraTranslation")
|
||||||
|
isAbortOnError = false
|
||||||
|
isCheckReleaseBuilds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
// Source models and interfaces from Tachiyomi 1.x
|
||||||
|
implementation("tachiyomi.sourceapi:source-api:1.1")
|
||||||
|
|
||||||
|
// AndroidX libraries
|
||||||
|
implementation("androidx.annotation:annotation:1.3.0-alpha01")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.3.0-rc01")
|
||||||
|
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha03")
|
||||||
|
implementation("androidx.browser:browser:1.3.0")
|
||||||
|
implementation("androidx.cardview:cardview:1.0.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.0-beta01")
|
||||||
|
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
|
||||||
|
implementation("androidx.core:core-ktx:1.3.2")
|
||||||
|
implementation("androidx.multidex:multidex:2.0.1")
|
||||||
|
implementation("androidx.preference:preference-ktx:1.1.1")
|
||||||
|
implementation("androidx.recyclerview:recyclerview:1.2.0")
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
|
||||||
|
|
||||||
|
val lifecycleVersion = "2.3.0"
|
||||||
|
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
|
||||||
|
|
||||||
|
// Job scheduling
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.5.0")
|
||||||
|
|
||||||
|
// UI library
|
||||||
|
implementation("com.google.android.material:material:1.3.0")
|
||||||
|
|
||||||
|
"standardImplementation"("com.google.firebase:firebase-core:18.0.3")
|
||||||
|
|
||||||
|
// ReactiveX
|
||||||
|
implementation("io.reactivex:rxandroid:1.2.1")
|
||||||
|
implementation("io.reactivex:rxjava:1.3.8")
|
||||||
|
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
|
||||||
|
implementation("com.github.pwittchen:reactivenetwork:0.13.0")
|
||||||
|
|
||||||
|
// Network client
|
||||||
|
val okhttpVersion = "5.0.0-alpha.2"
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
|
||||||
|
implementation("com.squareup.okio:okio:2.10.0")
|
||||||
|
|
||||||
|
// TLS 1.3 support for Android < 10
|
||||||
|
implementation("org.conscrypt:conscrypt-android:2.5.1")
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
val kotlinSerializationVersion = "1.0.1"
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
|
||||||
|
implementation("com.google.code.gson:gson:2.8.6")
|
||||||
|
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
|
||||||
|
|
||||||
|
// JavaScript engine
|
||||||
|
implementation("com.squareup.duktape:duktape-android:1.3.0")
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
implementation("com.jakewharton:disklrucache:2.0.2")
|
||||||
|
implementation("com.github.tachiyomiorg:unifile:e9e3a40")
|
||||||
|
implementation("com.github.junrar:junrar:7.4.0")
|
||||||
|
|
||||||
|
// HTML parser
|
||||||
|
implementation("org.jsoup:jsoup:1.13.1")
|
||||||
|
|
||||||
|
// Database
|
||||||
|
implementation("androidx.sqlite:sqlite-ktx:2.1.0")
|
||||||
|
implementation("com.github.inorichi.storio:storio-common:8be19de@aar")
|
||||||
|
implementation("com.github.inorichi.storio:storio-sqlite:8be19de@aar")
|
||||||
|
implementation("io.requery:sqlite-android:3.33.0")
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
implementation("com.github.tfcporciuncula.flow-preferences:flow-preferences:1.3.4")
|
||||||
|
|
||||||
|
// Model View Presenter
|
||||||
|
val nucleusVersion = "3.0.0"
|
||||||
|
implementation("info.android15.nucleus:nucleus:$nucleusVersion")
|
||||||
|
implementation("info.android15.nucleus:nucleus-support-v7:$nucleusVersion")
|
||||||
|
|
||||||
|
// Dependency injection
|
||||||
|
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
|
||||||
|
|
||||||
|
// Image library
|
||||||
|
val glideVersion = "4.12.0"
|
||||||
|
implementation("com.github.bumptech.glide:glide:$glideVersion")
|
||||||
|
implementation("com.github.bumptech.glide:okhttp3-integration:$glideVersion")
|
||||||
|
kapt("com.github.bumptech.glide:compiler:$glideVersion")
|
||||||
|
|
||||||
|
implementation("com.github.tachiyomiorg:subsampling-scale-image-view:547d9c0")
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
implementation("com.jakewharton.timber:timber:4.7.1")
|
||||||
|
|
||||||
|
// Crash reports
|
||||||
|
implementation("ch.acra:acra-http:5.7.0")
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
|
||||||
|
|
||||||
|
// UI
|
||||||
|
implementation("com.dmitrymalkovich.android:material-design-dimens:1.4")
|
||||||
|
implementation("com.github.dmytrodanylyk.android-process-button:library:1.0.4")
|
||||||
|
implementation("eu.davidea:flexible-adapter:5.1.0")
|
||||||
|
implementation("eu.davidea:flexible-adapter-ui:1.0.0")
|
||||||
|
implementation("com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.1.0")
|
||||||
|
implementation("com.github.chrisbanes:PhotoView:2.3.0")
|
||||||
|
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0")
|
||||||
|
implementation("dev.chrisbanes.insetter:insetter:0.5.0")
|
||||||
|
|
||||||
|
// 3.2.0+ introduces weird UI blinking or cut off issues on some devices
|
||||||
|
val materialDialogsVersion = "3.1.1"
|
||||||
|
implementation("com.afollestad.material-dialogs:core:$materialDialogsVersion")
|
||||||
|
implementation("com.afollestad.material-dialogs:input:$materialDialogsVersion")
|
||||||
|
implementation("com.afollestad.material-dialogs:datetime:$materialDialogsVersion")
|
||||||
|
|
||||||
|
// Conductor
|
||||||
|
implementation("com.bluelinelabs:conductor:2.1.5")
|
||||||
|
implementation("com.bluelinelabs:conductor-support:2.1.5") {
|
||||||
|
exclude(group = "com.android.support")
|
||||||
|
}
|
||||||
|
implementation("com.github.tachiyomiorg:conductor-support-preference:2.0.1")
|
||||||
|
|
||||||
|
// FlowBinding
|
||||||
|
val flowbindingVersion = "0.12.0"
|
||||||
|
implementation("io.github.reactivecircus.flowbinding:flowbinding-android:$flowbindingVersion")
|
||||||
|
implementation("io.github.reactivecircus.flowbinding:flowbinding-appcompat:$flowbindingVersion")
|
||||||
|
implementation("io.github.reactivecircus.flowbinding:flowbinding-recyclerview:$flowbindingVersion")
|
||||||
|
implementation("io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbindingVersion")
|
||||||
|
implementation("io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbindingVersion")
|
||||||
|
|
||||||
|
// Licenses
|
||||||
|
implementation("com.mikepenz:aboutlibraries:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
|
||||||
|
|
||||||
|
// Tests
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.assertj:assertj-core:3.16.1")
|
||||||
|
testImplementation("org.mockito:mockito-core:1.10.19")
|
||||||
|
|
||||||
|
val robolectricVersion = "3.1.4"
|
||||||
|
testImplementation("org.robolectric:robolectric:$robolectricVersion")
|
||||||
|
testImplementation("org.robolectric:shadows-multidex:$robolectricVersion")
|
||||||
|
testImplementation("org.robolectric:shadows-play-services:$robolectricVersion")
|
||||||
|
|
||||||
|
implementation(kotlin("reflect", version = BuildPluginsVersion.KOTLIN))
|
||||||
|
|
||||||
|
val coroutinesVersion = "1.4.2"
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
|
||||||
|
|
||||||
|
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
||||||
|
// debugImplementation("com.squareup.leakcanary:leakcanary-android:2.6")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks {
|
||||||
|
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers)
|
||||||
|
withType<KotlinCompile> {
|
||||||
|
kotlinOptions.freeCompilerArgs += listOf(
|
||||||
|
"-Xopt-in=kotlin.Experimental",
|
||||||
|
"-Xopt-in=kotlin.RequiresOptIn",
|
||||||
|
"-Xuse-experimental=kotlin.ExperimentalStdlibApi",
|
||||||
|
"-Xuse-experimental=kotlinx.coroutines.FlowPreview",
|
||||||
|
"-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
|
"-Xuse-experimental=kotlinx.coroutines.InternalCoroutinesApi",
|
||||||
|
"-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicating Hebrew string assets due to some locale code issues on different devices
|
||||||
|
val copyHebrewStrings = task("copyHebrewStrings", type = Copy::class) {
|
||||||
|
from("./src/main/res/values-he")
|
||||||
|
into("./src/main/res/values-iw")
|
||||||
|
include("**/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
preBuild {
|
||||||
|
dependsOn(formatKotlin, copyHebrewStrings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath(kotlin("gradle-plugin", version = BuildPluginsVersion.KOTLIN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Git is needed in your system PATH for these commands to work.
|
||||||
|
// If it's not installed, you can return a random value as a workaround
|
||||||
|
fun getCommitCount(): String {
|
||||||
|
return runCommand("git rev-list --count HEAD")
|
||||||
|
// return "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGitSha(): String {
|
||||||
|
return runCommand("git rev-parse --short HEAD")
|
||||||
|
// return "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBuildTime(): String {
|
||||||
|
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
|
||||||
|
df.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
return df.format(Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runCommand(command: String): String {
|
||||||
|
val byteOut = ByteArrayOutputStream()
|
||||||
|
project.exec {
|
||||||
|
commandLine = command.split(" ")
|
||||||
|
standardOutput = byteOut
|
||||||
|
}
|
||||||
|
return String(byteOut.toByteArray()).trim()
|
||||||
|
}
|
@ -2,16 +2,24 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="eu.kanade.tachiyomi">
|
package="eu.kanade.tachiyomi">
|
||||||
|
|
||||||
|
<!-- Internet -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
|
||||||
|
<!-- Storage -->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<!-- For background jobs -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!-- For managing extensions -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<!-- To view extension packages in API 30+ -->
|
||||||
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
@ -43,7 +51,7 @@
|
|||||||
android:name=".ui.main.DeepLinkActivity"
|
android:name=".ui.main.DeepLinkActivity"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@android:style/Theme.NoDisplay"
|
android:theme="@android:style/Theme.NoDisplay"
|
||||||
android:label="@string/process_text_action_name">
|
android:label="@string/action_global_search">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEARCH" />
|
<action android:name="android.intent.action.SEARCH" />
|
||||||
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
|
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
|
||||||
@ -55,7 +63,7 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@ -96,7 +104,18 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.setting.track.MyAnimeListLoginActivity"
|
android:name=".ui.setting.track.MyAnimeListLoginActivity"
|
||||||
android:configChanges="uiMode|orientation|screenSize" />
|
android:label="MyAnimeList">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="myanimelist-auth"
|
||||||
|
android:scheme="tachiyomi" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.setting.track.ShikimoriLoginActivity"
|
android:name=".ui.setting.track.ShikimoriLoginActivity"
|
||||||
android:label="Shikimori">
|
android:label="Shikimori">
|
||||||
|
@ -20,9 +20,7 @@ import org.acra.sender.HttpSender
|
|||||||
import org.conscrypt.Conscrypt
|
import org.conscrypt.Conscrypt
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.InjektScope
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import uy.kohesive.injekt.registry.default.DefaultRegistrar
|
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
|
|
||||||
@AcraCore(
|
@AcraCore(
|
||||||
@ -30,30 +28,22 @@ import java.security.Security
|
|||||||
excludeMatchingSharedPreferencesKeys = [".*username.*", ".*password.*", ".*token.*"]
|
excludeMatchingSharedPreferencesKeys = [".*username.*", ".*password.*", ".*token.*"]
|
||||||
)
|
)
|
||||||
@AcraHttpSender(
|
@AcraHttpSender(
|
||||||
uri = "https://tachiyomi.kanade.eu/crash_report",
|
uri = BuildConfig.ACRA_URI,
|
||||||
httpMethod = HttpSender.Method.PUT
|
httpMethod = HttpSender.Method.PUT
|
||||||
)
|
)
|
||||||
open class App : Application(), LifecycleObserver {
|
open class App : Application(), LifecycleObserver {
|
||||||
|
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||||
|
|
||||||
// Debug tool; see https://fbflipper.com/
|
|
||||||
// SoLoader.init(this, false)
|
|
||||||
// if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(this)) {
|
|
||||||
// val client = AndroidFlipperClient.getInstance(this)
|
|
||||||
// client.addPlugin(InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()))
|
|
||||||
// client.addPlugin(DatabasesFlipperPlugin(this))
|
|
||||||
// client.start()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TLS 1.3 support for Android < 10
|
// TLS 1.3 support for Android < 10
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Injekt = InjektScope(DefaultRegistrar())
|
|
||||||
Injekt.importModule(AppModule(this))
|
Injekt.importModule(AppModule(this))
|
||||||
|
|
||||||
setupAcra()
|
setupAcra()
|
||||||
@ -77,14 +67,15 @@ open class App : Application(), LifecycleObserver {
|
|||||||
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun onAppBackgrounded() {
|
fun onAppBackgrounded() {
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
|
||||||
if (preferences.lockAppAfter().get() >= 0) {
|
if (preferences.lockAppAfter().get() >= 0) {
|
||||||
SecureActivityDelegate.locked = true
|
SecureActivityDelegate.locked = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun setupAcra() {
|
protected open fun setupAcra() {
|
||||||
ACRA.init(this)
|
if (BuildConfig.FLAVOR != "dev") {
|
||||||
|
ACRA.init(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun setupNotificationChannels() {
|
protected open fun setupNotificationChannels() {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi
|
package eu.kanade.tachiyomi
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Handler
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||||
@ -11,8 +12,6 @@ import eu.kanade.tachiyomi.data.track.TrackManager
|
|||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import uy.kohesive.injekt.api.InjektModule
|
import uy.kohesive.injekt.api.InjektModule
|
||||||
import uy.kohesive.injekt.api.InjektRegistrar
|
import uy.kohesive.injekt.api.InjektRegistrar
|
||||||
@ -48,15 +47,16 @@ class AppModule(val app: Application) : InjektModule {
|
|||||||
addSingletonFactory { Json { ignoreUnknownKeys = true } }
|
addSingletonFactory { Json { ignoreUnknownKeys = true } }
|
||||||
|
|
||||||
// Asynchronously init expensive components for a faster cold start
|
// Asynchronously init expensive components for a faster cold start
|
||||||
|
Handler().post {
|
||||||
|
get<PreferencesHelper>()
|
||||||
|
|
||||||
GlobalScope.launch { get<PreferencesHelper>() }
|
get<NetworkHelper>()
|
||||||
|
|
||||||
GlobalScope.launch { get<NetworkHelper>() }
|
get<SourceManager>()
|
||||||
|
|
||||||
GlobalScope.launch { get<SourceManager>() }
|
get<DatabaseHelper>()
|
||||||
|
|
||||||
GlobalScope.launch { get<DatabaseHelper>() }
|
get<DownloadManager>()
|
||||||
|
}
|
||||||
GlobalScope.launch { get<DownloadManager>() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
import eu.kanade.tachiyomi.data.updater.UpdaterJob
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
|
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
|
||||||
import eu.kanade.tachiyomi.ui.library.LibrarySort
|
import eu.kanade.tachiyomi.ui.library.LibrarySort
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
|
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
|
||||||
@ -116,14 +117,28 @@ object Migrations {
|
|||||||
putInt(PreferenceKeys.filterCompleted, convertBooleanPrefToTriState("pref_filter_completed_key"))
|
putInt(PreferenceKeys.filterCompleted, convertBooleanPrefToTriState("pref_filter_completed_key"))
|
||||||
remove("pref_filter_completed_key")
|
remove("pref_filter_completed_key")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (oldVersion < 54) {
|
||||||
// Force MAL log out due to login flow change
|
// Force MAL log out due to login flow change
|
||||||
|
// v52: switched from scraping to WebView
|
||||||
|
// v53: switched from WebView to OAuth
|
||||||
val trackManager = Injekt.get<TrackManager>()
|
val trackManager = Injekt.get<TrackManager>()
|
||||||
if (trackManager.myAnimeList.isLogged) {
|
if (trackManager.myAnimeList.isLogged) {
|
||||||
trackManager.myAnimeList.logout()
|
trackManager.myAnimeList.logout()
|
||||||
context.toast(R.string.myanimelist_relogin)
|
context.toast(R.string.myanimelist_relogin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 57) {
|
||||||
|
// Migrate DNS over HTTPS setting
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val wasDohEnabled = prefs.getBoolean("enable_doh", false)
|
||||||
|
if (wasDohEnabled) {
|
||||||
|
prefs.edit {
|
||||||
|
putInt(PreferenceKeys.dohProvider, PREF_DOH_CLOUDFLARE)
|
||||||
|
remove("enable_doh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,12 +5,13 @@ import android.net.Uri
|
|||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.source.model.toSChapter
|
||||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
abstract class AbstractBackupManager(protected val context: Context) {
|
abstract class AbstractBackupManager(protected val context: Context) {
|
||||||
@ -31,22 +32,22 @@ abstract class AbstractBackupManager(protected val context: Context) {
|
|||||||
databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches chapter information
|
* Fetches chapter information.
|
||||||
*
|
*
|
||||||
* @param source source of manga
|
* @param source source of manga
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @param chapters list of chapters in the backup
|
* @param chapters list of chapters in the backup
|
||||||
* @return [Observable] that contains manga
|
* @return Updated manga chapters.
|
||||||
*/
|
*/
|
||||||
internal fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
internal suspend fun restoreChapters(source: Source, manga: Manga, chapters: List<Chapter>): Pair<List<Chapter>, List<Chapter>> {
|
||||||
return source.fetchChapterList(manga)
|
val fetchedChapters = source.getChapterList(manga.toMangaInfo())
|
||||||
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
|
.map { it.toSChapter() }
|
||||||
.doOnNext { (first) ->
|
val syncedChapters = syncChaptersWithSource(databaseHelper, fetchedChapters, manga, source)
|
||||||
if (first.isNotEmpty()) {
|
if (syncedChapters.first.isNotEmpty()) {
|
||||||
chapters.forEach { it.manga_id = manga.id }
|
chapters.forEach { it.manga_id = manga.id }
|
||||||
updateChapters(chapters)
|
updateChapters(chapters)
|
||||||
}
|
}
|
||||||
}
|
return syncedChapters
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,6 +80,13 @@ abstract class AbstractBackupManager(protected val context: Context) {
|
|||||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a list of chapters with known database ids
|
||||||
|
*/
|
||||||
|
protected fun updateKnownChapters(chapters: List<Chapter>) {
|
||||||
|
databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return number of backups.
|
* Return number of backups.
|
||||||
*
|
*
|
||||||
|
@ -10,8 +10,8 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
||||||
|
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -37,9 +37,9 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
|
|||||||
|
|
||||||
protected val errors = mutableListOf<Pair<Date, String>>()
|
protected val errors = mutableListOf<Pair<Date, String>>()
|
||||||
|
|
||||||
abstract fun performRestore(uri: Uri): Boolean
|
abstract suspend fun performRestore(uri: Uri): Boolean
|
||||||
|
|
||||||
fun restoreBackup(uri: Uri): Boolean {
|
suspend fun restoreBackup(uri: Uri): Boolean {
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
restoreProgress = 0
|
restoreProgress = 0
|
||||||
errors.clear()
|
errors.clear()
|
||||||
@ -58,48 +58,48 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches chapter information
|
* Fetches chapter information.
|
||||||
*
|
*
|
||||||
* @param source source of manga
|
* @param source source of manga
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @return [Observable] that contains manga
|
* @return Updated manga chapters.
|
||||||
*/
|
*/
|
||||||
internal fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
internal suspend fun updateChapters(source: Source, manga: Manga, chapters: List<Chapter>): Pair<List<Chapter>, List<Chapter>> {
|
||||||
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
|
return try {
|
||||||
|
backupManager.restoreChapters(source, manga, chapters)
|
||||||
|
} catch (e: Exception) {
|
||||||
// If there's any error, return empty update and continue.
|
// If there's any error, return empty update and continue.
|
||||||
.onErrorReturn {
|
val errorMessage = if (e is NoChaptersException) {
|
||||||
val errorMessage = if (it is NoChaptersException) {
|
context.getString(R.string.no_chapters_error)
|
||||||
context.getString(R.string.no_chapters_error)
|
} else {
|
||||||
} else {
|
e.message
|
||||||
it.message
|
|
||||||
}
|
|
||||||
errors.add(Date() to "${manga.title} - $errorMessage")
|
|
||||||
Pair(emptyList(), emptyList())
|
|
||||||
}
|
}
|
||||||
|
errors.add(Date() to "${manga.title} - $errorMessage")
|
||||||
|
Pair(emptyList(), emptyList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that refreshes tracking information
|
* Refreshes tracking information.
|
||||||
|
*
|
||||||
* @param manga manga that needs updating.
|
* @param manga manga that needs updating.
|
||||||
* @param tracks list containing tracks from restore file.
|
* @param tracks list containing tracks from restore file.
|
||||||
* @return [Observable] that contains updated track item
|
|
||||||
*/
|
*/
|
||||||
internal fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> {
|
internal suspend fun updateTracking(manga: Manga, tracks: List<Track>) {
|
||||||
return Observable.from(tracks)
|
tracks.forEach { track ->
|
||||||
.flatMap { track ->
|
val service = trackManager.getService(track.sync_id)
|
||||||
val service = trackManager.getService(track.sync_id)
|
if (service != null && service.isLogged) {
|
||||||
if (service != null && service.isLogged) {
|
try {
|
||||||
service.refresh(track)
|
val updatedTrack = service.refresh(track)
|
||||||
.doOnNext { db.insertTrack(it).executeAsBlocking() }
|
db.insertTrack(updatedTrack).executeAsBlocking()
|
||||||
.onErrorReturn {
|
} catch (e: Exception) {
|
||||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
errors.add(Date() to "${manga.title} - ${e.message}")
|
||||||
track
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, service?.name)}")
|
|
||||||
Observable.empty()
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val serviceName = service?.nameRes()?.let { context.getString(it) }
|
||||||
|
errors.add(Date() to "${manga.title} - ${context.getString(R.string.tracker_not_logged_in, serviceName)}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,15 +120,15 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(protected val co
|
|||||||
internal fun writeErrorLog(): File {
|
internal fun writeErrorLog(): File {
|
||||||
try {
|
try {
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
val destFile = File(context.externalCacheDir, "tachiyomi_restore.txt")
|
val file = context.createFileInCacheDir("tachiyomi_restore.txt")
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
destFile.bufferedWriter().use { out ->
|
file.bufferedWriter().use { out ->
|
||||||
errors.forEach { (date, message) ->
|
errors.forEach { (date, message) ->
|
||||||
out.write("[${sdf.format(date)}] $message\n")
|
out.write("[${sdf.format(date)}] $message\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return destFile
|
return file
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Empty
|
// Empty
|
||||||
|
@ -111,7 +111,7 @@ class BackupCreateService : Service() {
|
|||||||
|
|
||||||
val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri()
|
val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri()
|
||||||
val unifile = UniFile.fromUri(this, backupFileUri)
|
val unifile = UniFile.fromUri(this, backupFileUri)
|
||||||
notifier.showBackupComplete(unifile)
|
notifier.showBackupComplete(unifile, backupType == BackupConst.BACKUP_TYPE_LEGACY)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
notifier.showBackupError(e.message)
|
notifier.showBackupError(e.message)
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
setSmallIcon(R.drawable.ic_tachi)
|
setSmallIcon(R.drawable.ic_tachi)
|
||||||
setAutoCancel(false)
|
setAutoCancel(false)
|
||||||
setOngoing(true)
|
setOngoing(true)
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_COMPLETE) {
|
private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_COMPLETE) {
|
||||||
@ -41,7 +42,6 @@ class BackupNotifier(private val context: Context) {
|
|||||||
setContentTitle(context.getString(R.string.creating_backup))
|
setContentTitle(context.getString(R.string.creating_backup))
|
||||||
|
|
||||||
setProgress(0, 0, true)
|
setProgress(0, 0, true)
|
||||||
setOnlyAlertOnce(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.show(Notifications.ID_BACKUP_PROGRESS)
|
builder.show(Notifications.ID_BACKUP_PROGRESS)
|
||||||
@ -60,7 +60,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showBackupComplete(unifile: UniFile) {
|
fun showBackupComplete(unifile: UniFile, isLegacyFormat: Boolean) {
|
||||||
context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS)
|
context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS)
|
||||||
|
|
||||||
with(completeNotificationBuilder) {
|
with(completeNotificationBuilder) {
|
||||||
@ -68,14 +68,12 @@ class BackupNotifier(private val context: Context) {
|
|||||||
setContentText(unifile.filePath ?: unifile.name)
|
setContentText(unifile.filePath ?: unifile.name)
|
||||||
|
|
||||||
// Clear old actions if they exist
|
// Clear old actions if they exist
|
||||||
if (mActions.isNotEmpty()) {
|
clearActions()
|
||||||
mActions.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
addAction(
|
addAction(
|
||||||
R.drawable.ic_share_24dp,
|
R.drawable.ic_share_24dp,
|
||||||
context.getString(R.string.action_share),
|
context.getString(R.string.action_share),
|
||||||
NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, Notifications.ID_BACKUP_COMPLETE)
|
NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, isLegacyFormat, Notifications.ID_BACKUP_COMPLETE)
|
||||||
)
|
)
|
||||||
|
|
||||||
show(Notifications.ID_BACKUP_COMPLETE)
|
show(Notifications.ID_BACKUP_COMPLETE)
|
||||||
@ -94,9 +92,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
setOnlyAlertOnce(true)
|
setOnlyAlertOnce(true)
|
||||||
|
|
||||||
// Clear old actions if they exist
|
// Clear old actions if they exist
|
||||||
if (mActions.isNotEmpty()) {
|
clearActions()
|
||||||
mActions.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
addAction(
|
addAction(
|
||||||
R.drawable.ic_close_24dp,
|
R.drawable.ic_close_24dp,
|
||||||
@ -137,9 +133,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
|
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
|
||||||
|
|
||||||
// Clear old actions if they exist
|
// Clear old actions if they exist
|
||||||
if (mActions.isNotEmpty()) {
|
clearActions()
|
||||||
mActions.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) {
|
if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) {
|
||||||
val destFile = File(path, file)
|
val destFile = File(path, file)
|
||||||
@ -147,7 +141,7 @@ class BackupNotifier(private val context: Context) {
|
|||||||
|
|
||||||
addAction(
|
addAction(
|
||||||
R.drawable.ic_folder_24dp,
|
R.drawable.ic_folder_24dp,
|
||||||
context.getString(R.string.action_open_log),
|
context.getString(R.string.action_show_errors),
|
||||||
NotificationReceiver.openErrorLogPendingActivity(context, uri)
|
NotificationReceiver.openErrorLogPendingActivity(context, uri)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.notification.Notifications
|
|||||||
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
||||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@ -40,12 +43,11 @@ class BackupRestoreService : Service() {
|
|||||||
* @param context context of application
|
* @param context context of application
|
||||||
* @param uri path of Uri
|
* @param uri path of Uri
|
||||||
*/
|
*/
|
||||||
fun start(context: Context, uri: Uri, mode: Int, online: Boolean?) {
|
fun start(context: Context, uri: Uri, mode: Int) {
|
||||||
if (!isRunning(context)) {
|
if (!isRunning(context)) {
|
||||||
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
||||||
putExtra(BackupConst.EXTRA_URI, uri)
|
putExtra(BackupConst.EXTRA_URI, uri)
|
||||||
putExtra(BackupConst.EXTRA_MODE, mode)
|
putExtra(BackupConst.EXTRA_MODE, mode)
|
||||||
online?.let { putExtra(BackupConst.EXTRA_TYPE, it) }
|
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
}
|
}
|
||||||
@ -68,12 +70,14 @@ class BackupRestoreService : Service() {
|
|||||||
*/
|
*/
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
|
|
||||||
|
private lateinit var ioScope: CoroutineScope
|
||||||
private var backupRestore: AbstractBackupRestore<*>? = null
|
private var backupRestore: AbstractBackupRestore<*>? = null
|
||||||
private lateinit var notifier: BackupNotifier
|
private lateinit var notifier: BackupNotifier
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
notifier = BackupNotifier(this)
|
notifier = BackupNotifier(this)
|
||||||
wakeLock = acquireWakeLock(javaClass.name)
|
wakeLock = acquireWakeLock(javaClass.name)
|
||||||
|
|
||||||
@ -92,6 +96,7 @@ class BackupRestoreService : Service() {
|
|||||||
|
|
||||||
private fun destroyJob() {
|
private fun destroyJob() {
|
||||||
backupRestore?.job?.cancel()
|
backupRestore?.job?.cancel()
|
||||||
|
ioScope?.cancel()
|
||||||
if (wakeLock.isHeld) {
|
if (wakeLock.isHeld) {
|
||||||
wakeLock.release()
|
wakeLock.release()
|
||||||
}
|
}
|
||||||
@ -113,15 +118,15 @@ class BackupRestoreService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
|
val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
|
||||||
val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL)
|
val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL)
|
||||||
val online = intent.getBooleanExtra(BackupConst.EXTRA_TYPE, true)
|
|
||||||
|
|
||||||
// Cancel any previous job if needed.
|
// Cancel any previous job if needed.
|
||||||
backupRestore?.job?.cancel()
|
backupRestore?.job?.cancel()
|
||||||
|
|
||||||
backupRestore = when (mode) {
|
backupRestore = when (mode) {
|
||||||
BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier, online)
|
BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier)
|
||||||
else -> LegacyBackupRestore(this, notifier)
|
else -> LegacyBackupRestore(this, notifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
val handler = CoroutineExceptionHandler { _, exception ->
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
Timber.e(exception)
|
Timber.e(exception)
|
||||||
backupRestore?.writeErrorLog()
|
backupRestore?.writeErrorLog()
|
||||||
@ -129,14 +134,15 @@ class BackupRestoreService : Service() {
|
|||||||
notifier.showRestoreError(exception.message)
|
notifier.showRestoreError(exception.message)
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
}
|
}
|
||||||
backupRestore?.job = GlobalScope.launch(handler) {
|
val job = ioScope.launch(handler) {
|
||||||
if (backupRestore?.restoreBackup(uri) == false) {
|
if (backupRestore?.restoreBackup(uri) == false) {
|
||||||
notifier.showRestoreError(getString(R.string.restoring_backup_canceled))
|
notifier.showRestoreError(getString(R.string.restoring_backup_canceled))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backupRestore?.job?.invokeOnCompletion {
|
job.invokeOnCompletion {
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
}
|
}
|
||||||
|
backupRestore?.job = job
|
||||||
|
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
@ -26,17 +26,13 @@ import eu.kanade.tachiyomi.data.database.models.History
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.source.Source
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.sink
|
import okio.sink
|
||||||
import rx.Observable
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
||||||
|
|
||||||
val parser = ProtoBuf
|
val parser = ProtoBuf
|
||||||
@ -182,29 +178,15 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches manga information
|
* Fetches manga information
|
||||||
*
|
*
|
||||||
* @param source source of manga
|
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @return [Observable] that contains manga
|
* @return Updated manga info.
|
||||||
*/
|
*/
|
||||||
fun restoreMangaFetchObservable(source: Source?, manga: Manga, online: Boolean): Observable<Manga> {
|
fun restoreManga(manga: Manga): Manga {
|
||||||
return if (online && source != null) {
|
return manga.also {
|
||||||
source.fetchMangaDetails(manga)
|
it.initialized = it.description != null
|
||||||
.map { networkManga ->
|
it.id = insertManga(it)
|
||||||
manga.copyFrom(networkManga)
|
|
||||||
manga.favorite = manga.favorite
|
|
||||||
manga.initialized = true
|
|
||||||
manga.id = insertManga(manga)
|
|
||||||
manga
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Observable.just(manga)
|
|
||||||
.map {
|
|
||||||
it.initialized = it.description != null
|
|
||||||
it.id = insertManga(it)
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,7 +231,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
*/
|
*/
|
||||||
internal fun restoreCategoriesForManga(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
|
internal fun restoreCategoriesForManga(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
|
||||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||||
val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
|
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(categories.size)
|
||||||
categories.forEach { backupCategoryOrder ->
|
categories.forEach { backupCategoryOrder ->
|
||||||
backupCategories.firstOrNull {
|
backupCategories.firstOrNull {
|
||||||
it.order == backupCategoryOrder
|
it.order == backupCategoryOrder
|
||||||
@ -276,7 +258,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
*/
|
*/
|
||||||
internal fun restoreHistoryForManga(history: List<BackupHistory>) {
|
internal fun restoreHistoryForManga(history: List<BackupHistory>) {
|
||||||
// List containing history to be updated
|
// List containing history to be updated
|
||||||
val historyToBeUpdated = mutableListOf<History>()
|
val historyToBeUpdated = ArrayList<History>(history.size)
|
||||||
for ((url, lastRead) in history) {
|
for ((url, lastRead) in history) {
|
||||||
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
||||||
// Check if history already in database and update
|
// Check if history already in database and update
|
||||||
@ -313,29 +295,26 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
val trackToUpdate = mutableListOf<Track>()
|
val trackToUpdate = mutableListOf<Track>()
|
||||||
|
|
||||||
tracks.forEach { track ->
|
tracks.forEach { track ->
|
||||||
val service = trackManager.getService(track.sync_id)
|
var isInDatabase = false
|
||||||
if (service != null && service.isLogged) {
|
for (dbTrack in dbTracks) {
|
||||||
var isInDatabase = false
|
if (track.sync_id == dbTrack.sync_id) {
|
||||||
for (dbTrack in dbTracks) {
|
// The sync is already in the db, only update its fields
|
||||||
if (track.sync_id == dbTrack.sync_id) {
|
if (track.media_id != dbTrack.media_id) {
|
||||||
// The sync is already in the db, only update its fields
|
dbTrack.media_id = track.media_id
|
||||||
if (track.media_id != dbTrack.media_id) {
|
|
||||||
dbTrack.media_id = track.media_id
|
|
||||||
}
|
|
||||||
if (track.library_id != dbTrack.library_id) {
|
|
||||||
dbTrack.library_id = track.library_id
|
|
||||||
}
|
|
||||||
dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
|
|
||||||
isInDatabase = true
|
|
||||||
trackToUpdate.add(dbTrack)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
if (track.library_id != dbTrack.library_id) {
|
||||||
|
dbTrack.library_id = track.library_id
|
||||||
|
}
|
||||||
|
dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||||
|
isInDatabase = true
|
||||||
|
trackToUpdate.add(dbTrack)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (!isInDatabase) {
|
}
|
||||||
// Insert new sync. Let the db assign the id
|
if (!isInDatabase) {
|
||||||
track.id = null
|
// Insert new sync. Let the db assign the id
|
||||||
trackToUpdate.add(track)
|
track.id = null
|
||||||
}
|
trackToUpdate.add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update database
|
// Update database
|
||||||
@ -344,25 +323,12 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
|
||||||
* Restore the chapters for manga if chapters already in database
|
|
||||||
*
|
|
||||||
* @param manga manga of chapters
|
|
||||||
* @param chapters list containing chapters that get restored
|
|
||||||
* @return boolean answering if chapter fetch is not needed
|
|
||||||
*/
|
|
||||||
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
|
|
||||||
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||||
|
|
||||||
// Return if fetch is needed
|
|
||||||
if (dbChapters.isEmpty() || dbChapters.size < chapters.size) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
chapters.forEach { chapter ->
|
chapters.forEach { chapter ->
|
||||||
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
|
val dbChapter = dbChapters.find { it.url == chapter.url }
|
||||||
if (pos != -1) {
|
if (dbChapter != null) {
|
||||||
val dbChapter = dbChapters[pos]
|
|
||||||
chapter.id = dbChapter.id
|
chapter.id = dbChapter.id
|
||||||
chapter.copyFrom(dbChapter)
|
chapter.copyFrom(dbChapter)
|
||||||
if (dbChapter.read && !chapter.read) {
|
if (dbChapter.read && !chapter.read) {
|
||||||
@ -375,38 +341,12 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|||||||
chapter.bookmark = dbChapter.bookmark
|
chapter.bookmark = dbChapter.bookmark
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chapter.manga_id = manga.id
|
||||||
}
|
}
|
||||||
// Filter the chapters that couldn't be found.
|
|
||||||
chapters.filter { it.id != null }
|
|
||||||
chapters.map { it.manga_id = manga.id }
|
|
||||||
|
|
||||||
updateChapters(chapters)
|
val newChapters = chapters.groupBy { it.id != null }
|
||||||
return true
|
newChapters[true]?.let { updateKnownChapters(it) }
|
||||||
}
|
newChapters[false]?.let { insertChapters(it) }
|
||||||
|
|
||||||
internal fun restoreChaptersForMangaOffline(manga: Manga, chapters: List<Chapter>) {
|
|
||||||
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
|
||||||
|
|
||||||
chapters.forEach { chapter ->
|
|
||||||
val pos = dbChapters.indexOfFirst { it.url == chapter.url }
|
|
||||||
if (pos != -1) {
|
|
||||||
val dbChapter = dbChapters[pos]
|
|
||||||
chapter.id = dbChapter.id
|
|
||||||
chapter.copyFrom(dbChapter)
|
|
||||||
if (dbChapter.read && !chapter.read) {
|
|
||||||
chapter.read = dbChapter.read
|
|
||||||
chapter.last_page_read = dbChapter.last_page_read
|
|
||||||
} else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
|
|
||||||
chapter.last_page_read = dbChapter.last_page_read
|
|
||||||
}
|
|
||||||
if (!chapter.bookmark && dbChapter.bookmark) {
|
|
||||||
chapter.bookmark = dbChapter.bookmark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chapters.map { it.manga_id = manga.id }
|
|
||||||
|
|
||||||
updateChapters(chapters.filter { it.id != null })
|
|
||||||
insertChapters(chapters.filter { it.id == null })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,18 +12,14 @@ import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.source.Source
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.source
|
import okio.source
|
||||||
import rx.Observable
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore<FullBackupManager>(context, notifier) {
|
||||||
class FullBackupRestore(context: Context, notifier: BackupNotifier, private val online: Boolean) : AbstractBackupRestore<FullBackupManager>(context, notifier) {
|
|
||||||
|
|
||||||
override fun performRestore(uri: Uri): Boolean {
|
override suspend fun performRestore(uri: Uri): Boolean {
|
||||||
backupManager = FullBackupManager(context)
|
backupManager = FullBackupManager(context)
|
||||||
|
|
||||||
val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() }
|
val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() }
|
||||||
@ -45,9 +41,11 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreManga(it, backup.backupCategories, online)
|
restoreManga(it, backup.backupCategories)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: optionally trigger online library + tracker update
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +58,7 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
|
|||||||
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>, online: Boolean) {
|
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>) {
|
||||||
val manga = backupManga.getMangaImpl()
|
val manga = backupManga.getMangaImpl()
|
||||||
val chapters = backupManga.getChaptersImpl()
|
val chapters = backupManga.getChaptersImpl()
|
||||||
val categories = backupManga.categories
|
val categories = backupManga.categories
|
||||||
@ -68,15 +66,10 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
|
|||||||
val tracks = backupManga.getTrackingImpl()
|
val tracks = backupManga.getTrackingImpl()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val source = backupManager.sourceManager.get(manga.source)
|
restoreMangaData(manga, chapters, categories, history, tracks, backupCategories)
|
||||||
if (source != null || !online) {
|
|
||||||
restoreMangaData(manga, source, chapters, categories, history, tracks, backupCategories, online)
|
|
||||||
} else {
|
|
||||||
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
|
|
||||||
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
errors.add(Date() to "${manga.title} - ${e.message}")
|
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
|
||||||
|
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreProgress += 1
|
restoreProgress += 1
|
||||||
@ -87,7 +80,6 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
|
|||||||
* Returns a manga restore observable
|
* Returns a manga restore observable
|
||||||
*
|
*
|
||||||
* @param manga manga data from json
|
* @param manga manga data from json
|
||||||
* @param source source to get manga data from
|
|
||||||
* @param chapters chapters data from json
|
* @param chapters chapters data from json
|
||||||
* @param categories categories data from json
|
* @param categories categories data from json
|
||||||
* @param history history data from json
|
* @param history history data from json
|
||||||
@ -95,101 +87,65 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier, private val
|
|||||||
*/
|
*/
|
||||||
private fun restoreMangaData(
|
private fun restoreMangaData(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
source: Source?,
|
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
categories: List<Int>,
|
categories: List<Int>,
|
||||||
history: List<BackupHistory>,
|
history: List<BackupHistory>,
|
||||||
tracks: List<Track>,
|
tracks: List<Track>,
|
||||||
backupCategories: List<BackupCategory>,
|
backupCategories: List<BackupCategory>
|
||||||
online: Boolean
|
|
||||||
) {
|
) {
|
||||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
|
||||||
|
|
||||||
db.inTransaction {
|
db.inTransaction {
|
||||||
|
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||||
if (dbManga == null) {
|
if (dbManga == null) {
|
||||||
// Manga not in database
|
// Manga not in database
|
||||||
restoreMangaFetch(source, manga, chapters, categories, history, tracks, backupCategories, online)
|
restoreMangaFetch(manga, chapters, categories, history, tracks, backupCategories)
|
||||||
} else { // Manga in database
|
} else {
|
||||||
|
// Manga in database
|
||||||
// Copy information from manga already in database
|
// Copy information from manga already in database
|
||||||
backupManager.restoreMangaNoFetch(manga, dbManga)
|
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||||
// Fetch rest of manga information
|
// Fetch rest of manga information
|
||||||
restoreMangaNoFetch(source, manga, chapters, categories, history, tracks, backupCategories, online)
|
restoreMangaNoFetch(manga, chapters, categories, history, tracks, backupCategories)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches manga information
|
* Fetches manga information
|
||||||
*
|
*
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @param chapters chapters of manga that needs updating
|
* @param chapters chapters of manga that needs updating
|
||||||
* @param categories categories that need updating
|
* @param categories categories that need updating
|
||||||
*/
|
*/
|
||||||
private fun restoreMangaFetch(
|
private fun restoreMangaFetch(
|
||||||
source: Source?,
|
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
categories: List<Int>,
|
categories: List<Int>,
|
||||||
history: List<BackupHistory>,
|
history: List<BackupHistory>,
|
||||||
tracks: List<Track>,
|
tracks: List<Track>,
|
||||||
backupCategories: List<BackupCategory>,
|
backupCategories: List<BackupCategory>
|
||||||
online: Boolean
|
|
||||||
) {
|
) {
|
||||||
backupManager.restoreMangaFetchObservable(source, manga, online)
|
try {
|
||||||
.doOnError {
|
val fetchedManga = backupManager.restoreManga(manga)
|
||||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
fetchedManga.id ?: return
|
||||||
}
|
|
||||||
.filter { it.id != null }
|
backupManager.restoreChaptersForManga(fetchedManga, chapters)
|
||||||
.flatMap {
|
|
||||||
if (online && source != null) {
|
restoreExtraForManga(fetchedManga, categories, history, tracks, backupCategories)
|
||||||
chapterFetchObservable(source, it, chapters)
|
} catch (e: Exception) {
|
||||||
// Convert to the manga that contains new chapters.
|
errors.add(Date() to "${manga.title} - ${e.message}")
|
||||||
.map { manga }
|
}
|
||||||
} else {
|
|
||||||
backupManager.restoreChaptersForMangaOffline(it, chapters)
|
|
||||||
Observable.just(manga)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.doOnNext {
|
|
||||||
restoreExtraForManga(it, categories, history, tracks, backupCategories)
|
|
||||||
}
|
|
||||||
.flatMap {
|
|
||||||
trackingFetchObservable(it, tracks)
|
|
||||||
}
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreMangaNoFetch(
|
private fun restoreMangaNoFetch(
|
||||||
source: Source?,
|
|
||||||
backupManga: Manga,
|
backupManga: Manga,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
categories: List<Int>,
|
categories: List<Int>,
|
||||||
history: List<BackupHistory>,
|
history: List<BackupHistory>,
|
||||||
tracks: List<Track>,
|
tracks: List<Track>,
|
||||||
backupCategories: List<BackupCategory>,
|
backupCategories: List<BackupCategory>
|
||||||
online: Boolean
|
|
||||||
) {
|
) {
|
||||||
Observable.just(backupManga)
|
backupManager.restoreChaptersForManga(backupManga, chapters)
|
||||||
.flatMap { manga ->
|
|
||||||
if (online && source != null) {
|
restoreExtraForManga(backupManga, categories, history, tracks, backupCategories)
|
||||||
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
|
||||||
chapterFetchObservable(source, manga, chapters)
|
|
||||||
.map { manga }
|
|
||||||
} else {
|
|
||||||
Observable.just(manga)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
backupManager.restoreChaptersForMangaOffline(manga, chapters)
|
|
||||||
Observable.just(manga)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.doOnNext {
|
|
||||||
restoreExtraForManga(it, categories, history, tracks, backupCategories)
|
|
||||||
}
|
|
||||||
.flatMap { manga ->
|
|
||||||
trackingFetchObservable(manga, tracks)
|
|
||||||
}
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
|
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
|
||||||
|
@ -5,12 +5,10 @@ import android.net.Uri
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator
|
import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator
|
||||||
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
|
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.source
|
import okio.source
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
||||||
/**
|
/**
|
||||||
* Checks for critical backup file data.
|
* Checks for critical backup file data.
|
||||||
@ -41,7 +39,7 @@ class FullBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
|||||||
val missingTrackers = trackers
|
val missingTrackers = trackers
|
||||||
.mapNotNull { trackManager.getService(it) }
|
.mapNotNull { trackManager.getService(it) }
|
||||||
.filter { !it.isLogged }
|
.filter { !it.isLogged }
|
||||||
.map { it.name }
|
.map { context.getString(it.nameRes()) }
|
||||||
.sorted()
|
.sorted()
|
||||||
|
|
||||||
return Results(missingSources, missingTrackers)
|
return Results(missingSources, missingTrackers)
|
||||||
|
@ -44,38 +44,23 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
|||||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
||||||
import eu.kanade.tachiyomi.source.LocalSource
|
import eu.kanade.tachiyomi.source.LocalSource
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import rx.Observable
|
import eu.kanade.tachiyomi.source.model.toSManga
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) {
|
class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) {
|
||||||
|
|
||||||
var parserVersion: Int = version
|
val parser: Gson = when (version) {
|
||||||
private set
|
2 -> GsonBuilder()
|
||||||
|
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||||
var parser: Gson = initParser()
|
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||||
|
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||||
/**
|
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||||
* Set version of parser
|
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||||
*
|
.create()
|
||||||
* @param version version of parser
|
|
||||||
*/
|
|
||||||
internal fun setVersion(version: Int) {
|
|
||||||
this.parserVersion = version
|
|
||||||
parser = initParser()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initParser(): Gson = when (parserVersion) {
|
|
||||||
2 ->
|
|
||||||
GsonBuilder()
|
|
||||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
|
||||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
|
||||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
|
||||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
|
||||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
|
||||||
.create()
|
|
||||||
else -> throw Exception("Unknown backup version")
|
else -> throw Exception("Unknown backup version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,21 +234,20 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches manga information
|
* Fetches manga information
|
||||||
*
|
*
|
||||||
* @param source source of manga
|
* @param source source of manga
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @return [Observable] that contains manga
|
* @return Updated manga.
|
||||||
*/
|
*/
|
||||||
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
|
suspend fun fetchManga(source: Source, manga: Manga): Manga {
|
||||||
return source.fetchMangaDetails(manga)
|
val networkManga = source.getMangaDetails(manga.toMangaInfo())
|
||||||
.map { networkManga ->
|
return manga.also {
|
||||||
manga.copyFrom(networkManga)
|
it.copyFrom(networkManga.toSManga())
|
||||||
manga.favorite = true
|
it.favorite = true
|
||||||
manga.initialized = true
|
it.initialized = true
|
||||||
manga.id = insertManga(manga)
|
it.id = insertManga(manga)
|
||||||
manga
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -308,7 +292,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
|
|||||||
*/
|
*/
|
||||||
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||||
val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
|
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(categories.size)
|
||||||
for (backupCategoryStr in categories) {
|
for (backupCategoryStr in categories) {
|
||||||
for (dbCategory in dbCategories) {
|
for (dbCategory in dbCategories) {
|
||||||
if (backupCategoryStr == dbCategory.name) {
|
if (backupCategoryStr == dbCategory.name) {
|
||||||
@ -332,7 +316,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
|
|||||||
*/
|
*/
|
||||||
internal fun restoreHistoryForManga(history: List<DHistory>) {
|
internal fun restoreHistoryForManga(history: List<DHistory>) {
|
||||||
// List containing history to be updated
|
// List containing history to be updated
|
||||||
val historyToBeUpdated = mutableListOf<History>()
|
val historyToBeUpdated = ArrayList<History>(history.size)
|
||||||
for ((url, lastRead) in history) {
|
for ((url, lastRead) in history) {
|
||||||
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
||||||
// Check if history already in database and update
|
// Check if history already in database and update
|
||||||
@ -361,14 +345,14 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
|
|||||||
* @param tracks the track list to restore.
|
* @param tracks the track list to restore.
|
||||||
*/
|
*/
|
||||||
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
||||||
// Fix foreign keys with the current manga id
|
|
||||||
tracks.map { it.manga_id = manga.id!! }
|
|
||||||
|
|
||||||
// Get tracks from database
|
// Get tracks from database
|
||||||
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||||
val trackToUpdate = mutableListOf<Track>()
|
val trackToUpdate = ArrayList<Track>(tracks.size)
|
||||||
|
|
||||||
tracks.forEach { track ->
|
tracks.forEach { track ->
|
||||||
|
// Fix foreign keys with the current manga id
|
||||||
|
track.manga_id = manga.id!!
|
||||||
|
|
||||||
val service = trackManager.getService(track.sync_id)
|
val service = trackManager.getService(track.sync_id)
|
||||||
if (service != null && service.isLogged) {
|
if (service != null && service.isLogged) {
|
||||||
var isInDatabase = false
|
var isInDatabase = false
|
||||||
@ -423,12 +407,13 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
|
|||||||
chapter.copyFrom(dbChapter)
|
chapter.copyFrom(dbChapter)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Filter the chapters that couldn't be found.
|
|
||||||
chapters.filter { it.id != null }
|
|
||||||
chapters.map { it.manga_id = manga.id }
|
|
||||||
|
|
||||||
updateChapters(chapters)
|
chapter.manga_id = manga.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter the chapters that couldn't be found.
|
||||||
|
updateChapters(chapters.filter { it.id != null })
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,12 +21,11 @@ import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import rx.Observable
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore<LegacyBackupManager>(context, notifier) {
|
class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore<LegacyBackupManager>(context, notifier) {
|
||||||
|
|
||||||
override fun performRestore(uri: Uri): Boolean {
|
override suspend fun performRestore(uri: Uri): Boolean {
|
||||||
val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader())
|
val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader())
|
||||||
val json = JsonParser.parseReader(reader).asJsonObject
|
val json = JsonParser.parseReader(reader).asJsonObject
|
||||||
|
|
||||||
@ -63,7 +62,7 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreManga(mangaJson: JsonObject) {
|
private suspend fun restoreManga(mangaJson: JsonObject) {
|
||||||
val manga = backupManager.parser.fromJson<MangaImpl>(
|
val manga = backupManager.parser.fromJson<MangaImpl>(
|
||||||
mangaJson.get(
|
mangaJson.get(
|
||||||
Backup.MANGA
|
Backup.MANGA
|
||||||
@ -86,16 +85,17 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
?: JsonArray()
|
?: JsonArray()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val source = backupManager.sourceManager.get(manga.source)
|
||||||
|
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val source = backupManager.sourceManager.get(manga.source)
|
|
||||||
if (source != null) {
|
if (source != null) {
|
||||||
restoreMangaData(manga, source, chapters, categories, history, tracks)
|
restoreMangaData(manga, source, chapters, categories, history, tracks)
|
||||||
} else {
|
} else {
|
||||||
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
|
errors.add(Date() to "${manga.title} [$sourceName]: ${context.getString(R.string.source_not_found_name, sourceName)}")
|
||||||
errors.add(Date() to "${manga.title} - ${context.getString(R.string.source_not_found_name, sourceName)}")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
errors.add(Date() to "${manga.title} - ${e.message}")
|
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreProgress += 1
|
restoreProgress += 1
|
||||||
@ -112,7 +112,7 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
* @param history history data from json
|
* @param history history data from json
|
||||||
* @param tracks tracking data from json
|
* @param tracks tracking data from json
|
||||||
*/
|
*/
|
||||||
private fun restoreMangaData(
|
private suspend fun restoreMangaData(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
source: Source,
|
source: Source,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
@ -136,13 +136,13 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Observable] that fetches manga information
|
* Fetches manga information.
|
||||||
*
|
*
|
||||||
* @param manga manga that needs updating
|
* @param manga manga that needs updating
|
||||||
* @param chapters chapters of manga that needs updating
|
* @param chapters chapters of manga that needs updating
|
||||||
* @param categories categories that need updating
|
* @param categories categories that need updating
|
||||||
*/
|
*/
|
||||||
private fun restoreMangaFetch(
|
private suspend fun restoreMangaFetch(
|
||||||
source: Source,
|
source: Source,
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
@ -150,27 +150,21 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
history: List<DHistory>,
|
history: List<DHistory>,
|
||||||
tracks: List<Track>
|
tracks: List<Track>
|
||||||
) {
|
) {
|
||||||
backupManager.restoreMangaFetchObservable(source, manga)
|
try {
|
||||||
.onErrorReturn {
|
val fetchedManga = backupManager.fetchManga(source, manga)
|
||||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
fetchedManga.id ?: return
|
||||||
manga
|
|
||||||
}
|
updateChapters(source, fetchedManga, chapters)
|
||||||
.filter { it.id != null }
|
|
||||||
.flatMap {
|
restoreExtraForManga(fetchedManga, categories, history, tracks)
|
||||||
chapterFetchObservable(source, it, chapters)
|
|
||||||
// Convert to the manga that contains new chapters.
|
updateTracking(fetchedManga, tracks)
|
||||||
.map { manga }
|
} catch (e: Exception) {
|
||||||
}
|
errors.add(Date() to "${manga.title} - ${e.message}")
|
||||||
.doOnNext {
|
}
|
||||||
restoreExtraForManga(it, categories, history, tracks)
|
|
||||||
}
|
|
||||||
.flatMap {
|
|
||||||
trackingFetchObservable(it, tracks)
|
|
||||||
}
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreMangaNoFetch(
|
private suspend fun restoreMangaNoFetch(
|
||||||
source: Source,
|
source: Source,
|
||||||
backupManga: Manga,
|
backupManga: Manga,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
@ -178,22 +172,13 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
|||||||
history: List<DHistory>,
|
history: List<DHistory>,
|
||||||
tracks: List<Track>
|
tracks: List<Track>
|
||||||
) {
|
) {
|
||||||
Observable.just(backupManga)
|
if (!backupManager.restoreChaptersForManga(backupManga, chapters)) {
|
||||||
.flatMap { manga ->
|
updateChapters(source, backupManga, chapters)
|
||||||
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
}
|
||||||
chapterFetchObservable(source, manga, chapters)
|
|
||||||
.map { manga }
|
restoreExtraForManga(backupManga, categories, history, tracks)
|
||||||
} else {
|
|
||||||
Observable.just(manga)
|
updateTracking(backupManga, tracks)
|
||||||
}
|
|
||||||
}
|
|
||||||
.doOnNext {
|
|
||||||
restoreExtraForManga(it, categories, history, tracks)
|
|
||||||
}
|
|
||||||
.flatMap { manga ->
|
|
||||||
trackingFetchObservable(manga, tracks)
|
|
||||||
}
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
|
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
|
||||||
|
@ -45,7 +45,7 @@ class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
|||||||
val missingTrackers = trackers
|
val missingTrackers = trackers
|
||||||
.mapNotNull { trackManager.getService(it) }
|
.mapNotNull { trackManager.getService(it) }
|
||||||
.filter { !it.isLogged }
|
.filter { !it.isLogged }
|
||||||
.map { it.name }
|
.map { context.getString(it.nameRes()) }
|
||||||
.sorted()
|
.sorted()
|
||||||
|
|
||||||
return Results(missingSources, missingTrackers)
|
return Results(missingSources, missingTrackers)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -35,12 +35,13 @@ class CategoryPutResolver : DefaultPutResolver<Category>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Category) = ContentValues(4).apply {
|
override fun mapToContentValues(obj: Category) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_NAME, obj.name)
|
COL_ID to obj.id,
|
||||||
put(COL_ORDER, obj.order)
|
COL_NAME to obj.name,
|
||||||
put(COL_FLAGS, obj.flags)
|
COL_ORDER to obj.order,
|
||||||
}
|
COL_FLAGS to obj.flags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class CategoryGetResolver : DefaultGetResolver<Category>() {
|
class CategoryGetResolver : DefaultGetResolver<Category>() {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -43,20 +43,21 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
|
override fun mapToContentValues(obj: Chapter) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
COL_ID to obj.id,
|
||||||
put(COL_URL, obj.url)
|
COL_MANGA_ID to obj.manga_id,
|
||||||
put(COL_NAME, obj.name)
|
COL_URL to obj.url,
|
||||||
put(COL_READ, obj.read)
|
COL_NAME to obj.name,
|
||||||
put(COL_SCANLATOR, obj.scanlator)
|
COL_READ to obj.read,
|
||||||
put(COL_BOOKMARK, obj.bookmark)
|
COL_SCANLATOR to obj.scanlator,
|
||||||
put(COL_DATE_FETCH, obj.date_fetch)
|
COL_BOOKMARK to obj.bookmark,
|
||||||
put(COL_DATE_UPLOAD, obj.date_upload)
|
COL_DATE_FETCH to obj.date_fetch,
|
||||||
put(COL_LAST_PAGE_READ, obj.last_page_read)
|
COL_DATE_UPLOAD to obj.date_upload,
|
||||||
put(COL_CHAPTER_NUMBER, obj.chapter_number)
|
COL_LAST_PAGE_READ to obj.last_page_read,
|
||||||
put(COL_SOURCE_ORDER, obj.source_order)
|
COL_CHAPTER_NUMBER to obj.chapter_number,
|
||||||
}
|
COL_SOURCE_ORDER to obj.source_order
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChapterGetResolver : DefaultGetResolver<Chapter>() {
|
class ChapterGetResolver : DefaultGetResolver<Chapter>() {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -35,12 +35,13 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: History) = ContentValues(4).apply {
|
override fun mapToContentValues(obj: History) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_CHAPTER_ID, obj.chapter_id)
|
COL_ID to obj.id,
|
||||||
put(COL_LAST_READ, obj.last_read)
|
COL_CHAPTER_ID to obj.chapter_id,
|
||||||
put(COL_TIME_READ, obj.time_read)
|
COL_LAST_READ to obj.last_read,
|
||||||
}
|
COL_TIME_READ to obj.time_read
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryGetResolver : DefaultGetResolver<History>() {
|
class HistoryGetResolver : DefaultGetResolver<History>() {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -33,11 +33,12 @@ class MangaCategoryPutResolver : DefaultPutResolver<MangaCategory>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: MangaCategory) = ContentValues(3).apply {
|
override fun mapToContentValues(obj: MangaCategory) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
COL_ID to obj.id,
|
||||||
put(COL_CATEGORY_ID, obj.category_id)
|
COL_MANGA_ID to obj.manga_id,
|
||||||
}
|
COL_CATEGORY_ID to obj.category_id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class MangaCategoryGetResolver : DefaultGetResolver<MangaCategory>() {
|
class MangaCategoryGetResolver : DefaultGetResolver<MangaCategory>() {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -48,25 +48,26 @@ class MangaPutResolver : DefaultPutResolver<Manga>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Manga) = ContentValues(17).apply {
|
override fun mapToContentValues(obj: Manga) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_SOURCE, obj.source)
|
COL_ID to obj.id,
|
||||||
put(COL_URL, obj.url)
|
COL_SOURCE to obj.source,
|
||||||
put(COL_ARTIST, obj.artist)
|
COL_URL to obj.url,
|
||||||
put(COL_AUTHOR, obj.author)
|
COL_ARTIST to obj.artist,
|
||||||
put(COL_DESCRIPTION, obj.description)
|
COL_AUTHOR to obj.author,
|
||||||
put(COL_GENRE, obj.genre)
|
COL_DESCRIPTION to obj.description,
|
||||||
put(COL_TITLE, obj.title)
|
COL_GENRE to obj.genre,
|
||||||
put(COL_STATUS, obj.status)
|
COL_TITLE to obj.title,
|
||||||
put(COL_THUMBNAIL_URL, obj.thumbnail_url)
|
COL_STATUS to obj.status,
|
||||||
put(COL_FAVORITE, obj.favorite)
|
COL_THUMBNAIL_URL to obj.thumbnail_url,
|
||||||
put(COL_LAST_UPDATE, obj.last_update)
|
COL_FAVORITE to obj.favorite,
|
||||||
put(COL_INITIALIZED, obj.initialized)
|
COL_LAST_UPDATE to obj.last_update,
|
||||||
put(COL_VIEWER, obj.viewer)
|
COL_INITIALIZED to obj.initialized,
|
||||||
put(COL_CHAPTER_FLAGS, obj.chapter_flags)
|
COL_VIEWER to obj.viewer,
|
||||||
put(COL_COVER_LAST_MODIFIED, obj.cover_last_modified)
|
COL_CHAPTER_FLAGS to obj.chapter_flags,
|
||||||
put(COL_DATE_ADDED, obj.date_added)
|
COL_COVER_LAST_MODIFIED to obj.cover_last_modified,
|
||||||
}
|
COL_DATE_ADDED to obj.date_added
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseMangaGetResolver {
|
interface BaseMangaGetResolver {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.mappers
|
package eu.kanade.tachiyomi.data.database.mappers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||||
@ -44,21 +44,22 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Track) = ContentValues(10).apply {
|
override fun mapToContentValues(obj: Track) =
|
||||||
put(COL_ID, obj.id)
|
contentValuesOf(
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
COL_ID to obj.id,
|
||||||
put(COL_SYNC_ID, obj.sync_id)
|
COL_MANGA_ID to obj.manga_id,
|
||||||
put(COL_MEDIA_ID, obj.media_id)
|
COL_SYNC_ID to obj.sync_id,
|
||||||
put(COL_LIBRARY_ID, obj.library_id)
|
COL_MEDIA_ID to obj.media_id,
|
||||||
put(COL_TITLE, obj.title)
|
COL_LIBRARY_ID to obj.library_id,
|
||||||
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
|
COL_TITLE to obj.title,
|
||||||
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
|
COL_LAST_CHAPTER_READ to obj.last_chapter_read,
|
||||||
put(COL_STATUS, obj.status)
|
COL_TOTAL_CHAPTERS to obj.total_chapters,
|
||||||
put(COL_TRACKING_URL, obj.tracking_url)
|
COL_STATUS to obj.status,
|
||||||
put(COL_SCORE, obj.score)
|
COL_TRACKING_URL to obj.tracking_url,
|
||||||
put(COL_START_DATE, obj.started_reading_date)
|
COL_SCORE to obj.score,
|
||||||
put(COL_FINISH_DATE, obj.finished_reading_date)
|
COL_START_DATE to obj.started_reading_date,
|
||||||
}
|
COL_FINISH_DATE to obj.finished_reading_date
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrackGetResolver : DefaultGetResolver<Track>() {
|
class TrackGetResolver : DefaultGetResolver<Track>() {
|
||||||
|
@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
|
||||||
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterKnownBackupPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
||||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
||||||
@ -84,6 +85,11 @@ interface ChapterQueries : DbProvider {
|
|||||||
.withPutResolver(ChapterBackupPutResolver())
|
.withPutResolver(ChapterBackupPutResolver())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun updateKnownChaptersBackup(chapters: List<Chapter>) = db.put()
|
||||||
|
.objects(chapters)
|
||||||
|
.withPutResolver(ChapterKnownBackupPutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||||
.`object`(chapter)
|
.`object`(chapter)
|
||||||
.withPutResolver(ChapterProgressPutResolver())
|
.withPutResolver(ChapterProgressPutResolver())
|
||||||
|
@ -164,4 +164,14 @@ interface MangaQueries : DbProvider {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun getChapterFetchDateManga() = db.get()
|
||||||
|
.listOfObjects(Manga::class.java)
|
||||||
|
.withQuery(
|
||||||
|
RawQuery.builder()
|
||||||
|
.query(getChapterFetchDateMangaQuery())
|
||||||
|
.observesTables(MangaTable.TABLE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.prepare()
|
||||||
}
|
}
|
||||||
|
@ -122,6 +122,16 @@ fun getLatestChapterMangaQuery() =
|
|||||||
ORDER by max DESC
|
ORDER by max DESC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
fun getChapterFetchDateMangaQuery() =
|
||||||
|
"""
|
||||||
|
SELECT ${Manga.TABLE}.*, MAX(${Chapter.TABLE}.${Chapter.COL_DATE_FETCH}) AS max
|
||||||
|
FROM ${Manga.TABLE}
|
||||||
|
JOIN ${Chapter.TABLE}
|
||||||
|
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
|
||||||
|
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
|
||||||
|
ORDER by max DESC
|
||||||
|
"""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query to get the categories for a manga.
|
* Query to get the categories for a manga.
|
||||||
*/
|
*/
|
||||||
|
@ -10,6 +10,15 @@ import eu.kanade.tachiyomi.data.track.TrackService
|
|||||||
|
|
||||||
interface TrackQueries : DbProvider {
|
interface TrackQueries : DbProvider {
|
||||||
|
|
||||||
|
fun getTracks() = db.get()
|
||||||
|
.listOfObjects(Track::class.java)
|
||||||
|
.withQuery(
|
||||||
|
Query.builder()
|
||||||
|
.table(TrackTable.TABLE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun getTracks(manga: Manga) = db.get()
|
fun getTracks(manga: Manga) = db.get()
|
||||||
.listOfObjects(Track::class.java)
|
.listOfObjects(Track::class.java)
|
||||||
.withQuery(
|
.withQuery(
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,9 +25,10 @@ class ChapterBackupPutResolver : PutResolver<Chapter>() {
|
|||||||
.whereArgs(chapter.url)
|
.whereArgs(chapter.url)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
fun mapToContentValues(chapter: Chapter) =
|
||||||
put(ChapterTable.COL_READ, chapter.read)
|
contentValuesOf(
|
||||||
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
ChapterTable.COL_READ to chapter.read,
|
||||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
ChapterTable.COL_BOOKMARK to chapter.bookmark,
|
||||||
}
|
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
|
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||||
|
|
||||||
|
class ChapterKnownBackupPutResolver : PutResolver<Chapter>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(chapter)
|
||||||
|
val contentValues = mapToContentValues(chapter)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
|
||||||
|
.table(ChapterTable.TABLE)
|
||||||
|
.where("${ChapterTable.COL_ID} = ?")
|
||||||
|
.whereArgs(chapter.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(chapter: Chapter) =
|
||||||
|
contentValuesOf(
|
||||||
|
ChapterTable.COL_READ to chapter.read,
|
||||||
|
ChapterTable.COL_BOOKMARK to chapter.bookmark,
|
||||||
|
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,9 +25,10 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
|
|||||||
.whereArgs(chapter.id)
|
.whereArgs(chapter.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
fun mapToContentValues(chapter: Chapter) =
|
||||||
put(ChapterTable.COL_READ, chapter.read)
|
contentValuesOf(
|
||||||
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
ChapterTable.COL_READ to chapter.read,
|
||||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
ChapterTable.COL_BOOKMARK to chapter.bookmark,
|
||||||
}
|
ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class ChapterSourceOrderPutResolver : PutResolver<Chapter>() {
|
|||||||
.whereArgs(chapter.url, chapter.manga_id)
|
.whereArgs(chapter.url, chapter.manga_id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(chapter: Chapter) = ContentValues(1).apply {
|
fun mapToContentValues(chapter: Chapter) =
|
||||||
put(ChapterTable.COL_SOURCE_ORDER, chapter.source_order)
|
contentValuesOf(
|
||||||
}
|
ChapterTable.COL_SOURCE_ORDER to chapter.source_order
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
|
||||||
import androidx.annotation.NonNull
|
import androidx.annotation.NonNull
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||||
@ -57,7 +57,8 @@ class HistoryLastReadPutResolver : HistoryPutResolver() {
|
|||||||
* Create content query
|
* Create content query
|
||||||
* @param history object
|
* @param history object
|
||||||
*/
|
*/
|
||||||
fun mapToUpdateContentValues(history: History) = ContentValues(1).apply {
|
fun mapToUpdateContentValues(history: History) =
|
||||||
put(HistoryTable.COL_LAST_READ, history.last_read)
|
contentValuesOf(
|
||||||
}
|
HistoryTable.COL_LAST_READ to history.last_read
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class MangaCoverLastModifiedPutResolver : PutResolver<Manga>() {
|
|||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_COVER_LAST_MODIFIED, manga.cover_last_modified)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_COVER_LAST_MODIFIED to manga.cover_last_modified
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class MangaFavoritePutResolver : PutResolver<Manga>() {
|
|||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_FAVORITE, manga.favorite)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_FAVORITE to manga.favorite
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -35,7 +35,8 @@ class MangaFlagsPutResolver(private val updateAll: Boolean = false) : PutResolve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_CHAPTER_FLAGS, manga.chapter_flags)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_CHAPTER_FLAGS to manga.chapter_flags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class MangaLastUpdatedPutResolver : PutResolver<Manga>() {
|
|||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_LAST_UPDATE, manga.last_update)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_LAST_UPDATE to manga.last_update
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class MangaTitlePutResolver : PutResolver<Manga>() {
|
|||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_TITLE, manga.title)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_TITLE to manga.title
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.resolvers
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
import android.content.ContentValues
|
import androidx.core.content.contentValuesOf
|
||||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
@ -25,7 +25,8 @@ class MangaViewerPutResolver : PutResolver<Manga>() {
|
|||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) =
|
||||||
put(MangaTable.COL_VIEWER, manga.viewer)
|
contentValuesOf(
|
||||||
}
|
MangaTable.COL_VIEWER to manga.viewer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
@ -211,17 +212,35 @@ class DownloadManager(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source): List<Chapter> {
|
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source): List<Chapter> {
|
||||||
val filteredChapters = getChaptersToDelete(chapters)
|
val filteredChapters = getChaptersToDelete(chapters)
|
||||||
|
launchIO {
|
||||||
|
removeFromDownloadQueue(filteredChapters)
|
||||||
|
|
||||||
queue.remove(filteredChapters)
|
val chapterDirs = provider.findChapterDirs(filteredChapters, manga, source)
|
||||||
|
chapterDirs.forEach { it.delete() }
|
||||||
|
cache.removeChapters(filteredChapters, manga)
|
||||||
|
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
||||||
|
chapterDirs.firstOrNull()?.parentFile?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredChapters
|
||||||
|
}
|
||||||
|
|
||||||
val chapterDirs = provider.findChapterDirs(filteredChapters, manga, source)
|
private fun removeFromDownloadQueue(chapters: List<Chapter>) {
|
||||||
chapterDirs.forEach { it.delete() }
|
val wasRunning = downloader.isRunning
|
||||||
cache.removeChapters(filteredChapters, manga)
|
if (wasRunning) {
|
||||||
if (cache.getDownloadCount(manga) == 0) { // Delete manga directory if empty
|
downloader.pause()
|
||||||
chapterDirs.firstOrNull()?.parentFile?.delete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredChapters
|
downloader.queue.remove(chapters)
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
if (downloader.queue.isEmpty()) {
|
||||||
|
DownloadService.stop(context)
|
||||||
|
downloader.stop()
|
||||||
|
} else if (downloader.queue.isNotEmpty()) {
|
||||||
|
downloader.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -231,9 +250,11 @@ class DownloadManager(private val context: Context) {
|
|||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
fun deleteManga(manga: Manga, source: Source) {
|
fun deleteManga(manga: Manga, source: Source) {
|
||||||
queue.remove(manga)
|
launchIO {
|
||||||
provider.findMangaDir(manga, source)?.delete()
|
downloader.queue.remove(manga)
|
||||||
cache.removeManga(manga)
|
provider.findMangaDir(manga, source)?.delete()
|
||||||
|
cache.removeManga(manga)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,6 +27,9 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
private val progressNotificationBuilder by lazy {
|
private val progressNotificationBuilder by lazy {
|
||||||
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
|
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
|
||||||
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
||||||
|
setAutoCancel(false)
|
||||||
|
setOngoing(true)
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,15 +69,6 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
context.notificationManager.notify(id, build())
|
context.notificationManager.notify(id, build())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear old actions if they exist.
|
|
||||||
*/
|
|
||||||
private fun NotificationCompat.Builder.clearActions() {
|
|
||||||
if (mActions.isNotEmpty()) {
|
|
||||||
mActions.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
||||||
* those can only be dismissed by the user.
|
* those can only be dismissed by the user.
|
||||||
@ -93,7 +87,6 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
// Check if first call.
|
// Check if first call.
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
setAutoCancel(false)
|
|
||||||
clearActions()
|
clearActions()
|
||||||
// Open download manager when clicked
|
// Open download manager when clicked
|
||||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||||
@ -136,7 +129,6 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
setContentTitle(context.getString(R.string.chapter_paused))
|
setContentTitle(context.getString(R.string.chapter_paused))
|
||||||
setContentText(context.getString(R.string.download_notifier_download_paused))
|
setContentText(context.getString(R.string.download_notifier_download_paused))
|
||||||
setSmallIcon(R.drawable.ic_pause_24dp)
|
setSmallIcon(R.drawable.ic_pause_24dp)
|
||||||
setAutoCancel(false)
|
|
||||||
setProgress(0, 0, false)
|
setProgress(0, 0, false)
|
||||||
clearActions()
|
clearActions()
|
||||||
// Open download manager when clicked
|
// Open download manager when clicked
|
||||||
@ -165,6 +157,8 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
* This function shows a notification to inform download tasks are done.
|
* This function shows a notification to inform download tasks are done.
|
||||||
*/
|
*/
|
||||||
fun onComplete() {
|
fun onComplete() {
|
||||||
|
dismissProgress()
|
||||||
|
|
||||||
if (!errorThrown) {
|
if (!errorThrown) {
|
||||||
// Create notification
|
// Create notification
|
||||||
with(completeNotificationBuilder) {
|
with(completeNotificationBuilder) {
|
||||||
@ -224,7 +218,6 @@ internal class DownloadNotifier(private val context: Context) {
|
|||||||
setContentText(error ?: context.getString(R.string.download_notifier_unknown_error))
|
setContentText(error ?: context.getString(R.string.download_notifier_unknown_error))
|
||||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
clearActions()
|
clearActions()
|
||||||
setAutoCancel(false)
|
|
||||||
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
|
||||||
setProgress(0, 0, false)
|
setProgress(0, 0, false)
|
||||||
|
|
||||||
|
@ -9,11 +9,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
|||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,7 +25,7 @@ class DownloadProvider(private val context: Context) {
|
|||||||
|
|
||||||
private val preferences: PreferencesHelper by injectLazy()
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
private val scope = MainScope()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The root directory for downloads.
|
* The root directory for downloads.
|
||||||
@ -54,7 +53,8 @@ class DownloadProvider(private val context: Context) {
|
|||||||
return downloadsDir
|
return downloadsDir
|
||||||
.createDirectory(getSourceDirName(source))
|
.createDirectory(getSourceDirName(source))
|
||||||
.createDirectory(getMangaDirName(manga))
|
.createDirectory(getMangaDirName(manga))
|
||||||
} catch (e: NullPointerException) {
|
} catch (e: Throwable) {
|
||||||
|
Timber.e(e, "Invalid download directory")
|
||||||
throw Exception(context.getString(R.string.invalid_download_dir))
|
throw Exception(context.getString(R.string.invalid_download_dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,8 @@ import eu.kanade.tachiyomi.source.model.Page
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
||||||
import eu.kanade.tachiyomi.util.lang.RetryWithDelay
|
import eu.kanade.tachiyomi.util.lang.RetryWithDelay
|
||||||
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import eu.kanade.tachiyomi.util.lang.launchNow
|
import eu.kanade.tachiyomi.util.lang.launchNow
|
||||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
|
||||||
import eu.kanade.tachiyomi.util.lang.plusAssign
|
import eu.kanade.tachiyomi.util.lang.plusAssign
|
||||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.storage.saveTo
|
import eu.kanade.tachiyomi.util.storage.saveTo
|
||||||
@ -114,8 +114,8 @@ class Downloader(
|
|||||||
initializeSubscriptions()
|
initializeSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
val pending = queue.filter { it.status != Download.State.DOWNLOADED }
|
||||||
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE }
|
||||||
|
|
||||||
notifier.paused = false
|
notifier.paused = false
|
||||||
|
|
||||||
@ -129,20 +129,21 @@ class Downloader(
|
|||||||
fun stop(reason: String? = null) {
|
fun stop(reason: String? = null) {
|
||||||
destroySubscriptions()
|
destroySubscriptions()
|
||||||
queue
|
queue
|
||||||
.filter { it.status == Download.DOWNLOADING }
|
.filter { it.status == Download.State.DOWNLOADING }
|
||||||
.forEach { it.status = Download.ERROR }
|
.forEach { it.status = Download.State.ERROR }
|
||||||
|
|
||||||
if (reason != null) {
|
if (reason != null) {
|
||||||
notifier.onWarning(reason)
|
notifier.onWarning(reason)
|
||||||
} else {
|
return
|
||||||
if (notifier.paused) {
|
|
||||||
notifier.paused = false
|
|
||||||
notifier.onPaused()
|
|
||||||
} else {
|
|
||||||
notifier.dismissProgress()
|
|
||||||
notifier.onComplete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notifier.paused && !queue.isEmpty()) {
|
||||||
|
notifier.onPaused()
|
||||||
|
} else {
|
||||||
|
notifier.onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier.paused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -151,8 +152,8 @@ class Downloader(
|
|||||||
fun pause() {
|
fun pause() {
|
||||||
destroySubscriptions()
|
destroySubscriptions()
|
||||||
queue
|
queue
|
||||||
.filter { it.status == Download.DOWNLOADING }
|
.filter { it.status == Download.State.DOWNLOADING }
|
||||||
.forEach { it.status = Download.QUEUE }
|
.forEach { it.status = Download.State.QUEUE }
|
||||||
notifier.paused = true
|
notifier.paused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,8 +168,8 @@ class Downloader(
|
|||||||
// Needed to update the chapter view
|
// Needed to update the chapter view
|
||||||
if (isNotification) {
|
if (isNotification) {
|
||||||
queue
|
queue
|
||||||
.filter { it.status == Download.QUEUE }
|
.filter { it.status == Download.State.QUEUE }
|
||||||
.forEach { it.status = Download.NOT_DOWNLOADED }
|
.forEach { it.status = Download.State.NOT_DOWNLOADED }
|
||||||
}
|
}
|
||||||
queue.clear()
|
queue.clear()
|
||||||
notifier.dismissProgress()
|
notifier.dismissProgress()
|
||||||
@ -227,8 +228,8 @@ class Downloader(
|
|||||||
* @param chapters the list of chapters to download.
|
* @param chapters the list of chapters to download.
|
||||||
* @param autoStart whether to start the downloader after enqueing the chapters.
|
* @param autoStart whether to start the downloader after enqueing the chapters.
|
||||||
*/
|
*/
|
||||||
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
|
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchIO {
|
||||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
|
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO
|
||||||
val wasEmpty = queue.isEmpty()
|
val wasEmpty = queue.isEmpty()
|
||||||
// Called in background thread, the operation can be slow with SAF.
|
// Called in background thread, the operation can be slow with SAF.
|
||||||
val chaptersWithoutDir = async {
|
val chaptersWithoutDir = async {
|
||||||
@ -271,7 +272,7 @@ class Downloader(
|
|||||||
|
|
||||||
val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir)
|
val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir)
|
||||||
if (availSpace != -1L && availSpace < MIN_DISK_SPACE) {
|
if (availSpace != -1L && availSpace < MIN_DISK_SPACE) {
|
||||||
download.status = Download.ERROR
|
download.status = Download.State.ERROR
|
||||||
notifier.onError(context.getString(R.string.download_insufficient_space), download.chapter.name)
|
notifier.onError(context.getString(R.string.download_insufficient_space), download.chapter.name)
|
||||||
return@defer Observable.just(download)
|
return@defer Observable.just(download)
|
||||||
}
|
}
|
||||||
@ -301,7 +302,7 @@ class Downloader(
|
|||||||
?.forEach { it.delete() }
|
?.forEach { it.delete() }
|
||||||
|
|
||||||
download.downloadedImages = 0
|
download.downloadedImages = 0
|
||||||
download.status = Download.DOWNLOADING
|
download.status = Download.State.DOWNLOADING
|
||||||
}
|
}
|
||||||
// Get all the URLs to the source images, fetch pages if necessary
|
// Get all the URLs to the source images, fetch pages if necessary
|
||||||
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
||||||
@ -317,7 +318,7 @@ class Downloader(
|
|||||||
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
|
.doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) }
|
||||||
// If the page list threw, it will resume here
|
// If the page list threw, it will resume here
|
||||||
.onErrorReturn { error ->
|
.onErrorReturn { error ->
|
||||||
download.status = Download.ERROR
|
download.status = Download.State.ERROR
|
||||||
notifier.onError(error.message, download.chapter.name)
|
notifier.onError(error.message, download.chapter.name)
|
||||||
download
|
download
|
||||||
}
|
}
|
||||||
@ -457,13 +458,13 @@ class Downloader(
|
|||||||
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
||||||
|
|
||||||
download.status = if (downloadedImages.size == download.pages!!.size) {
|
download.status = if (downloadedImages.size == download.pages!!.size) {
|
||||||
Download.DOWNLOADED
|
Download.State.DOWNLOADED
|
||||||
} else {
|
} else {
|
||||||
Download.ERROR
|
Download.State.ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only rename the directory if it's downloaded.
|
// Only rename the directory if it's downloaded.
|
||||||
if (download.status == Download.DOWNLOADED) {
|
if (download.status == Download.State.DOWNLOADED) {
|
||||||
tmpDir.renameTo(dirname)
|
tmpDir.renameTo(dirname)
|
||||||
cache.addChapter(dirname, mangaDir, download.manga)
|
cache.addChapter(dirname, mangaDir, download.manga)
|
||||||
|
|
||||||
@ -476,7 +477,7 @@ class Downloader(
|
|||||||
*/
|
*/
|
||||||
private fun completeDownload(download: Download) {
|
private fun completeDownload(download: Download) {
|
||||||
// Delete successful downloads from queue
|
// Delete successful downloads from queue
|
||||||
if (download.status == Download.DOWNLOADED) {
|
if (download.status == Download.State.DOWNLOADED) {
|
||||||
// remove downloaded chapter from queue
|
// remove downloaded chapter from queue
|
||||||
queue.remove(download)
|
queue.remove(download)
|
||||||
}
|
}
|
||||||
@ -489,7 +490,7 @@ class Downloader(
|
|||||||
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
|
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
|
||||||
*/
|
*/
|
||||||
private fun areAllDownloadsFinished(): Boolean {
|
private fun areAllDownloadsFinished(): Boolean {
|
||||||
return queue.none { it.status <= Download.DOWNLOADING }
|
return queue.none { it.status.value <= Download.State.DOWNLOADING.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -20,7 +20,7 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
|
|||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@Transient
|
@Transient
|
||||||
var status: Int = 0
|
var status: State = State.NOT_DOWNLOADED
|
||||||
set(status) {
|
set(status) {
|
||||||
field = status
|
field = status
|
||||||
statusSubject?.onNext(this)
|
statusSubject?.onNext(this)
|
||||||
@ -47,11 +47,11 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
|
|||||||
statusCallback = f
|
statusCallback = f
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
enum class State(val value: Int) {
|
||||||
const val NOT_DOWNLOADED = 0
|
NOT_DOWNLOADED(0),
|
||||||
const val QUEUE = 1
|
QUEUE(1),
|
||||||
const val DOWNLOADING = 2
|
DOWNLOADING(2),
|
||||||
const val DOWNLOADED = 3
|
DOWNLOADED(3),
|
||||||
const val ERROR = 4
|
ERROR(4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ class DownloadQueue(
|
|||||||
downloads.forEach { download ->
|
downloads.forEach { download ->
|
||||||
download.setStatusSubject(statusSubject)
|
download.setStatusSubject(statusSubject)
|
||||||
download.setStatusCallback(::setPagesFor)
|
download.setStatusCallback(::setPagesFor)
|
||||||
download.status = Download.QUEUE
|
download.status = Download.State.QUEUE
|
||||||
}
|
}
|
||||||
queue.addAll(downloads)
|
queue.addAll(downloads)
|
||||||
store.addAll(downloads)
|
store.addAll(downloads)
|
||||||
@ -34,8 +34,8 @@ class DownloadQueue(
|
|||||||
store.remove(download)
|
store.remove(download)
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
download.setStatusCallback(null)
|
download.setStatusCallback(null)
|
||||||
if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
|
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
|
||||||
download.status = Download.NOT_DOWNLOADED
|
download.status = Download.State.NOT_DOWNLOADED
|
||||||
}
|
}
|
||||||
if (removed) {
|
if (removed) {
|
||||||
updatedRelay.call(Unit)
|
updatedRelay.call(Unit)
|
||||||
@ -60,8 +60,8 @@ class DownloadQueue(
|
|||||||
queue.forEach { download ->
|
queue.forEach { download ->
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
download.setStatusCallback(null)
|
download.setStatusCallback(null)
|
||||||
if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) {
|
if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) {
|
||||||
download.status = Download.NOT_DOWNLOADED
|
download.status = Download.State.NOT_DOWNLOADED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queue.clear()
|
queue.clear()
|
||||||
@ -70,7 +70,7 @@ class DownloadQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getActiveDownloads(): Observable<Download> =
|
fun getActiveDownloads(): Observable<Download> =
|
||||||
Observable.from(this).filter { download -> download.status == Download.DOWNLOADING }
|
Observable.from(this).filter { download -> download.status == Download.State.DOWNLOADING }
|
||||||
|
|
||||||
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ class DownloadQueue(
|
|||||||
.map { this }
|
.map { this }
|
||||||
|
|
||||||
private fun setPagesFor(download: Download) {
|
private fun setPagesFor(download: Download) {
|
||||||
if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
|
if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
|
||||||
setPagesSubject(download.pages, null)
|
setPagesSubject(download.pages, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,19 +88,19 @@ class DownloadQueue(
|
|||||||
return statusSubject.onBackpressureBuffer()
|
return statusSubject.onBackpressureBuffer()
|
||||||
.startWith(getActiveDownloads())
|
.startWith(getActiveDownloads())
|
||||||
.flatMap { download ->
|
.flatMap { download ->
|
||||||
if (download.status == Download.DOWNLOADING) {
|
if (download.status == Download.State.DOWNLOADING) {
|
||||||
val pageStatusSubject = PublishSubject.create<Int>()
|
val pageStatusSubject = PublishSubject.create<Int>()
|
||||||
setPagesSubject(download.pages, pageStatusSubject)
|
setPagesSubject(download.pages, pageStatusSubject)
|
||||||
return@flatMap pageStatusSubject
|
return@flatMap pageStatusSubject
|
||||||
.onBackpressureBuffer()
|
.onBackpressureBuffer()
|
||||||
.filter { it == Page.READY }
|
.filter { it == Page.READY }
|
||||||
.map { download }
|
.map { download }
|
||||||
} else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) {
|
} else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) {
|
||||||
setPagesSubject(download.pages, null)
|
setPagesSubject(download.pages, null)
|
||||||
}
|
}
|
||||||
Observable.just(download)
|
Observable.just(download)
|
||||||
}
|
}
|
||||||
.filter { it.status == Download.DOWNLOADING }
|
.filter { it.status == Download.State.DOWNLOADING }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
|
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) {
|
||||||
|
@ -110,7 +110,7 @@ class LibraryUpdateNotifier(private val context: Context) {
|
|||||||
setContentIntent(errorLogIntent)
|
setContentIntent(errorLogIntent)
|
||||||
addAction(
|
addAction(
|
||||||
R.drawable.ic_folder_24dp,
|
R.drawable.ic_folder_24dp,
|
||||||
context.getString(R.string.action_open_log),
|
context.getString(R.string.action_show_errors),
|
||||||
errorLogIntent
|
errorLogIntent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
|
||||||
@ -22,16 +23,27 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.toSChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.toSManga
|
||||||
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
||||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
||||||
import eu.kanade.tachiyomi.util.prepUpdateCover
|
import eu.kanade.tachiyomi.util.prepUpdateCover
|
||||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
||||||
|
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||||
import rx.Observable
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import rx.Subscription
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import rx.schedulers.Schedulers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
@ -55,17 +67,12 @@ class LibraryUpdateService(
|
|||||||
val coverCache: CoverCache = Injekt.get()
|
val coverCache: CoverCache = Injekt.get()
|
||||||
) : Service() {
|
) : Service() {
|
||||||
|
|
||||||
/**
|
|
||||||
* Wake lock that will be held until the service is destroyed.
|
|
||||||
*/
|
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
|
|
||||||
private lateinit var notifier: LibraryUpdateNotifier
|
private lateinit var notifier: LibraryUpdateNotifier
|
||||||
|
private lateinit var ioScope: CoroutineScope
|
||||||
|
|
||||||
/**
|
private var mangaToUpdate: List<LibraryManga> = mutableListOf()
|
||||||
* Subscription where the update is done.
|
private var updateJob: Job? = null
|
||||||
*/
|
|
||||||
private var subscription: Subscription? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines what should be updated within a service execution.
|
* Defines what should be updated within a service execution.
|
||||||
@ -80,6 +87,8 @@ class LibraryUpdateService(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
private var instance: LibraryUpdateService? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key for category to update.
|
* Key for category to update.
|
||||||
*/
|
*/
|
||||||
@ -110,17 +119,18 @@ class LibraryUpdateService(
|
|||||||
* @return true if service newly started, false otherwise
|
* @return true if service newly started, false otherwise
|
||||||
*/
|
*/
|
||||||
fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean {
|
fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean {
|
||||||
if (!isRunning(context)) {
|
return if (!isRunning(context)) {
|
||||||
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
||||||
putExtra(KEY_TARGET, target)
|
putExtra(KEY_TARGET, target)
|
||||||
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
|
||||||
return true
|
true
|
||||||
|
} else {
|
||||||
|
instance?.addMangaToQueue(category?.id ?: -1, target)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -140,6 +150,7 @@ class LibraryUpdateService(
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
notifier = LibraryUpdateNotifier(this)
|
notifier = LibraryUpdateNotifier(this)
|
||||||
wakeLock = acquireWakeLock(javaClass.name)
|
wakeLock = acquireWakeLock(javaClass.name)
|
||||||
|
|
||||||
@ -151,10 +162,14 @@ class LibraryUpdateService(
|
|||||||
* lock.
|
* lock.
|
||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
subscription?.unsubscribe()
|
updateJob?.cancel()
|
||||||
|
ioScope?.cancel()
|
||||||
if (wakeLock.isHeld) {
|
if (wakeLock.isHeld) {
|
||||||
wakeLock.release()
|
wakeLock.release()
|
||||||
}
|
}
|
||||||
|
if (instance == this) {
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,66 +193,68 @@ class LibraryUpdateService(
|
|||||||
val target = intent.getSerializableExtra(KEY_TARGET) as? Target
|
val target = intent.getSerializableExtra(KEY_TARGET) as? Target
|
||||||
?: return START_NOT_STICKY
|
?: return START_NOT_STICKY
|
||||||
|
|
||||||
// Unsubscribe from any previous subscription if needed.
|
instance = this
|
||||||
subscription?.unsubscribe()
|
|
||||||
|
|
||||||
// Update favorite manga. Destroy service when completed or in case of an error.
|
// Unsubscribe from any previous subscription if needed
|
||||||
subscription = Observable
|
updateJob?.cancel()
|
||||||
.defer {
|
|
||||||
val selectedScheme = preferences.libraryUpdatePrioritization().get()
|
|
||||||
val mangaList = getMangaToUpdate(intent, target)
|
|
||||||
.sortedWith(rankingScheme[selectedScheme])
|
|
||||||
|
|
||||||
// Update either chapter list or manga details.
|
// Update favorite manga
|
||||||
when (target) {
|
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
|
||||||
Target.CHAPTERS -> updateChapterList(mangaList)
|
addMangaToQueue(categoryId, target)
|
||||||
Target.COVERS -> updateCovers(mangaList)
|
|
||||||
Target.TRACKING -> updateTrackings(mangaList)
|
// Destroy service when completed or in case of an error.
|
||||||
}
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
|
Timber.e(exception)
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
updateJob = ioScope.launch(handler) {
|
||||||
|
when (target) {
|
||||||
|
Target.CHAPTERS -> updateChapterList()
|
||||||
|
Target.COVERS -> updateCovers()
|
||||||
|
Target.TRACKING -> updateTrackings()
|
||||||
}
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
}
|
||||||
.subscribe(
|
updateJob?.invokeOnCompletion { stopSelf(startId) }
|
||||||
{
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Timber.e(it)
|
|
||||||
stopSelf(startId)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stopSelf(startId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return START_REDELIVER_INTENT
|
return START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of manga to be updated.
|
* Adds list of manga to be updated.
|
||||||
*
|
*
|
||||||
* @param intent the update intent.
|
* @param category the ID of the category to update, or -1 if no category specified.
|
||||||
* @param target the target to update.
|
* @param target the target to update.
|
||||||
* @return a list of manga to update
|
|
||||||
*/
|
*/
|
||||||
fun getMangaToUpdate(intent: Intent, target: Target): List<LibraryManga> {
|
fun addMangaToQueue(categoryId: Int, target: Target) {
|
||||||
val categoryId = intent.getIntExtra(KEY_CATEGORY, -1)
|
val libraryManga = db.getLibraryMangas().executeAsBlocking()
|
||||||
|
|
||||||
var listToUpdate = if (categoryId != -1) {
|
var listToUpdate = if (categoryId != -1) {
|
||||||
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
libraryManga.filter { it.category == categoryId }
|
||||||
} else {
|
} else {
|
||||||
val categoriesToUpdate = preferences.libraryUpdateCategories().get().map(String::toInt)
|
val categoriesToUpdate = preferences.libraryUpdateCategories().get().map(String::toInt)
|
||||||
if (categoriesToUpdate.isNotEmpty()) {
|
val listToInclude = if (categoriesToUpdate.isNotEmpty()) {
|
||||||
db.getLibraryMangas().executeAsBlocking()
|
libraryManga.filter { it.category in categoriesToUpdate }
|
||||||
.filter { it.category in categoriesToUpdate }
|
|
||||||
.distinctBy { it.id }
|
|
||||||
} else {
|
} else {
|
||||||
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
|
libraryManga
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val categoriesToExclude = preferences.libraryUpdateCategoriesExclude().get().map(String::toInt)
|
||||||
|
val listToExclude = if (categoriesToExclude.isNotEmpty()) {
|
||||||
|
libraryManga.filter { it.category in categoriesToExclude }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
listToInclude.minus(listToExclude)
|
||||||
}
|
}
|
||||||
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
|
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
|
||||||
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
|
listToUpdate = listToUpdate.filterNot { it.status == SManga.COMPLETED }
|
||||||
}
|
}
|
||||||
|
|
||||||
return listToUpdate
|
val selectedScheme = preferences.libraryUpdatePrioritization().get()
|
||||||
|
mangaToUpdate = listToUpdate
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.sortedWith(rankingScheme[selectedScheme])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -249,77 +266,57 @@ class LibraryUpdateService(
|
|||||||
* @param mangaToUpdate the list to update
|
* @param mangaToUpdate the list to update
|
||||||
* @return an observable delivering the progress of each update.
|
* @return an observable delivering the progress of each update.
|
||||||
*/
|
*/
|
||||||
fun updateChapterList(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
suspend fun updateChapterList() {
|
||||||
// Initialize the variables holding the progress of the updates.
|
val progressCount = AtomicInteger(0)
|
||||||
val count = AtomicInteger(0)
|
|
||||||
// List containing new updates
|
|
||||||
val newUpdates = mutableListOf<Pair<LibraryManga, Array<Chapter>>>()
|
val newUpdates = mutableListOf<Pair<LibraryManga, Array<Chapter>>>()
|
||||||
// List containing failed updates
|
|
||||||
val failedUpdates = mutableListOf<Pair<Manga, String?>>()
|
val failedUpdates = mutableListOf<Pair<Manga, String?>>()
|
||||||
// Boolean to determine if DownloadManager has downloads
|
|
||||||
var hasDownloads = false
|
var hasDownloads = false
|
||||||
|
|
||||||
// Emit each manga and update it sequentially.
|
mangaToUpdate.forEach { manga ->
|
||||||
return Observable.from(mangaToUpdate)
|
if (updateJob?.isActive != true) {
|
||||||
// Notify manga that will update.
|
return
|
||||||
.doOnNext { notifier.showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
|
|
||||||
// Update the chapters of the manga
|
|
||||||
.concatMap { manga ->
|
|
||||||
updateManga(manga)
|
|
||||||
// If there's any error, return empty update and continue.
|
|
||||||
.onErrorReturn {
|
|
||||||
val errorMessage = if (it is NoChaptersException) {
|
|
||||||
getString(R.string.no_chapters_error)
|
|
||||||
} else {
|
|
||||||
it.message
|
|
||||||
}
|
|
||||||
failedUpdates.add(Pair(manga, errorMessage))
|
|
||||||
Pair(emptyList(), emptyList())
|
|
||||||
}
|
|
||||||
// Filter out mangas without new chapters (or failed).
|
|
||||||
.filter { (first) -> first.isNotEmpty() }
|
|
||||||
.doOnNext {
|
|
||||||
if (manga.shouldDownloadNewChapters(db, preferences)) {
|
|
||||||
downloadChapters(manga, it.first)
|
|
||||||
hasDownloads = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Convert to the manga that contains new chapters.
|
|
||||||
.map {
|
|
||||||
Pair(
|
|
||||||
manga,
|
|
||||||
(
|
|
||||||
it.first.sortedByDescending { ch -> ch.source_order }
|
|
||||||
.toTypedArray()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Add manga with new chapters to the list.
|
|
||||||
.doOnNext { manga ->
|
|
||||||
// Add to the list
|
|
||||||
newUpdates.add(manga)
|
|
||||||
}
|
|
||||||
// Notify result of the overall update.
|
|
||||||
.doOnCompleted {
|
|
||||||
notifier.cancelProgressNotification()
|
|
||||||
|
|
||||||
if (newUpdates.isNotEmpty()) {
|
notifier.showProgressNotification(manga, progressCount.andIncrement, mangaToUpdate.size)
|
||||||
notifier.showUpdateNotifications(newUpdates)
|
|
||||||
if (hasDownloads) {
|
|
||||||
DownloadService.start(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
|
try {
|
||||||
val errorFile = writeErrorFile(failedUpdates)
|
val (newChapters, _) = updateManga(manga)
|
||||||
notifier.showUpdateErrorNotification(
|
|
||||||
failedUpdates.map { it.first.title },
|
if (newChapters.isNotEmpty()) {
|
||||||
errorFile.getUriCompat(this)
|
if (manga.shouldDownloadNewChapters(db, preferences)) {
|
||||||
)
|
downloadChapters(manga, newChapters)
|
||||||
|
hasDownloads = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to the manga that contains new chapters
|
||||||
|
newUpdates.add(manga to newChapters.sortedByDescending { ch -> ch.source_order }.toTypedArray())
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val errorMessage = if (e is NoChaptersException) {
|
||||||
|
getString(R.string.no_chapters_error)
|
||||||
|
} else {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
failedUpdates.add(manga to errorMessage)
|
||||||
}
|
}
|
||||||
.map { (first) -> first }
|
}
|
||||||
|
|
||||||
|
notifier.cancelProgressNotification()
|
||||||
|
|
||||||
|
if (newUpdates.isNotEmpty()) {
|
||||||
|
notifier.showUpdateNotifications(newUpdates)
|
||||||
|
if (hasDownloads) {
|
||||||
|
DownloadService.start(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
|
||||||
|
val errorFile = writeErrorFile(failedUpdates)
|
||||||
|
notifier.showUpdateErrorNotification(
|
||||||
|
failedUpdates.map { it.first.title },
|
||||||
|
errorFile.getUriCompat(this)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
@ -334,94 +331,102 @@ class LibraryUpdateService(
|
|||||||
* @param manga the manga to update.
|
* @param manga the manga to update.
|
||||||
* @return a pair of the inserted and removed chapters.
|
* @return a pair of the inserted and removed chapters.
|
||||||
*/
|
*/
|
||||||
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
suspend fun updateManga(manga: Manga): Pair<List<Chapter>, List<Chapter>> {
|
||||||
val source = sourceManager.getOrStub(manga.source)
|
val source = sourceManager.getOrStub(manga.source)
|
||||||
|
|
||||||
// Update manga details metadata in the background
|
// Update manga details metadata in the background
|
||||||
if (preferences.autoUpdateMetadata()) {
|
if (preferences.autoUpdateMetadata()) {
|
||||||
source.fetchMangaDetails(manga)
|
val handler = CoroutineExceptionHandler { _, exception ->
|
||||||
.map { updatedManga ->
|
Timber.e(exception)
|
||||||
// Avoid "losing" existing cover
|
}
|
||||||
if (!updatedManga.thumbnail_url.isNullOrEmpty()) {
|
GlobalScope.launch(Dispatchers.IO + handler) {
|
||||||
manga.prepUpdateCover(coverCache, updatedManga, false)
|
val updatedManga = source.getMangaDetails(manga.toMangaInfo())
|
||||||
} else {
|
val sManga = updatedManga.toSManga()
|
||||||
updatedManga.thumbnail_url = manga.thumbnail_url
|
// Avoid "losing" existing cover
|
||||||
}
|
if (!sManga.thumbnail_url.isNullOrEmpty()) {
|
||||||
|
manga.prepUpdateCover(coverCache, sManga, false)
|
||||||
manga.copyFrom(updatedManga)
|
} else {
|
||||||
db.insertManga(manga).executeAsBlocking()
|
sManga.thumbnail_url = manga.thumbnail_url
|
||||||
manga
|
|
||||||
}
|
}
|
||||||
.onErrorResumeNext { Observable.just(manga) }
|
|
||||||
.subscribeOn(Schedulers.io())
|
manga.copyFrom(sManga)
|
||||||
.subscribe()
|
db.insertManga(manga).executeAsBlocking()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.fetchChapterList(manga)
|
val chapters = source.getChapterList(manga.toMangaInfo())
|
||||||
.map { syncChaptersWithSource(db, it, manga, source) }
|
.map { it.toSChapter() }
|
||||||
|
|
||||||
|
return syncChaptersWithSource(db, chapters, manga, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateCovers(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
private suspend fun updateCovers() {
|
||||||
var count = 0
|
var progressCount = 0
|
||||||
|
|
||||||
return Observable.from(mangaToUpdate)
|
mangaToUpdate.forEach { manga ->
|
||||||
.doOnNext {
|
if (updateJob?.isActive != true) {
|
||||||
notifier.showProgressNotification(it, count++, mangaToUpdate.size)
|
return
|
||||||
}
|
}
|
||||||
.flatMap { manga ->
|
|
||||||
val source = sourceManager.get(manga.source)
|
|
||||||
?: return@flatMap Observable.empty<LibraryManga>()
|
|
||||||
|
|
||||||
source.fetchMangaDetails(manga)
|
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
|
||||||
.map { networkManga ->
|
|
||||||
manga.prepUpdateCover(coverCache, networkManga, true)
|
sourceManager.get(manga.source)?.let { source ->
|
||||||
networkManga.thumbnail_url?.let {
|
try {
|
||||||
manga.thumbnail_url = it
|
val networkManga = source.getMangaDetails(manga.toMangaInfo())
|
||||||
db.insertManga(manga).executeAsBlocking()
|
val sManga = networkManga.toSManga()
|
||||||
}
|
manga.prepUpdateCover(coverCache, sManga, true)
|
||||||
manga
|
sManga.thumbnail_url?.let {
|
||||||
|
manga.thumbnail_url = it
|
||||||
|
db.insertManga(manga).executeAsBlocking()
|
||||||
}
|
}
|
||||||
.onErrorReturn { manga }
|
} catch (e: Throwable) {
|
||||||
}
|
// Ignore errors and continue
|
||||||
.doOnCompleted {
|
Timber.e(e)
|
||||||
notifier.cancelProgressNotification()
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier.cancelProgressNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||||
* background thread, so it's safe to do heavy operations or network calls here.
|
* background thread, so it's safe to do heavy operations or network calls here.
|
||||||
*/
|
*/
|
||||||
private fun updateTrackings(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
|
private suspend fun updateTrackings() {
|
||||||
// Initialize the variables holding the progress of the updates.
|
var progressCount = 0
|
||||||
var count = 0
|
|
||||||
|
|
||||||
val loggedServices = trackManager.services.filter { it.isLogged }
|
val loggedServices = trackManager.services.filter { it.isLogged }
|
||||||
|
|
||||||
// Emit each manga and update it sequentially.
|
mangaToUpdate.forEach { manga ->
|
||||||
return Observable.from(mangaToUpdate)
|
if (updateJob?.isActive != true) {
|
||||||
// Notify manga that will update.
|
return
|
||||||
.doOnNext { notifier.showProgressNotification(it, count++, mangaToUpdate.size) }
|
}
|
||||||
// Update the tracking details.
|
|
||||||
.concatMap { manga ->
|
|
||||||
val tracks = db.getTracks(manga).executeAsBlocking()
|
|
||||||
|
|
||||||
Observable.from(tracks)
|
// Notify manga that will update.
|
||||||
.concatMap { track ->
|
notifier.showProgressNotification(manga, progressCount++, mangaToUpdate.size)
|
||||||
val service = trackManager.getService(track.sync_id)
|
|
||||||
if (service != null && service in loggedServices) {
|
// Update the tracking details.
|
||||||
service.refresh(track)
|
db.getTracks(manga).executeAsBlocking()
|
||||||
.doOnNext { db.insertTrack(it).executeAsBlocking() }
|
.map { track ->
|
||||||
.onErrorReturn { track }
|
supervisorScope {
|
||||||
} else {
|
async {
|
||||||
Observable.empty()
|
val service = trackManager.getService(track.sync_id)
|
||||||
|
if (service != null && service in loggedServices) {
|
||||||
|
try {
|
||||||
|
val updatedTrack = service.refresh(track)
|
||||||
|
db.insertTrack(updatedTrack).executeAsBlocking()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// Ignore errors and continue
|
||||||
|
Timber.e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { manga }
|
}
|
||||||
}
|
.awaitAll()
|
||||||
.doOnCompleted {
|
}
|
||||||
notifier.cancelProgressNotification()
|
|
||||||
}
|
notifier.cancelProgressNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -430,15 +435,14 @@ class LibraryUpdateService(
|
|||||||
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
|
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
|
||||||
try {
|
try {
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
val destFile = File(externalCacheDir, "tachiyomi_update_errors.txt")
|
val file = createFileInCacheDir("tachiyomi_update_errors.txt")
|
||||||
|
file.bufferedWriter().use { out ->
|
||||||
destFile.bufferedWriter().use { out ->
|
|
||||||
errors.forEach { (manga, error) ->
|
errors.forEach { (manga, error) ->
|
||||||
val source = sourceManager.getOrStub(manga.source)
|
val source = sourceManager.getOrStub(manga.source)
|
||||||
out.write("${manga.title} ($source): $error\n")
|
out.write("${manga.title} ($source): $error\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return destFile
|
return file
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Empty
|
// Empty
|
||||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.notification
|
|||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ClipData
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@ -69,9 +70,10 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
)
|
)
|
||||||
// Share backup file
|
// Share backup file
|
||||||
ACTION_SHARE_BACKUP ->
|
ACTION_SHARE_BACKUP ->
|
||||||
shareBackup(
|
shareFile(
|
||||||
context,
|
context,
|
||||||
intent.getParcelableExtra(EXTRA_URI),
|
intent.getParcelableExtra(EXTRA_URI),
|
||||||
|
if (intent.getBooleanExtra(EXTRA_IS_LEGACY_BACKUP, false)) "application/json" else "application/x-protobuf+gzip",
|
||||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||||
)
|
)
|
||||||
ACTION_CANCEL_RESTORE -> cancelRestore(
|
ACTION_CANCEL_RESTORE -> cancelRestore(
|
||||||
@ -100,6 +102,14 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
markAsRead(urls, mangaId)
|
markAsRead(urls, mangaId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Share crash dump file
|
||||||
|
ACTION_SHARE_CRASH_LOG ->
|
||||||
|
shareFile(
|
||||||
|
context,
|
||||||
|
intent.getParcelableExtra(EXTRA_URI),
|
||||||
|
"text/plain",
|
||||||
|
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,14 +130,13 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
* @param notificationId id of notification
|
* @param notificationId id of notification
|
||||||
*/
|
*/
|
||||||
private fun shareImage(context: Context, path: String, notificationId: Int) {
|
private fun shareImage(context: Context, path: String, notificationId: Int) {
|
||||||
// Create intent
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
val uri = File(path).getUriCompat(context)
|
val uri = File(path).getUriCompat(context)
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
clipData = ClipData.newRawUri(null, uri)
|
||||||
type = "image/*"
|
type = "image/*"
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
}
|
}
|
||||||
// Dismiss notification
|
|
||||||
dismissNotification(context, notificationId)
|
dismissNotification(context, notificationId)
|
||||||
// Launch share activity
|
// Launch share activity
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
@ -140,10 +149,11 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
* @param path path of file
|
* @param path path of file
|
||||||
* @param notificationId id of notification
|
* @param notificationId id of notification
|
||||||
*/
|
*/
|
||||||
private fun shareBackup(context: Context, uri: Uri, notificationId: Int) {
|
private fun shareFile(context: Context, uri: Uri, fileMimeType: String, notificationId: Int) {
|
||||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
type = "application/json"
|
clipData = ClipData.newRawUri(null, uri)
|
||||||
|
type = fileMimeType
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
}
|
}
|
||||||
// Dismiss notification
|
// Dismiss notification
|
||||||
@ -244,59 +254,34 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val NAME = "NotificationReceiver"
|
private const val NAME = "NotificationReceiver"
|
||||||
|
|
||||||
// Called to launch share intent.
|
|
||||||
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
|
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
|
||||||
|
|
||||||
// Called to delete image.
|
|
||||||
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
|
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
|
||||||
|
|
||||||
// Called to launch send intent.
|
|
||||||
private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP"
|
private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP"
|
||||||
|
|
||||||
// Called to cancel backup restore job.
|
private const val ACTION_SHARE_CRASH_LOG = "$ID.$NAME.SEND_CRASH_LOG"
|
||||||
|
|
||||||
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
|
private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE"
|
||||||
|
|
||||||
// Called to cancel library update.
|
|
||||||
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
|
||||||
|
|
||||||
// Called to mark manga chapters as read.
|
|
||||||
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
|
private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ"
|
||||||
|
|
||||||
// Called to open chapter.
|
|
||||||
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
|
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
|
||||||
|
|
||||||
// Value containing file location.
|
|
||||||
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
|
|
||||||
|
|
||||||
// Called to resume downloads.
|
|
||||||
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
|
||||||
|
|
||||||
// Called to pause downloads.
|
|
||||||
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
|
private const val ACTION_PAUSE_DOWNLOADS = "$ID.$NAME.ACTION_PAUSE_DOWNLOADS"
|
||||||
|
|
||||||
// Called to clear downloads.
|
|
||||||
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
|
||||||
|
|
||||||
// Called to dismiss notification.
|
|
||||||
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
|
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
|
||||||
|
|
||||||
// Value containing uri.
|
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
|
||||||
private const val EXTRA_URI = "$ID.$NAME.URI"
|
private const val EXTRA_URI = "$ID.$NAME.URI"
|
||||||
|
|
||||||
// Value containing notification id.
|
|
||||||
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
|
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
|
||||||
|
|
||||||
// Value containing group id.
|
|
||||||
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
|
private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID"
|
||||||
|
|
||||||
// Value containing manga id.
|
|
||||||
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
|
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
|
||||||
|
|
||||||
// Value containing chapter id.
|
|
||||||
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
|
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
|
||||||
|
|
||||||
// Value containing chapter url.
|
|
||||||
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
|
private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL"
|
||||||
|
private const val EXTRA_IS_LEGACY_BACKUP = "$ID.$NAME.EXTRA_IS_LEGACY_BACKUP"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a [PendingIntent] that resumes the download of a chapter
|
* Returns a [PendingIntent] that resumes the download of a chapter
|
||||||
@ -509,10 +494,11 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
* @param notificationId id of notification
|
* @param notificationId id of notification
|
||||||
* @return [PendingIntent]
|
* @return [PendingIntent]
|
||||||
*/
|
*/
|
||||||
internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent {
|
internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, isLegacyFormat: Boolean, notificationId: Int): PendingIntent {
|
||||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||||
action = ACTION_SHARE_BACKUP
|
action = ACTION_SHARE_BACKUP
|
||||||
putExtra(EXTRA_URI, uri)
|
putExtra(EXTRA_URI, uri)
|
||||||
|
putExtra(EXTRA_IS_LEGACY_BACKUP, isLegacyFormat)
|
||||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
}
|
}
|
||||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
@ -534,6 +520,23 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||||||
return PendingIntent.getActivity(context, 0, intent, 0)
|
return PendingIntent.getActivity(context, 0, intent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns [PendingIntent] that starts a share activity for a crash log dump file.
|
||||||
|
*
|
||||||
|
* @param context context of application
|
||||||
|
* @param uri uri of file
|
||||||
|
* @param notificationId id of notification
|
||||||
|
* @return [PendingIntent]
|
||||||
|
*/
|
||||||
|
internal fun shareCrashLogPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent {
|
||||||
|
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_SHARE_CRASH_LOG
|
||||||
|
putExtra(EXTRA_URI, uri)
|
||||||
|
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns [PendingIntent] that cancels a backup restore job.
|
* Returns [PendingIntent] that cancels a backup restore job.
|
||||||
*
|
*
|
||||||
|
@ -62,6 +62,12 @@ object Notifications {
|
|||||||
const val ID_BACKUP_COMPLETE = -502
|
const val ID_BACKUP_COMPLETE = -502
|
||||||
const val ID_RESTORE_COMPLETE = -504
|
const val ID_RESTORE_COMPLETE = -504
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification channel used for crash log file sharing.
|
||||||
|
*/
|
||||||
|
const val CHANNEL_CRASH_LOGS = "crash_logs_channel"
|
||||||
|
const val ID_CRASH_LOGS = -601
|
||||||
|
|
||||||
private val deprecatedChannels = listOf(
|
private val deprecatedChannels = listOf(
|
||||||
"downloader_channel",
|
"downloader_channel",
|
||||||
"backup_restore_complete_channel"
|
"backup_restore_complete_channel"
|
||||||
@ -143,7 +149,12 @@ object Notifications {
|
|||||||
group = GROUP_BACKUP_RESTORE
|
group = GROUP_BACKUP_RESTORE
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
setSound(null, null)
|
setSound(null, null)
|
||||||
}
|
},
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_CRASH_LOGS,
|
||||||
|
context.getString(R.string.channel_crash_logs),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
).forEach(context.notificationManager::createNotificationChannel)
|
).forEach(context.notificationManager::createNotificationChannel)
|
||||||
|
|
||||||
// Delete old notification channels
|
// Delete old notification channels
|
||||||
|
@ -23,6 +23,14 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val showPageNumber = "pref_show_page_number_key"
|
const val showPageNumber = "pref_show_page_number_key"
|
||||||
|
|
||||||
|
const val dualPageSplitPaged = "pref_dual_page_split"
|
||||||
|
|
||||||
|
const val dualPageSplitWebtoon = "pref_dual_page_split_webtoon"
|
||||||
|
|
||||||
|
const val dualPageInvertPaged = "pref_dual_page_invert"
|
||||||
|
|
||||||
|
const val dualPageInvertWebtoon = "pref_dual_page_invert_webtoon"
|
||||||
|
|
||||||
const val showReadingMode = "pref_show_reading_mode"
|
const val showReadingMode = "pref_show_reading_mode"
|
||||||
|
|
||||||
const val trueColor = "pref_true_color_key"
|
const val trueColor = "pref_true_color_key"
|
||||||
@ -57,7 +65,9 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val readWithTapping = "reader_tap"
|
const val readWithTapping = "reader_tap"
|
||||||
|
|
||||||
const val readWithTappingInverted = "reader_tapping_inverted"
|
const val pagerNavInverted = "reader_tapping_inverted"
|
||||||
|
|
||||||
|
const val webtoonNavInverted = "reader_tapping_inverted_webtoon"
|
||||||
|
|
||||||
const val readWithLongTap = "reader_long_tap"
|
const val readWithLongTap = "reader_long_tap"
|
||||||
|
|
||||||
@ -65,6 +75,14 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
||||||
|
|
||||||
|
const val navigationModePager = "reader_navigation_mode_pager"
|
||||||
|
|
||||||
|
const val navigationModeWebtoon = "reader_navigation_mode_webtoon"
|
||||||
|
|
||||||
|
const val showNavigationOverlayNewUser = "reader_navigation_overlay_new_user"
|
||||||
|
|
||||||
|
const val showNavigationOverlayOnStart = "reader_navigation_overlay_on_start"
|
||||||
|
|
||||||
const val webtoonSidePadding = "webtoon_side_padding"
|
const val webtoonSidePadding = "webtoon_side_padding"
|
||||||
|
|
||||||
const val portraitColumns = "pref_library_columns_portrait_key"
|
const val portraitColumns = "pref_library_columns_portrait_key"
|
||||||
@ -106,6 +124,7 @@ object PreferenceKeys {
|
|||||||
const val libraryUpdateRestriction = "library_update_restriction"
|
const val libraryUpdateRestriction = "library_update_restriction"
|
||||||
|
|
||||||
const val libraryUpdateCategories = "library_update_categories"
|
const val libraryUpdateCategories = "library_update_categories"
|
||||||
|
const val libraryUpdateCategoriesExclude = "library_update_categories_exclude"
|
||||||
|
|
||||||
const val libraryUpdatePrioritization = "library_update_prioritization"
|
const val libraryUpdatePrioritization = "library_update_prioritization"
|
||||||
|
|
||||||
@ -117,6 +136,8 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val filterCompleted = "pref_filter_library_completed"
|
const val filterCompleted = "pref_filter_library_completed"
|
||||||
|
|
||||||
|
const val filterTracked = "pref_filter_library_tracked"
|
||||||
|
|
||||||
const val librarySortingMode = "library_sorting_mode"
|
const val librarySortingMode = "library_sorting_mode"
|
||||||
|
|
||||||
const val automaticExtUpdates = "automatic_ext_updates"
|
const val automaticExtUpdates = "automatic_ext_updates"
|
||||||
@ -144,6 +165,7 @@ object PreferenceKeys {
|
|||||||
const val downloadNew = "download_new"
|
const val downloadNew = "download_new"
|
||||||
|
|
||||||
const val downloadNewCategories = "download_new_categories"
|
const val downloadNewCategories = "download_new_categories"
|
||||||
|
const val downloadNewCategoriesExclude = "download_new_categories_exclude"
|
||||||
|
|
||||||
const val libraryDisplayMode = "pref_display_mode_library"
|
const val libraryDisplayMode = "pref_display_mode_library"
|
||||||
|
|
||||||
@ -163,11 +185,13 @@ object PreferenceKeys {
|
|||||||
|
|
||||||
const val categoryTabs = "display_category_tabs"
|
const val categoryTabs = "display_category_tabs"
|
||||||
|
|
||||||
|
const val categoryNumberOfItems = "display_number_of_items"
|
||||||
|
|
||||||
const val alwaysShowChapterTransition = "always_show_chapter_transition"
|
const val alwaysShowChapterTransition = "always_show_chapter_transition"
|
||||||
|
|
||||||
const val searchPinnedSourcesOnly = "search_pinned_sources_only"
|
const val searchPinnedSourcesOnly = "search_pinned_sources_only"
|
||||||
|
|
||||||
const val enableDoh = "enable_doh"
|
const val dohProvider = "doh_provider"
|
||||||
|
|
||||||
const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
|
const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ package eu.kanade.tachiyomi.data.preference
|
|||||||
*/
|
*/
|
||||||
object PreferenceValues {
|
object PreferenceValues {
|
||||||
|
|
||||||
|
/* ktlint-disable experimental:enum-entry-name-case */
|
||||||
|
|
||||||
// Keys are lowercase to match legacy string values
|
// Keys are lowercase to match legacy string values
|
||||||
enum class ThemeMode {
|
enum class ThemeMode {
|
||||||
light,
|
light,
|
||||||
@ -25,16 +27,18 @@ object PreferenceValues {
|
|||||||
amoled,
|
amoled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ktlint-enable experimental:enum-entry-name-case */
|
||||||
|
|
||||||
enum class DisplayMode {
|
enum class DisplayMode {
|
||||||
COMPACT_GRID,
|
COMPACT_GRID,
|
||||||
COMFORTABLE_GRID,
|
COMFORTABLE_GRID,
|
||||||
LIST,
|
LIST,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TappingInvertMode {
|
enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) {
|
||||||
NONE,
|
NONE,
|
||||||
HORIZONTAL,
|
HORIZONTAL(shouldInvertHorizontal = true),
|
||||||
VERTICAL,
|
VERTICAL(shouldInvertVertical = true),
|
||||||
BOTH
|
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.data.preference.PreferenceValues.DisplayMode
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
|
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -23,8 +22,7 @@ import java.util.Locale
|
|||||||
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
|
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
fun <T> Preference<T>.asImmediateFlow(block: (T) -> Unit): Flow<T> {
|
||||||
fun <T> Preference<T>.asImmediateFlow(block: (value: T) -> Unit): Flow<T> {
|
|
||||||
block(get())
|
block(get())
|
||||||
return asFlow()
|
return asFlow()
|
||||||
.onEach { block(it) }
|
.onEach { block(it) }
|
||||||
@ -38,7 +36,10 @@ operator fun <T> Preference<Set<T>>.minusAssign(item: T) {
|
|||||||
set(get() - item)
|
set(get() - item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
fun Preference<Boolean>.toggle() {
|
||||||
|
set(!get())
|
||||||
|
}
|
||||||
|
|
||||||
class PreferencesHelper(val context: Context) {
|
class PreferencesHelper(val context: Context) {
|
||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
@ -92,6 +93,14 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true)
|
fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true)
|
||||||
|
|
||||||
|
fun dualPageSplitPaged() = flowPrefs.getBoolean(Keys.dualPageSplitPaged, false)
|
||||||
|
|
||||||
|
fun dualPageSplitWebtoon() = flowPrefs.getBoolean(Keys.dualPageSplitWebtoon, false)
|
||||||
|
|
||||||
|
fun dualPageInvertPaged() = flowPrefs.getBoolean(Keys.dualPageInvertPaged, false)
|
||||||
|
|
||||||
|
fun dualPageInvertWebtoon() = flowPrefs.getBoolean(Keys.dualPageInvertWebtoon, false)
|
||||||
|
|
||||||
fun showReadingMode() = prefs.getBoolean(Keys.showReadingMode, true)
|
fun showReadingMode() = prefs.getBoolean(Keys.showReadingMode, true)
|
||||||
|
|
||||||
fun trueColor() = flowPrefs.getBoolean(Keys.trueColor, false)
|
fun trueColor() = flowPrefs.getBoolean(Keys.trueColor, false)
|
||||||
@ -130,7 +139,9 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun readWithTapping() = flowPrefs.getBoolean(Keys.readWithTapping, true)
|
fun readWithTapping() = flowPrefs.getBoolean(Keys.readWithTapping, true)
|
||||||
|
|
||||||
fun readWithTappingInverted() = flowPrefs.getEnum(Keys.readWithTappingInverted, Values.TappingInvertMode.NONE)
|
fun pagerNavInverted() = flowPrefs.getEnum(Keys.pagerNavInverted, Values.TappingInvertMode.NONE)
|
||||||
|
|
||||||
|
fun webtoonNavInverted() = flowPrefs.getEnum(Keys.webtoonNavInverted, Values.TappingInvertMode.NONE)
|
||||||
|
|
||||||
fun readWithLongTap() = flowPrefs.getBoolean(Keys.readWithLongTap, true)
|
fun readWithLongTap() = flowPrefs.getBoolean(Keys.readWithLongTap, true)
|
||||||
|
|
||||||
@ -138,6 +149,14 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false)
|
||||||
|
|
||||||
|
fun navigationModePager() = flowPrefs.getInt(Keys.navigationModePager, 0)
|
||||||
|
|
||||||
|
fun navigationModeWebtoon() = flowPrefs.getInt(Keys.navigationModeWebtoon, 0)
|
||||||
|
|
||||||
|
fun showNavigationOverlayNewUser() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUser, true)
|
||||||
|
|
||||||
|
fun showNavigationOverlayOnStart() = flowPrefs.getBoolean(Keys.showNavigationOverlayOnStart, false)
|
||||||
|
|
||||||
fun portraitColumns() = flowPrefs.getInt(Keys.portraitColumns, 0)
|
fun portraitColumns() = flowPrefs.getInt(Keys.portraitColumns, 0)
|
||||||
|
|
||||||
fun landscapeColumns() = flowPrefs.getInt(Keys.landscapeColumns, 0)
|
fun landscapeColumns() = flowPrefs.getInt(Keys.landscapeColumns, 0)
|
||||||
@ -199,6 +218,7 @@ class PreferencesHelper(val context: Context) {
|
|||||||
fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, setOf("wifi"))
|
fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, setOf("wifi"))
|
||||||
|
|
||||||
fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
|
fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
|
||||||
|
fun libraryUpdateCategoriesExclude() = flowPrefs.getStringSet(Keys.libraryUpdateCategoriesExclude, emptySet())
|
||||||
|
|
||||||
fun libraryUpdatePrioritization() = flowPrefs.getInt(Keys.libraryUpdatePrioritization, 0)
|
fun libraryUpdatePrioritization() = flowPrefs.getInt(Keys.libraryUpdatePrioritization, 0)
|
||||||
|
|
||||||
@ -212,12 +232,16 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun categoryTabs() = flowPrefs.getBoolean(Keys.categoryTabs, true)
|
fun categoryTabs() = flowPrefs.getBoolean(Keys.categoryTabs, true)
|
||||||
|
|
||||||
|
fun categoryNumberOfItems() = flowPrefs.getBoolean(Keys.categoryNumberOfItems, false)
|
||||||
|
|
||||||
fun filterDownloaded() = flowPrefs.getInt(Keys.filterDownloaded, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
fun filterDownloaded() = flowPrefs.getInt(Keys.filterDownloaded, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
||||||
|
|
||||||
fun filterUnread() = flowPrefs.getInt(Keys.filterUnread, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
fun filterUnread() = flowPrefs.getInt(Keys.filterUnread, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
||||||
|
|
||||||
fun filterCompleted() = flowPrefs.getInt(Keys.filterCompleted, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
fun filterCompleted() = flowPrefs.getInt(Keys.filterCompleted, ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
||||||
|
|
||||||
|
fun filterTracking(name: Int) = flowPrefs.getInt("${Keys.filterTracked}_$name", ExtendedNavigationView.Item.TriStateGroup.State.IGNORE.value)
|
||||||
|
|
||||||
fun librarySortingMode() = flowPrefs.getInt(Keys.librarySortingMode, 0)
|
fun librarySortingMode() = flowPrefs.getInt(Keys.librarySortingMode, 0)
|
||||||
|
|
||||||
fun librarySortingAscending() = flowPrefs.getBoolean("library_sorting_ascending", true)
|
fun librarySortingAscending() = flowPrefs.getBoolean("library_sorting_ascending", true)
|
||||||
@ -241,6 +265,7 @@ class PreferencesHelper(val context: Context) {
|
|||||||
fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false)
|
fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false)
|
||||||
|
|
||||||
fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet())
|
fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet())
|
||||||
|
fun downloadNewCategoriesExclude() = flowPrefs.getStringSet(Keys.downloadNewCategoriesExclude, emptySet())
|
||||||
|
|
||||||
fun lang() = prefs.getString(Keys.lang, "")
|
fun lang() = prefs.getString(Keys.lang, "")
|
||||||
|
|
||||||
@ -254,7 +279,7 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet())
|
fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet())
|
||||||
|
|
||||||
fun enableDoh() = prefs.getBoolean(Keys.enableDoh, false)
|
fun dohProvider() = prefs.getInt(Keys.dohProvider, -1)
|
||||||
|
|
||||||
fun lastSearchQuerySearchSettings() = flowPrefs.getString("last_search_query", "")
|
fun lastSearchQuerySearchSettings() = flowPrefs.getString("last_search_query", "")
|
||||||
|
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
package eu.kanade.tachiyomi.data.track
|
package eu.kanade.tachiyomi.data.track
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import rx.Completable
|
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
abstract class TrackService(val id: Int) {
|
abstract class TrackService(val id: Int) {
|
||||||
@ -20,7 +20,8 @@ abstract class TrackService(val id: Int) {
|
|||||||
get() = networkService.client
|
get() = networkService.client
|
||||||
|
|
||||||
// Name of the manga sync service to display
|
// Name of the manga sync service to display
|
||||||
abstract val name: String
|
@StringRes
|
||||||
|
abstract fun nameRes(): Int
|
||||||
|
|
||||||
// Application and remote support for reading dates
|
// Application and remote support for reading dates
|
||||||
open val supportsReadingDates: Boolean = false
|
open val supportsReadingDates: Boolean = false
|
||||||
@ -28,6 +29,7 @@ abstract class TrackService(val id: Int) {
|
|||||||
@DrawableRes
|
@DrawableRes
|
||||||
abstract fun getLogo(): Int
|
abstract fun getLogo(): Int
|
||||||
|
|
||||||
|
@ColorInt
|
||||||
abstract fun getLogoColor(): Int
|
abstract fun getLogoColor(): Int
|
||||||
|
|
||||||
abstract fun getStatusList(): List<Int>
|
abstract fun getStatusList(): List<Int>
|
||||||
@ -44,17 +46,17 @@ abstract class TrackService(val id: Int) {
|
|||||||
|
|
||||||
abstract fun displayScore(track: Track): String
|
abstract fun displayScore(track: Track): String
|
||||||
|
|
||||||
abstract fun add(track: Track): Observable<Track>
|
abstract suspend fun add(track: Track): Track
|
||||||
|
|
||||||
abstract fun update(track: Track): Observable<Track>
|
abstract suspend fun update(track: Track): Track
|
||||||
|
|
||||||
abstract fun bind(track: Track): Observable<Track>
|
abstract suspend fun bind(track: Track): Track
|
||||||
|
|
||||||
abstract fun search(query: String): Observable<List<TrackSearch>>
|
abstract suspend fun search(query: String): List<TrackSearch>
|
||||||
|
|
||||||
abstract fun refresh(track: Track): Observable<Track>
|
abstract suspend fun refresh(track: Track): Track
|
||||||
|
|
||||||
abstract fun login(username: String, password: String): Completable
|
abstract suspend fun login(username: String, password: String)
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
open fun logout() {
|
open fun logout() {
|
||||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
@ -9,8 +10,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import rx.Completable
|
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||||
@ -23,9 +22,6 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val PLANNING = 5
|
const val PLANNING = 5
|
||||||
const val REPEATING = 6
|
const val REPEATING = 6
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
|
||||||
const val DEFAULT_SCORE = 0
|
|
||||||
|
|
||||||
const val POINT_100 = "POINT_100"
|
const val POINT_100 = "POINT_100"
|
||||||
const val POINT_10 = "POINT_10"
|
const val POINT_10 = "POINT_10"
|
||||||
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
|
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
|
||||||
@ -33,14 +29,14 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val POINT_3 = "POINT_3"
|
const val POINT_3 = "POINT_3"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "AniList"
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
|
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
|
||||||
|
|
||||||
private val api by lazy { AnilistApi(client, interceptor) }
|
private val api by lazy { AnilistApi(client, interceptor) }
|
||||||
|
|
||||||
|
override val supportsReadingDates: Boolean = true
|
||||||
|
|
||||||
private val scorePreference = preferences.anilistScoreType()
|
private val scorePreference = preferences.anilistScoreType()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -53,6 +49,9 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
override fun nameRes() = R.string.tracker_anilist
|
||||||
|
|
||||||
override fun getLogo() = R.drawable.ic_tracker_anilist
|
override fun getLogo() = R.drawable.ic_tracker_anilist
|
||||||
|
|
||||||
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
||||||
@ -131,65 +130,58 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track)
|
return api.addLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override suspend fun update(track: Track): Track {
|
||||||
// If user was using API v1 fetch library_id
|
// If user was using API v1 fetch library_id
|
||||||
if (track.library_id == null || track.library_id!! == 0L) {
|
if (track.library_id == null || track.library_id!! == 0L) {
|
||||||
return api.findLibManga(track, getUsername().toInt()).flatMap {
|
val libManga = api.findLibManga(track, getUsername().toInt())
|
||||||
if (it == null) {
|
?: throw Exception("$track not found on user library")
|
||||||
throw Exception("$track not found on user library")
|
track.library_id = libManga.library_id
|
||||||
}
|
|
||||||
track.library_id = it.library_id
|
|
||||||
api.updateLibManga(track)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override suspend fun bind(track: Track): Track {
|
||||||
return api.findLibManga(track, getUsername().toInt())
|
val remoteTrack = api.findLibManga(track, getUsername().toInt())
|
||||||
.flatMap { remoteTrack ->
|
return if (remoteTrack != null) {
|
||||||
if (remoteTrack != null) {
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.library_id = remoteTrack.library_id
|
||||||
track.library_id = remoteTrack.library_id
|
update(track)
|
||||||
update(track)
|
} else {
|
||||||
} else {
|
// Set default fields if it's not found in the list
|
||||||
// Set default fields if it's not found in the list
|
track.status = READING
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
track.score = 0F
|
||||||
track.status = DEFAULT_STATUS
|
add(track)
|
||||||
add(track)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override suspend fun refresh(track: Track): Track {
|
||||||
return api.getLibManga(track, getUsername().toInt())
|
val remoteTrack = api.getLibManga(track, getUsername().toInt())
|
||||||
.map { remoteTrack ->
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
return track
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun login(username: String, password: String) = login(password)
|
override suspend fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
fun login(token: String): Completable {
|
suspend fun login(token: String) {
|
||||||
val oauth = api.createOAuth(token)
|
try {
|
||||||
interceptor.setAuth(oauth)
|
val oauth = api.createOAuth(token)
|
||||||
return api.getCurrentUser().map { (username, scoreType) ->
|
interceptor.setAuth(oauth)
|
||||||
|
val (username, scoreType) = api.getCurrentUser()
|
||||||
scorePreference.set(scoreType)
|
scorePreference.set(scoreType)
|
||||||
saveCredentials(username.toString(), oauth.access_token)
|
saveCredentials(username.toString(), oauth.access_token)
|
||||||
}.doOnError {
|
} catch (e: Throwable) {
|
||||||
logout()
|
logout()
|
||||||
}.toCompletable()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
|
@ -2,11 +2,17 @@ package eu.kanade.tachiyomi.data.track.anilist
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.afollestad.date.dayOfMonth
|
||||||
|
import com.afollestad.date.month
|
||||||
|
import com.afollestad.date.year
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import kotlinx.serialization.decodeFromString
|
import eu.kanade.tachiyomi.network.await
|
||||||
import kotlinx.serialization.json.Json
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
@ -18,24 +24,17 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import kotlinx.serialization.json.long
|
import kotlinx.serialization.json.long
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
import kotlinx.serialization.json.putJsonObject
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
val query =
|
return withIOContext {
|
||||||
"""
|
val query = """
|
||||||
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
||||||
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
|
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
|
||||||
| id
|
| id
|
||||||
@ -43,68 +42,67 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
|""".trimMargin()
|
|""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
putJsonObject("variables") {
|
putJsonObject("variables") {
|
||||||
put("mangaId", track.media_id)
|
put("mangaId", track.media_id)
|
||||||
put("progress", track.last_chapter_read)
|
put("progress", track.last_chapter_read)
|
||||||
put("status", track.toAnilistStatus())
|
put("status", track.toAnilistStatus())
|
||||||
}
|
|
||||||
}
|
|
||||||
val body = payload.toString().toRequestBody(jsonMime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
netResponse.close()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
}
|
||||||
val response = json.decodeFromString<JsonObject>(responseBody)
|
|
||||||
track.library_id = response["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
|
||||||
track
|
|
||||||
}
|
}
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
apiUrl,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
track.library_id =
|
||||||
|
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
val query =
|
return withIOContext {
|
||||||
"""
|
val query = """
|
||||||
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|
|mutation UpdateManga(
|
||||||
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus,
|
||||||
|
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|
||||||
|
|) {
|
||||||
|
|SaveMediaListEntry(
|
||||||
|
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status,
|
||||||
|
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|
||||||
|
|) {
|
||||||
|id
|
|id
|
||||||
|status
|
|status
|
||||||
|progress
|
|progress
|
||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
|""".trimMargin()
|
|""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
putJsonObject("variables") {
|
putJsonObject("variables") {
|
||||||
put("listId", track.library_id)
|
put("listId", track.library_id)
|
||||||
put("progress", track.last_chapter_read)
|
put("progress", track.last_chapter_read)
|
||||||
put("status", track.toAnilistStatus())
|
put("status", track.toAnilistStatus())
|
||||||
put("score", track.score.toInt())
|
put("score", track.score.toInt())
|
||||||
|
put("startedAt", createDate(track.started_reading_date))
|
||||||
|
put("completedAt", createDate(track.finished_reading_date))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
authClient.newCall(POST(apiUrl, body = payload.toString().toRequestBody(jsonMime)))
|
||||||
|
.await()
|
||||||
|
track
|
||||||
}
|
}
|
||||||
val body = payload.toString().toRequestBody(jsonMime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map {
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(search: String): Observable<List<TrackSearch>> {
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
val query =
|
return withIOContext {
|
||||||
"""
|
val query = """
|
||||||
|query Search(${'$'}query: String) {
|
|query Search(${'$'}query: String) {
|
||||||
|Page (perPage: 50) {
|
|Page (perPage: 50) {
|
||||||
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||||
@ -128,36 +126,33 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
|""".trimMargin()
|
|""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
putJsonObject("variables") {
|
putJsonObject("variables") {
|
||||||
put("query", search)
|
put("query", search)
|
||||||
}
|
|
||||||
}
|
|
||||||
val body = payload.toString().toRequestBody(jsonMime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
}
|
||||||
val response = json.decodeFromString<JsonObject>(responseBody)
|
|
||||||
val data = response["data"]!!.jsonObject
|
|
||||||
val page = data["Page"]!!.jsonObject
|
|
||||||
val media = page["media"]!!.jsonArray
|
|
||||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
|
||||||
entries.map { it.toTrack() }
|
|
||||||
}
|
}
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
apiUrl,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let { response ->
|
||||||
|
val data = response["data"]!!.jsonObject
|
||||||
|
val page = data["Page"]!!.jsonObject
|
||||||
|
val media = page["media"]!!.jsonArray
|
||||||
|
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||||
|
entries.map { it.toTrack() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
|
suspend fun findLibManga(track: Track, userid: Int): Track? {
|
||||||
val query =
|
return withIOContext {
|
||||||
"""
|
val query = """
|
||||||
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
||||||
|Page {
|
|Page {
|
||||||
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
||||||
@ -165,6 +160,16 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|status
|
|status
|
||||||
|scoreRaw: score(format: POINT_100)
|
|scoreRaw: score(format: POINT_100)
|
||||||
|progress
|
|progress
|
||||||
|
|startedAt {
|
||||||
|
|year
|
||||||
|
|month
|
||||||
|
|day
|
||||||
|
|}
|
||||||
|
|completedAt {
|
||||||
|
|year
|
||||||
|
|month
|
||||||
|
|day
|
||||||
|
|}
|
||||||
|media {
|
|media {
|
||||||
|id
|
|id
|
||||||
|title {
|
|title {
|
||||||
@ -187,46 +192,42 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
|""".trimMargin()
|
|""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
putJsonObject("variables") {
|
putJsonObject("variables") {
|
||||||
put("id", userid)
|
put("id", userid)
|
||||||
put("manga_id", track.media_id)
|
put("manga_id", track.media_id)
|
||||||
}
|
|
||||||
}
|
|
||||||
val body = payload.toString().toRequestBody(jsonMime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
}
|
||||||
val response = json.decodeFromString<JsonObject>(responseBody)
|
|
||||||
val data = response["data"]!!.jsonObject
|
|
||||||
val page = data["Page"]!!.jsonObject
|
|
||||||
val media = page["mediaList"]!!.jsonArray
|
|
||||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
|
||||||
entries.firstOrNull()?.toTrack()
|
|
||||||
}
|
}
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
apiUrl,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let { response ->
|
||||||
|
val data = response["data"]!!.jsonObject
|
||||||
|
val page = data["Page"]!!.jsonObject
|
||||||
|
val media = page["mediaList"]!!.jsonArray
|
||||||
|
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||||
|
entries.firstOrNull()?.toTrack()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibManga(track: Track, userid: Int): Observable<Track> {
|
suspend fun getLibManga(track: Track, userid: Int): Track {
|
||||||
return findLibManga(track, userid)
|
return findLibManga(track, userid) ?: throw Exception("Could not find manga")
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createOAuth(token: String): OAuth {
|
fun createOAuth(token: String): OAuth {
|
||||||
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentUser(): Observable<Pair<Int, String>> {
|
suspend fun getCurrentUser(): Pair<Int, String> {
|
||||||
val query =
|
return withIOContext {
|
||||||
"""
|
val query = """
|
||||||
|query User {
|
|query User {
|
||||||
|Viewer {
|
|Viewer {
|
||||||
|id
|
|id
|
||||||
@ -236,44 +237,29 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
|}
|
|}
|
||||||
|}
|
|}
|
||||||
|""".trimMargin()
|
|""".trimMargin()
|
||||||
val payload = buildJsonObject {
|
val payload = buildJsonObject {
|
||||||
put("query", query)
|
put("query", query)
|
||||||
}
|
|
||||||
val body = payload.toString().toRequestBody(jsonMime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
|
||||||
val response = json.decodeFromString<JsonObject>(responseBody)
|
|
||||||
val data = response["data"]!!.jsonObject
|
|
||||||
val viewer = data["Viewer"]!!.jsonObject
|
|
||||||
Pair(viewer["id"]!!.jsonPrimitive.int, viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content)
|
|
||||||
}
|
}
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
apiUrl,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val data = it["data"]!!.jsonObject
|
||||||
|
val viewer = data["Viewer"]!!.jsonObject
|
||||||
|
Pair(
|
||||||
|
viewer["id"]!!.jsonPrimitive.int,
|
||||||
|
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonToALManga(struct: JsonObject): ALManga {
|
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||||
val date = try {
|
|
||||||
val date = Calendar.getInstance()
|
|
||||||
date.set(
|
|
||||||
struct["startDate"]!!.jsonObject["year"]!!.jsonPrimitive.intOrNull ?: 0,
|
|
||||||
(
|
|
||||||
struct["startDate"]!!.jsonObject["month"]!!.jsonPrimitive.intOrNull
|
|
||||||
?: 0
|
|
||||||
) - 1,
|
|
||||||
struct["startDate"]!!.jsonObject["day"]!!.jsonPrimitive.intOrNull ?: 0
|
|
||||||
)
|
|
||||||
date.timeInMillis
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
|
|
||||||
return ALManga(
|
return ALManga(
|
||||||
struct["id"]!!.jsonPrimitive.int,
|
struct["id"]!!.jsonPrimitive.int,
|
||||||
struct["title"]!!.jsonObject["romaji"]!!.jsonPrimitive.content,
|
struct["title"]!!.jsonObject["romaji"]!!.jsonPrimitive.content,
|
||||||
@ -281,7 +267,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
struct["description"]!!.jsonPrimitive.contentOrNull,
|
struct["description"]!!.jsonPrimitive.contentOrNull,
|
||||||
struct["type"]!!.jsonPrimitive.content,
|
struct["type"]!!.jsonPrimitive.content,
|
||||||
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
||||||
date,
|
parseDate(struct, "startDate"),
|
||||||
struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0
|
struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -292,13 +278,46 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
|||||||
struct["status"]!!.jsonPrimitive.content,
|
struct["status"]!!.jsonPrimitive.content,
|
||||||
struct["scoreRaw"]!!.jsonPrimitive.int,
|
struct["scoreRaw"]!!.jsonPrimitive.int,
|
||||||
struct["progress"]!!.jsonPrimitive.int,
|
struct["progress"]!!.jsonPrimitive.int,
|
||||||
|
parseDate(struct, "startedAt"),
|
||||||
|
parseDate(struct, "completedAt"),
|
||||||
jsonToALManga(struct["media"]!!.jsonObject)
|
jsonToALManga(struct["media"]!!.jsonObject)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseDate(struct: JsonObject, dateKey: String): Long {
|
||||||
|
return try {
|
||||||
|
val date = Calendar.getInstance()
|
||||||
|
date.set(
|
||||||
|
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
|
||||||
|
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int - 1,
|
||||||
|
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int
|
||||||
|
)
|
||||||
|
date.timeInMillis
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDate(dateValue: Long): JsonObject {
|
||||||
|
if (dateValue == 0L) {
|
||||||
|
return buildJsonObject {
|
||||||
|
put("year", JsonNull)
|
||||||
|
put("month", JsonNull)
|
||||||
|
put("day", JsonNull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.timeInMillis = dateValue
|
||||||
|
return buildJsonObject {
|
||||||
|
put("year", calendar.year)
|
||||||
|
put("month", calendar.month + 1)
|
||||||
|
put("day", calendar.dayOfMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val clientId = "385"
|
private const val clientId = "385"
|
||||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
|
||||||
private const val apiUrl = "https://graphql.anilist.co/"
|
private const val apiUrl = "https://graphql.anilist.co/"
|
||||||
private const val baseUrl = "https://anilist.co/api/v2/"
|
private const val baseUrl = "https://anilist.co/api/v2/"
|
||||||
private const val baseMangaUrl = "https://anilist.co/manga/"
|
private const val baseMangaUrl = "https://anilist.co/manga/"
|
||||||
|
@ -44,6 +44,8 @@ data class ALUserManga(
|
|||||||
val list_status: String,
|
val list_status: String,
|
||||||
val score_raw: Int,
|
val score_raw: Int,
|
||||||
val chapters_read: Int,
|
val chapters_read: Int,
|
||||||
|
val start_date_fuzzy: Long,
|
||||||
|
val completed_date_fuzzy: Long,
|
||||||
val manga: ALManga
|
val manga: ALManga
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -51,6 +53,8 @@ data class ALUserManga(
|
|||||||
media_id = manga.media_id
|
media_id = manga.media_id
|
||||||
status = toTrackStatus()
|
status = toTrackStatus()
|
||||||
score = score_raw.toFloat()
|
score = score_raw.toFloat()
|
||||||
|
started_reading_date = start_date_fuzzy
|
||||||
|
finished_reading_date = completed_date_fuzzy
|
||||||
last_chapter_read = chapters_read
|
last_chapter_read = chapters_read
|
||||||
library_id = this@ALUserManga.library_id
|
library_id = this@ALUserManga.library_id
|
||||||
total_chapters = manga.total_chapters
|
total_chapters = manga.total_chapters
|
||||||
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.bangumi
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
@ -9,20 +10,19 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import rx.Completable
|
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
override val name = "Bangumi"
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { BangumiInterceptor(this) }
|
private val interceptor by lazy { BangumiInterceptor(this) }
|
||||||
|
|
||||||
private val api by lazy { BangumiApi(client, interceptor) }
|
private val api by lazy { BangumiApi(client, interceptor) }
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
override fun nameRes() = R.string.tracker_bangumi
|
||||||
|
|
||||||
override fun getScoreList(): List<String> {
|
override fun getScoreList(): List<String> {
|
||||||
return IntRange(0, 10).map(Int::toString)
|
return IntRange(0, 10).map(Int::toString)
|
||||||
}
|
}
|
||||||
@ -31,52 +31,45 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
return track.score.toInt().toString()
|
return track.score.toInt().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track)
|
return api.addLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override suspend fun update(track: Track): Track {
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override suspend fun bind(track: Track): Track {
|
||||||
return api.statusLibManga(track)
|
val statusTrack = api.statusLibManga(track)
|
||||||
.flatMap {
|
val remoteTrack = api.findLibManga(track)
|
||||||
api.findLibManga(track).flatMap { remoteTrack ->
|
return if (remoteTrack != null && statusTrack != null) {
|
||||||
if (remoteTrack != null && it != null) {
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.library_id = remoteTrack.library_id
|
||||||
track.library_id = remoteTrack.library_id
|
track.status = statusTrack.status
|
||||||
track.status = remoteTrack.status
|
track.score = statusTrack.score
|
||||||
track.last_chapter_read = remoteTrack.last_chapter_read
|
track.last_chapter_read = statusTrack.last_chapter_read
|
||||||
refresh(track)
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
} else {
|
refresh(track)
|
||||||
// Set default fields if it's not found in the list
|
} else {
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
// Set default fields if it's not found in the list
|
||||||
track.status = DEFAULT_STATUS
|
track.status = READING
|
||||||
add(track)
|
track.score = 0F
|
||||||
update(track)
|
add(track)
|
||||||
}
|
update(track)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override suspend fun refresh(track: Track): Track {
|
||||||
return api.statusLibManga(track)
|
val remoteStatusTrack = api.statusLibManga(track)
|
||||||
.flatMap {
|
track.copyPersonalFrom(remoteStatusTrack!!)
|
||||||
track.copyPersonalFrom(it!!)
|
api.findLibManga(track)?.let { remoteTrack ->
|
||||||
api.findLibManga(track)
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
.map { remoteTrack ->
|
}
|
||||||
if (remoteTrack != null) {
|
return track
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
|
||||||
track.status = remoteTrack.status
|
|
||||||
}
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogo() = R.drawable.ic_tracker_bangumi
|
override fun getLogo() = R.drawable.ic_tracker_bangumi
|
||||||
@ -100,17 +93,16 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
|
|
||||||
override fun getCompletionStatus(): Int = COMPLETED
|
override fun getCompletionStatus(): Int = COMPLETED
|
||||||
|
|
||||||
override fun login(username: String, password: String) = login(password)
|
override suspend fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
fun login(code: String): Completable {
|
suspend fun login(code: String) {
|
||||||
return api.accessToken(code).map { oauth: OAuth? ->
|
try {
|
||||||
|
val oauth = api.accessToken(code)
|
||||||
interceptor.newAuth(oauth)
|
interceptor.newAuth(oauth)
|
||||||
if (oauth != null) {
|
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
||||||
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
} catch (e: Throwable) {
|
||||||
}
|
|
||||||
}.doOnError {
|
|
||||||
logout()
|
logout()
|
||||||
}.toCompletable()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToken(oauth: OAuth?) {
|
fun saveToken(oauth: OAuth?) {
|
||||||
@ -137,8 +129,5 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val ON_HOLD = 4
|
const val ON_HOLD = 4
|
||||||
const val DROPPED = 5
|
const val DROPPED = 5
|
||||||
const val PLANNING = 1
|
const val PLANNING = 1
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
|
||||||
const val DEFAULT_SCORE = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,15 @@ import androidx.core.net.toUri
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.float
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.int
|
import kotlinx.serialization.json.int
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@ -19,7 +22,6 @@ import okhttp3.CacheControl
|
|||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
@ -29,145 +31,131 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
|||||||
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
val body = FormBody.Builder()
|
return withIOContext {
|
||||||
.add("rating", track.score.toInt().toString())
|
val body = FormBody.Builder()
|
||||||
.add("status", track.toBangumiStatus())
|
.add("rating", track.score.toInt().toString())
|
||||||
.build()
|
.add("status", track.toBangumiStatus())
|
||||||
val request = Request.Builder()
|
.build()
|
||||||
.url("$apiUrl/collection/${track.media_id}/update")
|
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = body))
|
||||||
.post(body)
|
.await()
|
||||||
.build()
|
track
|
||||||
return authClient.newCall(request)
|
}
|
||||||
.asObservableSuccess()
|
|
||||||
.map {
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
// chapter update
|
return withIOContext {
|
||||||
val body = FormBody.Builder()
|
// read status update
|
||||||
.add("watched_eps", track.last_chapter_read.toString())
|
val sbody = FormBody.Builder()
|
||||||
.build()
|
.add("rating", track.score.toInt().toString())
|
||||||
val request = Request.Builder()
|
.add("status", track.toBangumiStatus())
|
||||||
.url("$apiUrl/subject/${track.media_id}/update/watched_eps")
|
.build()
|
||||||
.post(body)
|
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = sbody))
|
||||||
.build()
|
.await()
|
||||||
|
|
||||||
// read status update
|
// chapter update
|
||||||
val sbody = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
.add("status", track.toBangumiStatus())
|
.add("watched_eps", track.last_chapter_read.toString())
|
||||||
.build()
|
.build()
|
||||||
val srequest = Request.Builder()
|
authClient.newCall(
|
||||||
.url("$apiUrl/collection/${track.media_id}/update")
|
POST(
|
||||||
.post(sbody)
|
"$apiUrl/subject/${track.media_id}/update/watched_eps",
|
||||||
.build()
|
body = body
|
||||||
return authClient.newCall(srequest)
|
)
|
||||||
.asObservableSuccess()
|
).await()
|
||||||
.map {
|
|
||||||
track
|
track
|
||||||
}.flatMap {
|
}
|
||||||
authClient.newCall(request)
|
}
|
||||||
.asObservableSuccess()
|
|
||||||
.map {
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
track
|
return withIOContext {
|
||||||
|
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
|
||||||
|
.toUri()
|
||||||
|
.buildUpon()
|
||||||
|
.appendQueryParameter("max_results", "20")
|
||||||
|
.build()
|
||||||
|
authClient.newCall(GET(url.toString()))
|
||||||
|
.await()
|
||||||
|
.use {
|
||||||
|
var responseBody = it.body?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
}
|
}
|
||||||
}
|
if (responseBody.contains("\"code\":404")) {
|
||||||
}
|
responseBody = "{\"results\":0,\"list\":[]}"
|
||||||
|
}
|
||||||
fun search(search: String): Observable<List<TrackSearch>> {
|
val response = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
|
||||||
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
|
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }
|
||||||
.toUri()
|
?.map { jsonToSearch(it.jsonObject) }.orEmpty()
|
||||||
.buildUpon()
|
|
||||||
.appendQueryParameter("max_results", "20")
|
|
||||||
.build()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url.toString())
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
var responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
}
|
||||||
if (responseBody.contains("\"code\":404")) {
|
}
|
||||||
responseBody = "{\"results\":0,\"list\":[]}"
|
|
||||||
}
|
|
||||||
val response = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
|
|
||||||
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }?.map { jsonToSearch(it.jsonObject) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||||
|
val coverUrl = if (obj["images"] is JsonObject) {
|
||||||
|
obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: ""
|
||||||
|
} else {
|
||||||
|
// Sometimes JsonNull
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val totalChapters = if (obj["eps_count"] != null) {
|
||||||
|
obj["eps_count"]!!.jsonPrimitive.int
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
return TrackSearch.create(TrackManager.BANGUMI).apply {
|
return TrackSearch.create(TrackManager.BANGUMI).apply {
|
||||||
media_id = obj["id"]!!.jsonPrimitive.int
|
media_id = obj["id"]!!.jsonPrimitive.int
|
||||||
title = obj["name_cn"]!!.jsonPrimitive.content
|
title = obj["name_cn"]!!.jsonPrimitive.content
|
||||||
cover_url = obj["images"]!!.jsonObject["common"]!!.jsonPrimitive.content
|
cover_url = coverUrl
|
||||||
summary = obj["name"]!!.jsonPrimitive.content
|
summary = obj["name"]!!.jsonPrimitive.content
|
||||||
tracking_url = obj["url"]!!.jsonPrimitive.content
|
tracking_url = obj["url"]!!.jsonPrimitive.content
|
||||||
|
total_chapters = totalChapters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonToTrack(mangas: JsonObject): Track {
|
suspend fun findLibManga(track: Track): Track? {
|
||||||
return Track.create(TrackManager.BANGUMI).apply {
|
return withIOContext {
|
||||||
title = mangas["name"]!!.jsonPrimitive.content
|
authClient.newCall(GET("$apiUrl/subject/${track.media_id}"))
|
||||||
media_id = mangas["id"]!!.jsonPrimitive.int
|
.await()
|
||||||
score = try {
|
.parseAs<JsonObject>()
|
||||||
mangas["rating"]!!.jsonObject["score"]!!.jsonPrimitive.float
|
.let { jsonToSearch(it) }
|
||||||
} catch (_: Exception) {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
status = Bangumi.DEFAULT_STATUS
|
|
||||||
tracking_url = mangas["url"]!!.jsonPrimitive.content
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track): Observable<Track?> {
|
suspend fun statusLibManga(track: Track): Track? {
|
||||||
val urlMangas = "$apiUrl/subject/${track.media_id}"
|
return withIOContext {
|
||||||
val requestMangas = Request.Builder()
|
val urlUserRead = "$apiUrl/collection/${track.media_id}"
|
||||||
.url(urlMangas)
|
val requestUserRead = Request.Builder()
|
||||||
.get()
|
.url(urlUserRead)
|
||||||
.build()
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
return authClient.newCall(requestMangas)
|
// TODO: get user readed chapter here
|
||||||
.asObservableSuccess()
|
var response = authClient.newCall(requestUserRead).await()
|
||||||
.map { netResponse ->
|
var responseBody = response.body?.string().orEmpty()
|
||||||
// get comic info
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
jsonToTrack(json.decodeFromString(responseBody))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun statusLibManga(track: Track): Observable<Track?> {
|
|
||||||
val urlUserRead = "$apiUrl/collection/${track.media_id}"
|
|
||||||
val requestUserRead = Request.Builder()
|
|
||||||
.url(urlUserRead)
|
|
||||||
.cacheControl(CacheControl.FORCE_NETWORK)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// todo get user readed chapter here
|
|
||||||
return authClient.newCall(requestUserRead)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val resp = netResponse.body?.string()
|
|
||||||
val coll = json.decodeFromString<Collection>(resp!!)
|
|
||||||
track.status = coll.status?.id!!
|
|
||||||
track.last_chapter_read = coll.ep_status!!
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun accessToken(code: String): Observable<OAuth> {
|
|
||||||
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
if (responseBody.isEmpty()) {
|
||||||
throw Exception("Null Response")
|
throw Exception("Null Response")
|
||||||
}
|
}
|
||||||
json.decodeFromString<OAuth>(responseBody)
|
if (responseBody.contains("\"code\":400")) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
json.decodeFromString<Collection>(responseBody).let {
|
||||||
|
track.status = it.status?.id!!
|
||||||
|
track.last_chapter_read = it.ep_status!!
|
||||||
|
track.score = it.rating!!
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun accessToken(code: String): OAuth {
|
||||||
|
return withIOContext {
|
||||||
|
client.newCall(accessTokenRequest(code))
|
||||||
|
.await()
|
||||||
|
.parseAs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ data class Collection(
|
|||||||
val comment: String? = "",
|
val comment: String? = "",
|
||||||
val ep_status: Int? = 0,
|
val ep_status: Int? = 0,
|
||||||
val lasttouch: Int? = 0,
|
val lasttouch: Int? = 0,
|
||||||
val rating: Int? = 0,
|
val rating: Float? = 0f,
|
||||||
val status: Status? = Status(),
|
val status: Status? = Status(),
|
||||||
val tag: List<String?>? = listOf(),
|
val tag: List<String?>? = listOf(),
|
||||||
val user: User? = User(),
|
val user: User? = User(),
|
||||||
|
@ -2,13 +2,14 @@ package eu.kanade.tachiyomi.data.track.kitsu
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import com.google.gson.Gson
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import kotlinx.serialization.decodeFromString
|
||||||
import rx.Observable
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
|
|
||||||
@ -20,16 +21,14 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLAN_TO_READ = 5
|
const val PLAN_TO_READ = 5
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
|
||||||
const val DEFAULT_SCORE = 0f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "Kitsu"
|
@StringRes
|
||||||
|
override fun nameRes() = R.string.tracker_kitsu
|
||||||
|
|
||||||
private val gson: Gson by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { KitsuInterceptor(this, gson) }
|
private val interceptor by lazy { KitsuInterceptor(this) }
|
||||||
|
|
||||||
private val api by lazy { KitsuApi(client, interceptor) }
|
private val api by lazy { KitsuApi(client, interceptor) }
|
||||||
|
|
||||||
@ -68,49 +67,43 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
return df.format(track.score)
|
return df.format(track.score)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track, getUserId())
|
return api.addLibManga(track, getUserId())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override suspend fun update(track: Track): Track {
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override suspend fun bind(track: Track): Track {
|
||||||
return api.findLibManga(track, getUserId())
|
val remoteTrack = api.findLibManga(track, getUserId())
|
||||||
.flatMap { remoteTrack ->
|
return if (remoteTrack != null) {
|
||||||
if (remoteTrack != null) {
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.media_id = remoteTrack.media_id
|
||||||
track.media_id = remoteTrack.media_id
|
update(track)
|
||||||
update(track)
|
} else {
|
||||||
} else {
|
track.status = READING
|
||||||
track.score = DEFAULT_SCORE
|
track.score = 0F
|
||||||
track.status = DEFAULT_STATUS
|
add(track)
|
||||||
add(track)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override suspend fun refresh(track: Track): Track {
|
||||||
return api.getLibManga(track)
|
val remoteTrack = api.getLibManga(track)
|
||||||
.map { remoteTrack ->
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
return track
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun login(username: String, password: String): Completable {
|
override suspend fun login(username: String, password: String) {
|
||||||
return api.login(username, password)
|
val token = api.login(username, password)
|
||||||
.doOnNext { interceptor.newAuth(it) }
|
interceptor.newAuth(token)
|
||||||
.flatMap { api.getCurrentUser() }
|
val userId = api.getCurrentUser()
|
||||||
.doOnNext { userId -> saveCredentials(username, userId) }
|
saveCredentials(username, userId)
|
||||||
.doOnError { logout() }
|
|
||||||
.toCompletable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
@ -123,13 +116,12 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun saveToken(oauth: OAuth?) {
|
fun saveToken(oauth: OAuth?) {
|
||||||
val json = gson.toJson(oauth)
|
preferences.trackToken(this).set(json.encodeToString(oauth))
|
||||||
preferences.trackToken(this).set(json)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreToken(): OAuth? {
|
fun restoreToken(): OAuth? {
|
||||||
return try {
|
return try {
|
||||||
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
@ -1,254 +1,253 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
import com.github.salomonbrys.kotson.array
|
import androidx.core.net.toUri
|
||||||
import com.github.salomonbrys.kotson.get
|
|
||||||
import com.github.salomonbrys.kotson.int
|
|
||||||
import com.github.salomonbrys.kotson.jsonObject
|
|
||||||
import com.github.salomonbrys.kotson.obj
|
|
||||||
import com.github.salomonbrys.kotson.string
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.Headers.Companion.headersOf
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import okhttp3.Request
|
||||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
import okhttp3.RequestBody
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.Field
|
|
||||||
import retrofit2.http.FormUrlEncoded
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Header
|
|
||||||
import retrofit2.http.Headers
|
|
||||||
import retrofit2.http.PATCH
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import retrofit2.http.Query
|
|
||||||
import rx.Observable
|
|
||||||
|
|
||||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
||||||
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
private val rest = Retrofit.Builder()
|
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||||
.baseUrl(baseUrl)
|
return withIOContext {
|
||||||
.client(authClient)
|
val data = buildJsonObject {
|
||||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
putJsonObject("data") {
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
put("type", "libraryEntries")
|
||||||
.build()
|
putJsonObject("attributes") {
|
||||||
.create(Rest::class.java)
|
put("status", track.toKitsuStatus())
|
||||||
|
put("progress", track.last_chapter_read)
|
||||||
|
}
|
||||||
|
putJsonObject("relationships") {
|
||||||
|
putJsonObject("user") {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("id", userId)
|
||||||
|
put("type", "users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putJsonObject("media") {
|
||||||
|
putJsonObject("data") {
|
||||||
|
put("id", track.media_id)
|
||||||
|
put("type", "manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val searchRest = Retrofit.Builder()
|
authClient.newCall(
|
||||||
.baseUrl(algoliaKeyUrl)
|
POST(
|
||||||
.client(authClient)
|
"${baseUrl}library-entries",
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
headers = headersOf(
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
"Content-Type",
|
||||||
.build()
|
"application/vnd.api+json"
|
||||||
.create(SearchKeyRest::class.java)
|
|
||||||
|
|
||||||
private val algoliaRest = Retrofit.Builder()
|
|
||||||
.baseUrl(algoliaUrl)
|
|
||||||
.client(client)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
|
||||||
.build()
|
|
||||||
.create(AgoliaSearchRest::class.java)
|
|
||||||
|
|
||||||
fun addLibManga(track: Track, userId: String): Observable<Track> {
|
|
||||||
return Observable.defer {
|
|
||||||
// @formatter:off
|
|
||||||
val data = jsonObject(
|
|
||||||
"type" to "libraryEntries",
|
|
||||||
"attributes" to jsonObject(
|
|
||||||
"status" to track.toKitsuStatus(),
|
|
||||||
"progress" to track.last_chapter_read
|
|
||||||
),
|
|
||||||
"relationships" to jsonObject(
|
|
||||||
"user" to jsonObject(
|
|
||||||
"data" to jsonObject(
|
|
||||||
"id" to userId,
|
|
||||||
"type" to "users"
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"media" to jsonObject(
|
body = data.toString().toRequestBody("application/vnd.api+json".toMediaType())
|
||||||
"data" to jsonObject(
|
|
||||||
"id" to track.media_id,
|
|
||||||
"type" to "manga"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.await()
|
||||||
rest.addLibManga(jsonObject("data" to data))
|
.parseAs<JsonObject>()
|
||||||
.map { json ->
|
.let {
|
||||||
track.media_id = json["data"]["id"].int
|
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return Observable.defer {
|
return withIOContext {
|
||||||
// @formatter:off
|
val data = buildJsonObject {
|
||||||
val data = jsonObject(
|
putJsonObject("data") {
|
||||||
"type" to "libraryEntries",
|
put("type", "libraryEntries")
|
||||||
"id" to track.media_id,
|
put("id", track.media_id)
|
||||||
"attributes" to jsonObject(
|
putJsonObject("attributes") {
|
||||||
"status" to track.toKitsuStatus(),
|
put("status", track.toKitsuStatus())
|
||||||
"progress" to track.last_chapter_read,
|
put("progress", track.last_chapter_read)
|
||||||
"ratingTwenty" to track.toKitsuScore()
|
put("ratingTwenty", track.toKitsuScore())
|
||||||
)
|
}
|
||||||
)
|
}
|
||||||
// @formatter:on
|
}
|
||||||
|
|
||||||
rest.updateLibManga(track.media_id, jsonObject("data" to data))
|
authClient.newCall(
|
||||||
.map { track }
|
Request.Builder()
|
||||||
|
.url("${baseUrl}library-entries/${track.media_id}")
|
||||||
|
.headers(
|
||||||
|
headersOf(
|
||||||
|
"Content-Type",
|
||||||
|
"application/vnd.api+json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.patch(data.toString().toRequestBody("application/vnd.api+json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
track
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun search(query: String): Observable<List<TrackSearch>> {
|
suspend fun search(query: String): List<TrackSearch> {
|
||||||
return searchRest
|
return withIOContext {
|
||||||
.getKey().map { json ->
|
authClient.newCall(GET(algoliaKeyUrl))
|
||||||
json["media"].asJsonObject["key"].string
|
.await()
|
||||||
}.flatMap { key ->
|
.parseAs<JsonObject>()
|
||||||
algoliaSearch(key, query)
|
.let {
|
||||||
}
|
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||||
}
|
algoliaSearch(key, query)
|
||||||
|
|
||||||
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
|
|
||||||
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
|
|
||||||
return algoliaRest
|
|
||||||
.getSearchQuery(algoliaAppId, key, jsonObject)
|
|
||||||
.map { json ->
|
|
||||||
val data = json["hits"].array
|
|
||||||
data.map { KitsuSearchManga(it.obj) }
|
|
||||||
.filter { it.subType != "novel" }
|
|
||||||
.map { it.toTrack() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findLibManga(track: Track, userId: String): Observable<Track?> {
|
|
||||||
return rest.findLibManga(track.media_id, userId)
|
|
||||||
.map { json ->
|
|
||||||
val data = json["data"].array
|
|
||||||
if (data.size() > 0) {
|
|
||||||
val manga = json["included"].array[0].obj
|
|
||||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibManga(track: Track): Observable<Track> {
|
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
|
||||||
return rest.getLibManga(track.media_id)
|
return withIOContext {
|
||||||
.map { json ->
|
val jsonObject = buildJsonObject {
|
||||||
val data = json["data"].array
|
put("params", "query=$query$algoliaFilter")
|
||||||
if (data.size() > 0) {
|
}
|
||||||
val manga = json["included"].array[0].obj
|
|
||||||
KitsuLibManga(data[0].obj, manga).toTrack()
|
client.newCall(
|
||||||
} else {
|
POST(
|
||||||
throw Exception("Could not find manga")
|
algoliaUrl,
|
||||||
|
headers = headersOf(
|
||||||
|
"X-Algolia-Application-Id",
|
||||||
|
algoliaAppId,
|
||||||
|
"X-Algolia-API-Key",
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
body = jsonObject.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["hits"]!!.jsonArray
|
||||||
|
.map { KitsuSearchManga(it.jsonObject) }
|
||||||
|
.filter { it.subType != "novel" }
|
||||||
|
.map { it.toTrack() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(username: String, password: String): Observable<OAuth> {
|
suspend fun findLibManga(track: Track, userId: String): Track? {
|
||||||
return Retrofit.Builder()
|
return withIOContext {
|
||||||
.baseUrl(loginUrl)
|
val url = "${baseUrl}library-entries".toUri().buildUpon()
|
||||||
.client(client)
|
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.appendQueryParameter("include", "manga")
|
||||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
.build()
|
||||||
.build()
|
authClient.newCall(GET(url.toString()))
|
||||||
.create(LoginRest::class.java)
|
.await()
|
||||||
.requestAccessToken(username, password)
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val data = it["data"]!!.jsonArray
|
||||||
|
if (data.size > 0) {
|
||||||
|
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||||
|
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentUser(): Observable<String> {
|
suspend fun getLibManga(track: Track): Track {
|
||||||
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
|
return withIOContext {
|
||||||
|
val url = "${baseUrl}library-entries".toUri().buildUpon()
|
||||||
|
.encodedQuery("filter[id]=${track.media_id}")
|
||||||
|
.appendQueryParameter("include", "manga")
|
||||||
|
.build()
|
||||||
|
authClient.newCall(GET(url.toString()))
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val data = it["data"]!!.jsonArray
|
||||||
|
if (data.size > 0) {
|
||||||
|
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||||
|
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||||
|
} else {
|
||||||
|
throw Exception("Could not find manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface Rest {
|
suspend fun login(username: String, password: String): OAuth {
|
||||||
|
return withIOContext {
|
||||||
@Headers("Content-Type: application/vnd.api+json")
|
val formBody: RequestBody = FormBody.Builder()
|
||||||
@POST("library-entries")
|
.add("username", username)
|
||||||
fun addLibManga(
|
.add("password", password)
|
||||||
@Body data: JsonObject
|
.add("grant_type", "password")
|
||||||
): Observable<JsonObject>
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
@Headers("Content-Type: application/vnd.api+json")
|
.build()
|
||||||
@PATCH("library-entries/{id}")
|
client.newCall(POST(loginUrl, body = formBody))
|
||||||
fun updateLibManga(
|
.await()
|
||||||
@Path("id") remoteId: Int,
|
.parseAs()
|
||||||
@Body data: JsonObject
|
}
|
||||||
): Observable<JsonObject>
|
|
||||||
|
|
||||||
@GET("library-entries")
|
|
||||||
fun findLibManga(
|
|
||||||
@Query("filter[manga_id]", encoded = true) remoteId: Int,
|
|
||||||
@Query("filter[user_id]", encoded = true) userId: String,
|
|
||||||
@Query("include") includes: String = "manga"
|
|
||||||
): Observable<JsonObject>
|
|
||||||
|
|
||||||
@GET("library-entries")
|
|
||||||
fun getLibManga(
|
|
||||||
@Query("filter[id]", encoded = true) remoteId: Int,
|
|
||||||
@Query("include") includes: String = "manga"
|
|
||||||
): Observable<JsonObject>
|
|
||||||
|
|
||||||
@GET("users")
|
|
||||||
fun getCurrentUser(
|
|
||||||
@Query("filter[self]", encoded = true) self: Boolean = true
|
|
||||||
): Observable<JsonObject>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface SearchKeyRest {
|
suspend fun getCurrentUser(): String {
|
||||||
@GET("media/")
|
return withIOContext {
|
||||||
fun getKey(): Observable<JsonObject>
|
val url = "${baseUrl}users".toUri().buildUpon()
|
||||||
}
|
.encodedQuery("filter[self]=true")
|
||||||
|
.build()
|
||||||
private interface AgoliaSearchRest {
|
authClient.newCall(GET(url.toString()))
|
||||||
@POST("query/")
|
.await()
|
||||||
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject>
|
.parseAs<JsonObject>()
|
||||||
}
|
.let {
|
||||||
|
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
|
||||||
private interface LoginRest {
|
}
|
||||||
|
}
|
||||||
@FormUrlEncoded
|
|
||||||
@POST("oauth/token")
|
|
||||||
fun requestAccessToken(
|
|
||||||
@Field("username") username: String,
|
|
||||||
@Field("password") password: String,
|
|
||||||
@Field("grant_type") grantType: String = "password",
|
|
||||||
@Field("client_id") client_id: String = clientId,
|
|
||||||
@Field("client_secret") client_secret: String = clientSecret
|
|
||||||
): Observable<OAuth>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
private const val clientId =
|
||||||
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
"dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||||
|
private const val clientSecret =
|
||||||
|
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||||
|
|
||||||
private const val baseUrl = "https://kitsu.io/api/edge/"
|
private const val baseUrl = "https://kitsu.io/api/edge/"
|
||||||
private const val loginUrl = "https://kitsu.io/api/"
|
private const val loginUrl = "https://kitsu.io/api/oauth/token"
|
||||||
private const val baseMangaUrl = "https://kitsu.io/manga/"
|
private const val baseMangaUrl = "https://kitsu.io/manga/"
|
||||||
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
|
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/media/"
|
||||||
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
|
|
||||||
|
private const val algoliaUrl =
|
||||||
|
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/query/"
|
||||||
private const val algoliaAppId = "AWQO5J657S"
|
private const val algoliaAppId = "AWQO5J657S"
|
||||||
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
private const val algoliaFilter =
|
||||||
|
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
||||||
|
|
||||||
fun mangaUrl(remoteId: Int): String {
|
fun mangaUrl(remoteId: Int): String {
|
||||||
return baseMangaUrl + remoteId
|
return baseMangaUrl + remoteId
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshTokenRequest(token: String) = POST(
|
fun refreshTokenRequest(token: String) = POST(
|
||||||
"${loginUrl}oauth/token",
|
loginUrl,
|
||||||
body = FormBody.Builder()
|
body = FormBody.Builder()
|
||||||
.add("grant_type", "refresh_token")
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("refresh_token", token)
|
||||||
.add("client_id", clientId)
|
.add("client_id", clientId)
|
||||||
.add("client_secret", clientSecret)
|
.add("client_secret", clientSecret)
|
||||||
.add("refresh_token", token)
|
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
|
class KitsuInterceptor(val kitsu: Kitsu) : Interceptor {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
@ -22,7 +26,7 @@ class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
|
|||||||
if (currAuth.isExpired()) {
|
if (currAuth.isExpired()) {
|
||||||
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
|
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java))
|
newAuth(json.decodeFromString(response.body!!.string()))
|
||||||
} else {
|
} else {
|
||||||
response.close()
|
response.close()
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,36 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import com.github.salomonbrys.kotson.byInt
|
|
||||||
import com.github.salomonbrys.kotson.byString
|
|
||||||
import com.github.salomonbrys.kotson.nullInt
|
|
||||||
import com.github.salomonbrys.kotson.nullObj
|
|
||||||
import com.github.salomonbrys.kotson.nullString
|
|
||||||
import com.github.salomonbrys.kotson.obj
|
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class KitsuSearchManga(obj: JsonObject) {
|
class KitsuSearchManga(obj: JsonObject) {
|
||||||
val id by obj.byInt
|
val id = obj["id"]!!.jsonPrimitive.int
|
||||||
private val canonicalTitle by obj.byString
|
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
|
||||||
private val chapterCount = obj.get("chapterCount").nullInt
|
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull
|
||||||
val subType = obj.get("subtype").nullString
|
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
|
||||||
val original = obj.get("posterImage").nullObj?.get("original")?.asString
|
val original = try {
|
||||||
private val synopsis by obj.byString
|
obj["posterImage"]?.jsonObject?.get("original")?.jsonPrimitive?.content
|
||||||
private var startDate = obj.get("startDate").nullString?.let {
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// posterImage is sometimes a jsonNull object instead
|
||||||
|
null
|
||||||
|
}
|
||||||
|
private val synopsis = obj["synopsis"]!!.jsonPrimitive.content
|
||||||
|
private var startDate = obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
|
||||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
outputDf.format(Date(it.toLong() * 1000))
|
outputDf.format(Date(it.toLong() * 1000))
|
||||||
}
|
}
|
||||||
private val endDate = obj.get("endDate").nullString
|
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
@ -47,17 +51,17 @@ class KitsuSearchManga(obj: JsonObject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
||||||
val id by manga.byInt
|
val id = manga["id"]!!.jsonPrimitive.int
|
||||||
private val canonicalTitle by manga["attributes"].byString
|
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
|
||||||
private val chapterCount = manga["attributes"].obj.get("chapterCount").nullInt
|
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull
|
||||||
val type = manga["attributes"].obj.get("mangaType").nullString.orEmpty()
|
val type = manga["attributes"]!!.jsonObject["mangaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
val original by manga["attributes"].obj["posterImage"].byString
|
val original = manga["attributes"]!!.jsonObject["posterImage"]!!.jsonObject["original"]!!.jsonPrimitive.content
|
||||||
private val synopsis by manga["attributes"].byString
|
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
|
||||||
private val startDate = manga["attributes"].obj.get("startDate").nullString.orEmpty()
|
private val startDate = manga["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
private val libraryId by obj.byInt("id")
|
private val libraryId = obj["id"]!!.jsonPrimitive.int
|
||||||
val status by obj["attributes"].byString
|
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
|
||||||
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
|
||||||
val progress by obj["attributes"].byInt
|
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
|
||||||
|
|
||||||
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
media_id = libraryId
|
media_id = libraryId
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class OAuth(
|
data class OAuth(
|
||||||
val access_token: String,
|
val access_token: String,
|
||||||
val token_type: String,
|
val token_type: String,
|
||||||
|
@ -2,13 +2,15 @@ package eu.kanade.tachiyomi.data.track.myanimelist
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import kotlinx.serialization.decodeFromString
|
||||||
import rx.Completable
|
import kotlinx.serialization.encodeToString
|
||||||
import rx.Observable
|
import kotlinx.serialization.json.Json
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
@ -18,20 +20,19 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLAN_TO_READ = 6
|
const val PLAN_TO_READ = 6
|
||||||
|
const val REREADING = 7
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
private const val SEARCH_ID_PREFIX = "id:"
|
||||||
const val DEFAULT_SCORE = 0
|
private const val SEARCH_LIST_PREFIX = "my:"
|
||||||
|
|
||||||
const val BASE_URL = "https://myanimelist.net"
|
|
||||||
const val USER_SESSION_COOKIE = "MALSESSIONID"
|
|
||||||
const val LOGGED_IN_COOKIE = "is_logged_in"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val interceptor by lazy { MyAnimeListInterceptor(this) }
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
|
||||||
private val api by lazy { MyAnimeListApi(client, interceptor) }
|
private val api by lazy { MyAnimeListApi(client, interceptor) }
|
||||||
|
|
||||||
override val name: String
|
@StringRes
|
||||||
get() = "MyAnimeList"
|
override fun nameRes() = R.string.tracker_myanimelist
|
||||||
|
|
||||||
override val supportsReadingDates: Boolean = true
|
override val supportsReadingDates: Boolean = true
|
||||||
|
|
||||||
@ -40,7 +41,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
override fun getLogoColor() = Color.rgb(46, 81, 162)
|
override fun getLogoColor() = Color.rgb(46, 81, 162)
|
||||||
|
|
||||||
override fun getStatusList(): List<Int> {
|
override fun getStatusList(): List<Int> {
|
||||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStatus(status: Int): String = with(context) {
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
@ -50,6 +51,7 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
ON_HOLD -> getString(R.string.on_hold)
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
DROPPED -> getString(R.string.dropped)
|
DROPPED -> getString(R.string.dropped)
|
||||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||||
|
REREADING -> getString(R.string.repeating)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,77 +66,75 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
return track.score.toInt().toString()
|
return track.score.toInt().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track)
|
track.status = READING
|
||||||
|
track.score = 0F
|
||||||
|
return api.updateItem(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override suspend fun update(track: Track): Track {
|
||||||
return api.updateLibManga(track)
|
return api.updateItem(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override suspend fun bind(track: Track): Track {
|
||||||
return api.findLibManga(track)
|
val remoteTrack = api.findListItem(track)
|
||||||
.flatMap { remoteTrack ->
|
return if (remoteTrack != null) {
|
||||||
if (remoteTrack != null) {
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.media_id = remoteTrack.media_id
|
||||||
update(track)
|
update(track)
|
||||||
} else {
|
} else {
|
||||||
// Set default fields if it's not found in the list
|
add(track)
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
}
|
||||||
track.status = DEFAULT_STATUS
|
}
|
||||||
add(track)
|
|
||||||
}
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
|
if (query.startsWith(SEARCH_ID_PREFIX)) {
|
||||||
|
query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id ->
|
||||||
|
return listOf(api.getMangaDetails(id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.startsWith(SEARCH_LIST_PREFIX)) {
|
||||||
|
query.substringAfter(SEARCH_LIST_PREFIX).let { title ->
|
||||||
|
return api.findListItems(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override suspend fun refresh(track: Track): Track {
|
||||||
return api.getLibManga(track)
|
return api.findListItem(track) ?: add(track)
|
||||||
.map { remoteTrack ->
|
|
||||||
track.copyPersonalFrom(remoteTrack)
|
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(csrfToken: String): Completable = login("myanimelist", csrfToken)
|
override suspend fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
override fun login(username: String, password: String): Completable {
|
suspend fun login(authCode: String) {
|
||||||
return Observable.fromCallable { saveCSRF(password) }
|
try {
|
||||||
.doOnNext { saveCredentials(username, password) }
|
val oauth = api.getAccessToken(authCode)
|
||||||
.doOnError { logout() }
|
interceptor.setAuth(oauth)
|
||||||
.toCompletable()
|
val username = api.getCurrentUser()
|
||||||
}
|
saveCredentials(username, oauth.access_token)
|
||||||
|
} catch (e: Throwable) {
|
||||||
fun ensureLoggedIn() {
|
logout()
|
||||||
if (isAuthorized) return
|
}
|
||||||
if (!isLogged) throw Exception(context.getString(R.string.myanimelist_creds_missing))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
super.logout()
|
super.logout()
|
||||||
preferences.trackToken(this).delete()
|
preferences.trackToken(this).delete()
|
||||||
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!)
|
interceptor.setAuth(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val isAuthorized: Boolean
|
fun saveOAuth(oAuth: OAuth?) {
|
||||||
get() = super.isLogged &&
|
preferences.trackToken(this).set(json.encodeToString(oAuth))
|
||||||
getCSRF().isNotEmpty() &&
|
}
|
||||||
checkCookies()
|
|
||||||
|
|
||||||
fun getCSRF(): String = preferences.trackToken(this).get()
|
fun loadOAuth(): OAuth? {
|
||||||
|
return try {
|
||||||
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
|
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
|
||||||
|
} catch (e: Exception) {
|
||||||
private fun checkCookies(): Boolean {
|
null
|
||||||
val url = BASE_URL.toHttpUrlOrNull()!!
|
|
||||||
val ckCount = networkService.cookieManager.get(url).count {
|
|
||||||
it.name == USER_SESSION_COOKIE || it.name == LOGGED_IN_COOKIE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ckCount == 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,472 +1,271 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservable
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
import eu.kanade.tachiyomi.util.lang.toCalendar
|
import eu.kanade.tachiyomi.util.PkceUtil
|
||||||
import eu.kanade.tachiyomi.util.selectInt
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
import eu.kanade.tachiyomi.util.selectText
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import org.jsoup.parser.Parser
|
|
||||||
import rx.Observable
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.GregorianCalendar
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.zip.GZIPInputStream
|
|
||||||
|
|
||||||
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
|
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
|
||||||
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
fun search(query: String): Observable<List<TrackSearch>> {
|
suspend fun getAccessToken(authCode: String): OAuth {
|
||||||
return if (query.startsWith(PREFIX_MY)) {
|
return withIOContext {
|
||||||
val realQuery = query.removePrefix(PREFIX_MY)
|
val formBody: RequestBody = FormBody.Builder()
|
||||||
getList()
|
.add("client_id", clientId)
|
||||||
.flatMap { Observable.from(it) }
|
.add("code", authCode)
|
||||||
.filter { it.title.contains(realQuery, true) }
|
.add("code_verifier", codeVerifier)
|
||||||
.toList()
|
.add("grant_type", "authorization_code")
|
||||||
} else {
|
.build()
|
||||||
client.newCall(GET(searchUrl(query)))
|
client.newCall(POST("$baseOAuthUrl/token", body = formBody))
|
||||||
.asObservable()
|
.await()
|
||||||
.flatMap { response ->
|
.parseAs()
|
||||||
Observable.from(
|
}
|
||||||
Jsoup.parse(response.consumeBody())
|
}
|
||||||
.select("div.js-categories-seasonal.js-block-list.list")
|
|
||||||
.select("table").select("tbody")
|
suspend fun getCurrentUser(): String {
|
||||||
.select("tr").drop(1)
|
return withIOContext {
|
||||||
)
|
val request = Request.Builder()
|
||||||
|
.url("$baseApiUrl/users/@me")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
authClient.newCall(request)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let { it["name"]!!.jsonPrimitive.content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(query: String): List<TrackSearch> {
|
||||||
|
return withIOContext {
|
||||||
|
val url = "$baseApiUrl/manga".toUri().buildUpon()
|
||||||
|
.appendQueryParameter("q", query)
|
||||||
|
.appendQueryParameter("nsfw", "true")
|
||||||
|
.build()
|
||||||
|
authClient.newCall(GET(url.toString()))
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["data"]!!.jsonArray
|
||||||
|
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||||
|
.map { node ->
|
||||||
|
val id = node["id"]!!.jsonPrimitive.int
|
||||||
|
async { getMangaDetails(id) }
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
.filter { trackSearch -> trackSearch.publishing_type != "novel" }
|
||||||
}
|
}
|
||||||
.filter { row ->
|
}
|
||||||
row.select(TD)[2].text() != "Novel"
|
}
|
||||||
}
|
|
||||||
.map { row ->
|
suspend fun getMangaDetails(id: Int): TrackSearch {
|
||||||
|
return withIOContext {
|
||||||
|
val url = "$baseApiUrl/manga".toUri().buildUpon()
|
||||||
|
.appendPath(id.toString())
|
||||||
|
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
|
||||||
|
.build()
|
||||||
|
authClient.newCall(GET(url.toString()))
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
val obj = it.jsonObject
|
||||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||||
title = row.searchTitle()
|
media_id = obj["id"]!!.jsonPrimitive.int
|
||||||
media_id = row.searchMediaId()
|
title = obj["title"]!!.jsonPrimitive.content
|
||||||
total_chapters = row.searchTotalChapters()
|
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
|
||||||
summary = row.searchSummary()
|
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||||
cover_url = row.searchCoverUrl()
|
cover_url = obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content ?: ""
|
||||||
tracking_url = mangaUrl(media_id)
|
tracking_url = "https://myanimelist.net/manga/$media_id"
|
||||||
publishing_status = row.searchPublishingStatus()
|
publishing_status = obj["status"]!!.jsonPrimitive.content.replace("_", " ")
|
||||||
publishing_type = row.searchPublishingType()
|
publishing_type = obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
|
||||||
start_date = row.searchStartDate()
|
start_date = try {
|
||||||
}
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
}
|
outputDf.format(obj["start_date"]!!)
|
||||||
.toList()
|
} catch (e: Exception) {
|
||||||
}
|
""
|
||||||
}
|
|
||||||
|
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
|
||||||
return Observable.defer {
|
|
||||||
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { track }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
|
||||||
return Observable.defer {
|
|
||||||
// Get track data
|
|
||||||
val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute()
|
|
||||||
val editData = response.use {
|
|
||||||
val page = Jsoup.parse(it.consumeBody())
|
|
||||||
|
|
||||||
// Extract track data from MAL page
|
|
||||||
extractDataFromEditPage(page).apply {
|
|
||||||
// Apply changes to the just fetched data
|
|
||||||
copyPersonalFrom(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update remote
|
|
||||||
authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData)))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map {
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findLibManga(track: Track): Observable<Track?> {
|
|
||||||
return authClient.newCall(GET(url = editPageUrl(track.media_id)))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
var libTrack: Track? = null
|
|
||||||
response.use {
|
|
||||||
if (it.priorResponse?.isRedirect != true) {
|
|
||||||
val trackForm = Jsoup.parse(it.consumeBody())
|
|
||||||
|
|
||||||
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
|
|
||||||
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
|
|
||||||
total_chapters = trackForm.select("#totalChap").text().toInt()
|
|
||||||
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
|
|
||||||
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull()
|
|
||||||
?: 0f
|
|
||||||
started_reading_date = trackForm.searchDatePicker("#add_manga_start_date")
|
|
||||||
finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
libTrack
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibManga(track: Track): Observable<Track> {
|
|
||||||
return findLibManga(track)
|
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getList(): Observable<List<TrackSearch>> {
|
|
||||||
return getListUrl()
|
|
||||||
.flatMap { url ->
|
|
||||||
getListXml(url)
|
|
||||||
}
|
|
||||||
.flatMap { doc ->
|
|
||||||
Observable.from(doc.select("manga"))
|
|
||||||
}
|
|
||||||
.map {
|
|
||||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
|
||||||
title = it.selectText("manga_title")!!
|
|
||||||
media_id = it.selectInt("manga_mangadb_id")
|
|
||||||
last_chapter_read = it.selectInt("my_read_chapters")
|
|
||||||
status = getStatus(it.selectText("my_status")!!)
|
|
||||||
score = it.selectInt("my_score").toFloat()
|
|
||||||
total_chapters = it.selectInt("manga_chapters")
|
|
||||||
tracking_url = mangaUrl(media_id)
|
|
||||||
started_reading_date = it.searchDateXml("my_start_date")
|
|
||||||
finished_reading_date = it.searchDateXml("my_finish_date")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getListUrl(): Observable<String> {
|
|
||||||
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
baseUrl + Jsoup.parse(response.consumeBody())
|
|
||||||
.select("div.goodresult")
|
|
||||||
.select("a")
|
|
||||||
.attr("href")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getListXml(url: String): Observable<Document> {
|
|
||||||
return authClient.newCall(GET(url))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Response.consumeBody(): String? {
|
|
||||||
use {
|
|
||||||
if (it.code != 200) throw Exception("HTTP error ${it.code}")
|
|
||||||
return it.body?.string()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Response.consumeXmlBody(): String? {
|
suspend fun updateItem(track: Track): Track {
|
||||||
use { res ->
|
return withIOContext {
|
||||||
if (res.code != 200) throw Exception("Export list error")
|
val formBodyBuilder = FormBody.Builder()
|
||||||
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
|
.add("status", track.toMyAnimeListStatus() ?: "reading")
|
||||||
val sb = StringBuilder()
|
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
|
||||||
reader.forEachLine { line ->
|
.add("score", track.score.toString())
|
||||||
sb.append(line)
|
.add("num_chapters_read", track.last_chapter_read.toString())
|
||||||
|
convertToIsoDate(track.started_reading_date)?.let {
|
||||||
|
formBodyBuilder.add("start_date", it)
|
||||||
|
}
|
||||||
|
convertToIsoDate(track.finished_reading_date)?.let {
|
||||||
|
formBodyBuilder.add("finish_date", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(mangaUrl(track.media_id).toString())
|
||||||
|
.put(formBodyBuilder.build())
|
||||||
|
.build()
|
||||||
|
authClient.newCall(request)
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let { parseMangaItem(it, track) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findListItem(track: Track): Track? {
|
||||||
|
return withIOContext {
|
||||||
|
val uri = "$baseApiUrl/manga".toUri().buildUpon()
|
||||||
|
.appendPath(track.media_id.toString())
|
||||||
|
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
|
||||||
|
.build()
|
||||||
|
authClient.newCall(GET(uri.toString()))
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let { obj ->
|
||||||
|
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||||
|
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||||
|
parseMangaItem(it, track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sb.toString()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
|
||||||
|
return withIOContext {
|
||||||
|
val json = getListPage(offset)
|
||||||
|
val obj = json.jsonObject
|
||||||
|
|
||||||
|
val matches = obj["data"]!!.jsonArray
|
||||||
|
.filter {
|
||||||
|
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains(
|
||||||
|
query,
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||||
|
async { getMangaDetails(id) }
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
|
||||||
|
// Check next page if there's more
|
||||||
|
if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) {
|
||||||
|
matches + findListItems(query, offset + listPaginationAmount)
|
||||||
|
} else {
|
||||||
|
matches
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractDataFromEditPage(page: Document): MyAnimeListEditData {
|
private suspend fun getListPage(offset: Int): JsonObject {
|
||||||
val tables = page.select("form#main-form table")
|
return withIOContext {
|
||||||
|
val urlBuilder = "$baseApiUrl/users/@me/mangalist".toUri().buildUpon()
|
||||||
|
.appendQueryParameter("fields", "list_status{start_date,finish_date}")
|
||||||
|
.appendQueryParameter("limit", listPaginationAmount.toString())
|
||||||
|
if (offset > 0) {
|
||||||
|
urlBuilder.appendQueryParameter("offset", offset.toString())
|
||||||
|
}
|
||||||
|
|
||||||
return MyAnimeListEditData(
|
val request = Request.Builder()
|
||||||
entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0
|
.url(urlBuilder.build().toString())
|
||||||
manga_id = tables[0].select("#manga_id").`val`(),
|
.get()
|
||||||
status = tables[0].select("#add_manga_status > option[selected]").`val`(),
|
.build()
|
||||||
num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(),
|
authClient.newCall(request)
|
||||||
last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty
|
.await()
|
||||||
num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(),
|
.parseAs()
|
||||||
score = tables[0].select("#add_manga_score > option[selected]").`val`(),
|
}
|
||||||
start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(),
|
}
|
||||||
start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(),
|
|
||||||
start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(),
|
private fun parseMangaItem(response: JsonObject, track: Track): Track {
|
||||||
finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(),
|
val obj = response.jsonObject
|
||||||
finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(),
|
return track.apply {
|
||||||
finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(),
|
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
|
||||||
tags = tables[1].select("#add_manga_tags").`val`(),
|
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content)
|
||||||
priority = tables[1].select("#add_manga_priority > option[selected]").`val`(),
|
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int
|
||||||
storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(),
|
score = obj["score"]!!.jsonPrimitive.int.toFloat()
|
||||||
num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(),
|
obj["start_date"]?.let {
|
||||||
num_read_times = tables[1].select("#add_manga_num_read_times").`val`(),
|
started_reading_date = parseDate(it.jsonPrimitive.content)
|
||||||
reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(),
|
}
|
||||||
comments = tables[1].select("#add_manga_comments").`val`(),
|
obj["finish_date"]?.let {
|
||||||
is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(),
|
finished_reading_date = parseDate(it.jsonPrimitive.content)
|
||||||
sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`()
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(isoDate: String): Long {
|
||||||
|
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertToIsoDate(epochTime: Long): String? {
|
||||||
|
if (epochTime == 0L) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
outputDf.format(epochTime)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CSRF = "csrf_token"
|
// Registered under arkon's MAL account
|
||||||
|
private const val clientId = "8fd3313bc138e8b890551aa1de1a2589"
|
||||||
|
|
||||||
private const val baseUrl = "https://myanimelist.net"
|
private const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2"
|
||||||
private const val baseMangaUrl = "$baseUrl/manga/"
|
private const val baseApiUrl = "https://api.myanimelist.net/v2"
|
||||||
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
|
|
||||||
private const val PREFIX_MY = "my:"
|
|
||||||
private const val TD = "td"
|
|
||||||
|
|
||||||
fun loginUrl() = baseUrl.toUri().buildUpon()
|
private const val listPaginationAmount = 250
|
||||||
.appendPath("login.php")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
private var codeVerifier: String = ""
|
||||||
|
|
||||||
private fun searchUrl(query: String): String {
|
fun authUrl(): Uri = "$baseOAuthUrl/authorize".toUri().buildUpon()
|
||||||
val col = "c[]"
|
.appendQueryParameter("client_id", clientId)
|
||||||
return baseUrl.toUri().buildUpon()
|
.appendQueryParameter("code_challenge", getPkceChallengeCode())
|
||||||
.appendPath("manga.php")
|
.appendQueryParameter("response_type", "code")
|
||||||
.appendQueryParameter("q", query)
|
.build()
|
||||||
.appendQueryParameter(col, "a")
|
|
||||||
.appendQueryParameter(col, "b")
|
|
||||||
.appendQueryParameter(col, "c")
|
|
||||||
.appendQueryParameter(col, "d")
|
|
||||||
.appendQueryParameter(col, "e")
|
|
||||||
.appendQueryParameter(col, "g")
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun exportListUrl() = baseUrl.toUri().buildUpon()
|
fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon()
|
||||||
.appendPath("panel.php")
|
.appendPath(id.toString())
|
||||||
.appendQueryParameter("go", "export")
|
.appendPath("my_list_status")
|
||||||
.toString()
|
.build()
|
||||||
|
|
||||||
private fun editPageUrl(mediaId: Int) = baseModifyListUrl.toUri().buildUpon()
|
fun refreshTokenRequest(refreshToken: String): Request {
|
||||||
.appendPath(mediaId.toString())
|
val formBody: RequestBody = FormBody.Builder()
|
||||||
.appendPath("edit")
|
.add("client_id", clientId)
|
||||||
.toString()
|
.add("refresh_token", refreshToken)
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
private fun addUrl() = baseModifyListUrl.toUri().buildUpon()
|
|
||||||
.appendPath("add.json")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
private fun exportPostBody(): RequestBody {
|
|
||||||
return FormBody.Builder()
|
|
||||||
.add("type", "2")
|
|
||||||
.add("subexport", "Export My List")
|
|
||||||
.build()
|
.build()
|
||||||
|
return POST("$baseOAuthUrl/token", body = formBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mangaPostPayload(track: Track): RequestBody {
|
private fun getPkceChallengeCode(): String {
|
||||||
val body = JSONObject()
|
codeVerifier = PkceUtil.generateCodeVerifier()
|
||||||
.put("manga_id", track.media_id)
|
return codeVerifier
|
||||||
.put("status", track.status)
|
|
||||||
.put("score", track.score)
|
|
||||||
.put("num_read_chapters", track.last_chapter_read)
|
|
||||||
|
|
||||||
return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody {
|
|
||||||
return FormBody.Builder()
|
|
||||||
.add("entry_id", track.entry_id)
|
|
||||||
.add("manga_id", track.manga_id)
|
|
||||||
.add("add_manga[status]", track.status)
|
|
||||||
.add("add_manga[num_read_volumes]", track.num_read_volumes)
|
|
||||||
.add("last_completed_vol", track.last_completed_vol)
|
|
||||||
.add("add_manga[num_read_chapters]", track.num_read_chapters)
|
|
||||||
.add("add_manga[score]", track.score)
|
|
||||||
.add("add_manga[start_date][month]", track.start_date_month)
|
|
||||||
.add("add_manga[start_date][day]", track.start_date_day)
|
|
||||||
.add("add_manga[start_date][year]", track.start_date_year)
|
|
||||||
.add("add_manga[finish_date][month]", track.finish_date_month)
|
|
||||||
.add("add_manga[finish_date][day]", track.finish_date_day)
|
|
||||||
.add("add_manga[finish_date][year]", track.finish_date_year)
|
|
||||||
.add("add_manga[tags]", track.tags)
|
|
||||||
.add("add_manga[priority]", track.priority)
|
|
||||||
.add("add_manga[storage_type]", track.storage_type)
|
|
||||||
.add("add_manga[num_retail_volumes]", track.num_retail_volumes)
|
|
||||||
.add("add_manga[num_read_times]", track.num_read_times)
|
|
||||||
.add("add_manga[reread_value]", track.reread_value)
|
|
||||||
.add("add_manga[comments]", track.comments)
|
|
||||||
.add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss)
|
|
||||||
.add("add_manga[sns_post_type]", track.sns_post_type)
|
|
||||||
.add("submitIt", track.submitIt)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.searchDateXml(field: String): Long {
|
|
||||||
val text = selectText(field, "0000-00-00")!!
|
|
||||||
// MAL sets the data to 0000-00-00 when date is invalid or missing
|
|
||||||
if (text == "0000-00-00") {
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.searchDatePicker(id: String): Long {
|
|
||||||
val month = select(id + "_month > option[selected]").`val`().toIntOrNull()
|
|
||||||
val day = select(id + "_day > option[selected]").`val`().toIntOrNull()
|
|
||||||
val year = select(id + "_year > option[selected]").`val`().toIntOrNull()
|
|
||||||
if (year == null || month == null || day == null) {
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
return GregorianCalendar(year, month - 1, day).timeInMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.searchTitle() = select("strong").text()!!
|
|
||||||
|
|
||||||
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
|
|
||||||
|
|
||||||
private fun Element.searchCoverUrl() = select("img")
|
|
||||||
.attr("data-src")
|
|
||||||
.split("\\?")[0]
|
|
||||||
.replace("/r/50x70/", "/")
|
|
||||||
|
|
||||||
private fun Element.searchMediaId() = select("div.picSurround")
|
|
||||||
.select("a").attr("id")
|
|
||||||
.replace("sarea", "")
|
|
||||||
.toInt()
|
|
||||||
|
|
||||||
private fun Element.searchSummary() = select("div.pt4")
|
|
||||||
.first()
|
|
||||||
.ownText()!!
|
|
||||||
|
|
||||||
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
|
|
||||||
|
|
||||||
private fun Element.searchPublishingType() = select(TD)[2].text()!!
|
|
||||||
|
|
||||||
private fun Element.searchStartDate() = select(TD)[6].text()!!
|
|
||||||
|
|
||||||
private fun getStatus(status: String) = when (status) {
|
|
||||||
"Reading" -> 1
|
|
||||||
"Completed" -> 2
|
|
||||||
"On-Hold" -> 3
|
|
||||||
"Dropped" -> 4
|
|
||||||
"Plan to Read" -> 6
|
|
||||||
else -> 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MyAnimeListEditData(
|
|
||||||
// entry_id
|
|
||||||
var entry_id: String,
|
|
||||||
|
|
||||||
// manga_id
|
|
||||||
var manga_id: String,
|
|
||||||
|
|
||||||
// add_manga[status]
|
|
||||||
var status: String,
|
|
||||||
|
|
||||||
// add_manga[num_read_volumes]
|
|
||||||
var num_read_volumes: String,
|
|
||||||
|
|
||||||
// last_completed_vol
|
|
||||||
var last_completed_vol: String,
|
|
||||||
|
|
||||||
// add_manga[num_read_chapters]
|
|
||||||
var num_read_chapters: String,
|
|
||||||
|
|
||||||
// add_manga[score]
|
|
||||||
var score: String,
|
|
||||||
|
|
||||||
// add_manga[start_date][month]
|
|
||||||
var start_date_month: String, // [1-12]
|
|
||||||
|
|
||||||
// add_manga[start_date][day]
|
|
||||||
var start_date_day: String,
|
|
||||||
|
|
||||||
// add_manga[start_date][year]
|
|
||||||
var start_date_year: String,
|
|
||||||
|
|
||||||
// add_manga[finish_date][month]
|
|
||||||
var finish_date_month: String, // [1-12]
|
|
||||||
|
|
||||||
// add_manga[finish_date][day]
|
|
||||||
var finish_date_day: String,
|
|
||||||
|
|
||||||
// add_manga[finish_date][year]
|
|
||||||
var finish_date_year: String,
|
|
||||||
|
|
||||||
// add_manga[tags]
|
|
||||||
var tags: String,
|
|
||||||
|
|
||||||
// add_manga[priority]
|
|
||||||
var priority: String,
|
|
||||||
|
|
||||||
// add_manga[storage_type]
|
|
||||||
var storage_type: String,
|
|
||||||
|
|
||||||
// add_manga[num_retail_volumes]
|
|
||||||
var num_retail_volumes: String,
|
|
||||||
|
|
||||||
// add_manga[num_read_times]
|
|
||||||
var num_read_times: String,
|
|
||||||
|
|
||||||
// add_manga[reread_value]
|
|
||||||
var reread_value: String,
|
|
||||||
|
|
||||||
// add_manga[comments]
|
|
||||||
var comments: String,
|
|
||||||
|
|
||||||
// add_manga[is_asked_to_discuss]
|
|
||||||
var is_asked_to_discuss: String,
|
|
||||||
|
|
||||||
// add_manga[sns_post_type]
|
|
||||||
var sns_post_type: String,
|
|
||||||
|
|
||||||
// submitIt
|
|
||||||
val submitIt: String = "0"
|
|
||||||
) {
|
|
||||||
fun copyPersonalFrom(track: Track) {
|
|
||||||
num_read_chapters = track.last_chapter_read.toString()
|
|
||||||
val numScore = track.score.toInt()
|
|
||||||
if (numScore == 0) {
|
|
||||||
score = ""
|
|
||||||
} else if (numScore in 1..10) {
|
|
||||||
score = numScore.toString()
|
|
||||||
}
|
|
||||||
status = track.status.toString()
|
|
||||||
if (track.started_reading_date == 0L) {
|
|
||||||
start_date_month = ""
|
|
||||||
start_date_day = ""
|
|
||||||
start_date_year = ""
|
|
||||||
}
|
|
||||||
if (track.finished_reading_date == 0L) {
|
|
||||||
finish_date_month = ""
|
|
||||||
finish_date_day = ""
|
|
||||||
finish_date_year = ""
|
|
||||||
}
|
|
||||||
track.started_reading_date.toCalendar()?.let { cal ->
|
|
||||||
start_date_month = (cal[Calendar.MONTH] + 1).toString()
|
|
||||||
start_date_day = cal[Calendar.DAY_OF_MONTH].toString()
|
|
||||||
start_date_year = cal[Calendar.YEAR].toString()
|
|
||||||
}
|
|
||||||
track.finished_reading_date.toCalendar()?.let { cal ->
|
|
||||||
finish_date_month = (cal[Calendar.MONTH] + 1).toString()
|
|
||||||
finish_date_day = cal[Calendar.DAY_OF_MONTH].toString()
|
|
||||||
finish_date_year = cal[Calendar.YEAR].toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,52 +1,58 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Buffer
|
import uy.kohesive.injekt.injectLazy
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor {
|
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
private var oauth: OAuth? = null
|
||||||
|
set(value) {
|
||||||
|
field = value?.copy(expires_in = System.currentTimeMillis() + (value.expires_in * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
myanimelist.ensureLoggedIn()
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
val request = chain.request()
|
if (token.isNullOrEmpty()) {
|
||||||
return chain.proceed(updateRequest(request))
|
throw Exception("Not authenticated with MyAnimeList")
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRequest(request: Request): Request {
|
|
||||||
return request.body?.let {
|
|
||||||
val contentType = it.contentType().toString()
|
|
||||||
val updatedBody = when {
|
|
||||||
contentType.contains("x-www-form-urlencoded") -> updateFormBody(it)
|
|
||||||
contentType.contains("json") -> updateJsonBody(it)
|
|
||||||
else -> it
|
|
||||||
}
|
|
||||||
request.newBuilder().post(updatedBody).build()
|
|
||||||
} ?: request
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bodyToString(requestBody: RequestBody): String {
|
|
||||||
Buffer().use {
|
|
||||||
requestBody.writeTo(it)
|
|
||||||
return it.readUtf8()
|
|
||||||
}
|
}
|
||||||
|
if (oauth == null) {
|
||||||
|
oauth = myanimelist.loadOAuth()
|
||||||
|
}
|
||||||
|
// Refresh access token if null or expired.
|
||||||
|
if (oauth!!.isExpired()) {
|
||||||
|
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!.refresh_token)).use {
|
||||||
|
if (it.isSuccessful) {
|
||||||
|
setAuth(json.decodeFromString(it.body!!.string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw on null auth.
|
||||||
|
if (oauth == null) {
|
||||||
|
throw Exception("No authentication token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the authorization header to the original request.
|
||||||
|
val authRequest = originalRequest.newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(authRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFormBody(requestBody: RequestBody): RequestBody {
|
/**
|
||||||
val formString = bodyToString(requestBody)
|
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
|
||||||
|
* and the oauth object.
|
||||||
return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType())
|
*/
|
||||||
}
|
fun setAuth(oauth: OAuth?) {
|
||||||
|
token = oauth?.access_token
|
||||||
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
|
this.oauth = oauth
|
||||||
val jsonString = bodyToString(requestBody)
|
myanimelist.saveOAuth(oauth)
|
||||||
val newBody = JSONObject(jsonString)
|
|
||||||
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
|
|
||||||
|
|
||||||
return newBody.toString().toRequestBody(requestBody.contentType())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
|
||||||
|
fun Track.toMyAnimeListStatus() = when (status) {
|
||||||
|
MyAnimeList.READING -> "reading"
|
||||||
|
MyAnimeList.COMPLETED -> "completed"
|
||||||
|
MyAnimeList.ON_HOLD -> "on_hold"
|
||||||
|
MyAnimeList.DROPPED -> "dropped"
|
||||||
|
MyAnimeList.PLAN_TO_READ -> "plan_to_read"
|
||||||
|
MyAnimeList.REREADING -> "reading"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStatus(status: String) = when (status) {
|
||||||
|
"reading" -> MyAnimeList.READING
|
||||||
|
"completed" -> MyAnimeList.COMPLETED
|
||||||
|
"on_hold" -> MyAnimeList.ON_HOLD
|
||||||
|
"dropped" -> MyAnimeList.DROPPED
|
||||||
|
"plan_to_read" -> MyAnimeList.PLAN_TO_READ
|
||||||
|
else -> MyAnimeList.READING
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OAuth(
|
||||||
|
val refresh_token: String,
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val expires_in: Long
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun isExpired() = System.currentTimeMillis() > expires_in
|
||||||
|
}
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.shikimori
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
@ -9,8 +10,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
|||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import rx.Completable
|
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
||||||
@ -22,19 +21,17 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLANNING = 5
|
const val PLANNING = 5
|
||||||
const val REPEATING = 6
|
const val REPEATING = 6
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
|
||||||
const val DEFAULT_SCORE = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "Shikimori"
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { ShikimoriInterceptor(this) }
|
private val interceptor by lazy { ShikimoriInterceptor(this) }
|
||||||
|
|
||||||
private val api by lazy { ShikimoriApi(client, interceptor) }
|
private val api by lazy { ShikimoriApi(client, interceptor) }
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
override fun nameRes() = R.string.tracker_shikimori
|
||||||
|
|
||||||
override fun getScoreList(): List<String> {
|
override fun getScoreList(): List<String> {
|
||||||
return IntRange(0, 10).map(Int::toString)
|
return IntRange(0, 10).map(Int::toString)
|
||||||
}
|
}
|
||||||
@ -43,43 +40,38 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
return track.score.toInt().toString()
|
return track.score.toInt().toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track, getUsername())
|
return api.addLibManga(track, getUsername())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override suspend fun update(track: Track): Track {
|
||||||
return api.updateLibManga(track, getUsername())
|
return api.updateLibManga(track, getUsername())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override suspend fun bind(track: Track): Track {
|
||||||
return api.findLibManga(track, getUsername())
|
val remoteTrack = api.findLibManga(track, getUsername())
|
||||||
.flatMap { remoteTrack ->
|
return if (remoteTrack != null) {
|
||||||
if (remoteTrack != null) {
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.library_id = remoteTrack.library_id
|
||||||
track.library_id = remoteTrack.library_id
|
update(track)
|
||||||
update(track)
|
} else {
|
||||||
} else {
|
// Set default fields if it's not found in the list
|
||||||
// Set default fields if it's not found in the list
|
track.status = READING
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
track.score = 0F
|
||||||
track.status = DEFAULT_STATUS
|
add(track)
|
||||||
add(track)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override suspend fun refresh(track: Track): Track {
|
||||||
return api.findLibManga(track, getUsername())
|
api.findLibManga(track, getUsername())?.let { remoteTrack ->
|
||||||
.map { remoteTrack ->
|
track.copyPersonalFrom(remoteTrack)
|
||||||
if (remoteTrack != null) {
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
track.copyPersonalFrom(remoteTrack)
|
}
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
return track
|
||||||
}
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogo() = R.drawable.ic_tracker_shikimori
|
override fun getLogo() = R.drawable.ic_tracker_shikimori
|
||||||
@ -104,18 +96,17 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
|||||||
|
|
||||||
override fun getCompletionStatus(): Int = COMPLETED
|
override fun getCompletionStatus(): Int = COMPLETED
|
||||||
|
|
||||||
override fun login(username: String, password: String) = login(password)
|
override suspend fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
fun login(code: String): Completable {
|
suspend fun login(code: String) {
|
||||||
return api.accessToken(code).map { oauth: OAuth? ->
|
try {
|
||||||
|
val oauth = api.accessToken(code)
|
||||||
interceptor.newAuth(oauth)
|
interceptor.newAuth(oauth)
|
||||||
if (oauth != null) {
|
val user = api.getCurrentUser()
|
||||||
val user = api.getCurrentUser()
|
saveCredentials(user.toString(), oauth.access_token)
|
||||||
saveCredentials(user.toString(), oauth.access_token)
|
} catch (e: Throwable) {
|
||||||
}
|
|
||||||
}.doOnError {
|
|
||||||
logout()
|
logout()
|
||||||
}.toCompletable()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToken(oauth: OAuth?) {
|
fun saveToken(oauth: OAuth?) {
|
||||||
|
@ -6,9 +6,11 @@ import eu.kanade.tachiyomi.data.track.TrackManager
|
|||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.await
|
||||||
import kotlinx.serialization.decodeFromString
|
import eu.kanade.tachiyomi.network.jsonMime
|
||||||
import kotlinx.serialization.json.Json
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
@ -19,65 +21,53 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
import kotlinx.serialization.json.putJsonObject
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import rx.Observable
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
|
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
fun addLibManga(track: Track, user_id: String): Observable<Track> {
|
suspend fun addLibManga(track: Track, user_id: String): Track {
|
||||||
val payload = buildJsonObject {
|
return withIOContext {
|
||||||
putJsonObject("user_rate") {
|
val payload = buildJsonObject {
|
||||||
put("user_id", user_id)
|
putJsonObject("user_rate") {
|
||||||
put("target_id", track.media_id)
|
put("user_id", user_id)
|
||||||
put("target_type", "Manga")
|
put("target_id", track.media_id)
|
||||||
put("chapters", track.last_chapter_read)
|
put("target_type", "Manga")
|
||||||
put("score", track.score.toInt())
|
put("chapters", track.last_chapter_read)
|
||||||
put("status", track.toShikimoriStatus())
|
put("score", track.score.toInt())
|
||||||
|
put("status", track.toShikimoriStatus())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
"$apiUrl/v2/user_rates",
|
||||||
|
body = payload.toString().toRequestBody(jsonMime)
|
||||||
|
)
|
||||||
|
).await()
|
||||||
|
track
|
||||||
}
|
}
|
||||||
val body = payload.toString().toRequestBody(jsonime)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$apiUrl/v2/user_rates")
|
|
||||||
.post(body)
|
|
||||||
.build()
|
|
||||||
return authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map {
|
|
||||||
track
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
|
suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
|
||||||
|
|
||||||
fun search(search: String): Observable<List<TrackSearch>> {
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
val url = "$apiUrl/mangas".toUri().buildUpon()
|
return withIOContext {
|
||||||
.appendQueryParameter("order", "popularity")
|
val url = "$apiUrl/mangas".toUri().buildUpon()
|
||||||
.appendQueryParameter("search", search)
|
.appendQueryParameter("order", "popularity")
|
||||||
.appendQueryParameter("limit", "20")
|
.appendQueryParameter("search", search)
|
||||||
.build()
|
.appendQueryParameter("limit", "20")
|
||||||
val request = Request.Builder()
|
.build()
|
||||||
.url(url.toString())
|
authClient.newCall(GET(url.toString()))
|
||||||
.get()
|
.await()
|
||||||
.build()
|
.parseAs<JsonArray>()
|
||||||
return authClient.newCall(request)
|
.let { response ->
|
||||||
.asObservableSuccess()
|
response.map {
|
||||||
.map { netResponse ->
|
jsonToSearch(it.jsonObject)
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
}
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
}
|
||||||
val response = json.decodeFromString<JsonArray>(responseBody)
|
}
|
||||||
response.map { jsonToSearch(it.jsonObject) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||||
@ -106,61 +96,51 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
|
suspend fun findLibManga(track: Track, user_id: String): Track? {
|
||||||
val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
|
return withIOContext {
|
||||||
.appendQueryParameter("user_id", user_id)
|
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
|
||||||
.appendQueryParameter("target_id", track.media_id.toString())
|
.appendPath(track.media_id.toString())
|
||||||
.appendQueryParameter("target_type", "Manga")
|
.build()
|
||||||
.build()
|
val mangas = authClient.newCall(GET(urlMangas.toString()))
|
||||||
val request = Request.Builder()
|
.await()
|
||||||
.url(url.toString())
|
.parseAs<JsonObject>()
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
|
val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
|
||||||
.appendPath(track.media_id.toString())
|
.appendQueryParameter("user_id", user_id)
|
||||||
.build()
|
.appendQueryParameter("target_id", track.media_id.toString())
|
||||||
val requestMangas = Request.Builder()
|
.appendQueryParameter("target_type", "Manga")
|
||||||
.url(urlMangas.toString())
|
.build()
|
||||||
.get()
|
authClient.newCall(GET(url.toString()))
|
||||||
.build()
|
.await()
|
||||||
return authClient.newCall(requestMangas)
|
.parseAs<JsonArray>()
|
||||||
.asObservableSuccess()
|
.let { response ->
|
||||||
.map { netResponse ->
|
if (response.size > 1) {
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
throw Exception("Too much mangas in response")
|
||||||
json.decodeFromString<JsonObject>(responseBody)
|
|
||||||
}.flatMap { mangas ->
|
|
||||||
authClient.newCall(request)
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { netResponse ->
|
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
|
||||||
if (responseBody.isEmpty()) {
|
|
||||||
throw Exception("Null Response")
|
|
||||||
}
|
|
||||||
val response = json.decodeFromString<JsonArray>(responseBody)
|
|
||||||
if (response.size > 1) {
|
|
||||||
throw Exception("Too much mangas in response")
|
|
||||||
}
|
|
||||||
val entry = response.map {
|
|
||||||
jsonToTrack(it.jsonObject, mangas)
|
|
||||||
}
|
|
||||||
entry.firstOrNull()
|
|
||||||
}
|
}
|
||||||
}
|
val entry = response.map {
|
||||||
|
jsonToTrack(it.jsonObject, mangas)
|
||||||
|
}
|
||||||
|
entry.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentUser(): Int {
|
fun getCurrentUser(): Int {
|
||||||
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string()!!
|
return runBlocking {
|
||||||
return json.decodeFromString<JsonObject>(user)["id"]!!.jsonPrimitive.int
|
authClient.newCall(GET("$apiUrl/users/whoami"))
|
||||||
|
.await()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["id"]!!.jsonPrimitive.int
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun accessToken(code: String): Observable<OAuth> {
|
suspend fun accessToken(code: String): OAuth {
|
||||||
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
return withIOContext {
|
||||||
val responseBody = netResponse.body?.string().orEmpty()
|
client.newCall(accessTokenRequest(code))
|
||||||
if (responseBody.isEmpty()) {
|
.await()
|
||||||
throw Exception("Null Response")
|
.parseAs()
|
||||||
}
|
|
||||||
json.decodeFromString<OAuth>(responseBody)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater.github
|
|
||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Path
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to connect with the GitHub API to get the latest release version from a repo.
|
|
||||||
*/
|
|
||||||
interface GithubService {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun create(): GithubService {
|
|
||||||
val restAdapter = Retrofit.Builder()
|
|
||||||
.baseUrl("https://api.github.com")
|
|
||||||
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
|
|
||||||
.client(Injekt.get<NetworkHelper>().client)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return restAdapter.create(GithubService::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET("/repos/{repo}/releases/latest")
|
|
||||||
suspend fun getLatestVersion(@Path("repo", encoded = true) repo: String): GithubRelease
|
|
||||||
}
|
|
@ -2,27 +2,39 @@ package eu.kanade.tachiyomi.data.updater.github
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class GithubUpdateChecker {
|
class GithubUpdateChecker {
|
||||||
|
|
||||||
private val service: GithubService = GithubService.create()
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
private val repo: String by lazy {
|
private val repo: String by lazy {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
"tachiyomiorg/android-app-preview"
|
"tachiyomiorg/tachiyomi-preview"
|
||||||
} else {
|
} else {
|
||||||
"inorichi/tachiyomi"
|
"tachiyomiorg/tachiyomi"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkForUpdate(): UpdateResult {
|
suspend fun checkForUpdate(): UpdateResult {
|
||||||
val release = service.getLatestVersion(repo)
|
return withIOContext {
|
||||||
|
networkService.client
|
||||||
// Check if latest version is different from current version
|
.newCall(GET("https://api.github.com/repos/$repo/releases/latest"))
|
||||||
return if (isNewVersion(release.version)) {
|
.await()
|
||||||
GithubUpdateResult.NewUpdate(release)
|
.parseAs<GithubRelease>()
|
||||||
} else {
|
.let {
|
||||||
GithubUpdateResult.NoNewUpdate()
|
// Check if latest version is different from current version
|
||||||
|
if (isNewVersion(it.version)) {
|
||||||
|
GithubUpdateResult.NewUpdate(it)
|
||||||
|
} else {
|
||||||
|
GithubUpdateResult.NoNewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,11 +43,11 @@ class GithubUpdateChecker {
|
|||||||
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
|
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
|
||||||
|
|
||||||
return if (BuildConfig.DEBUG) {
|
return if (BuildConfig.DEBUG) {
|
||||||
// Preview builds: based on releases in "tachiyomiorg/android-app-preview" repo
|
// Preview builds: based on releases in "tachiyomiorg/tachiyomi-preview" repo
|
||||||
// tagged as something like "r1234"
|
// tagged as something like "r1234"
|
||||||
newVersion.toInt() > BuildConfig.COMMIT_COUNT.toInt()
|
newVersion.toInt() > BuildConfig.COMMIT_COUNT.toInt()
|
||||||
} else {
|
} else {
|
||||||
// Release builds: based on releases in "inorichi/tachiyomi" repo
|
// Release builds: based on releases in "tachiyomiorg/tachiyomi" repo
|
||||||
// tagged as something like "v0.1.2"
|
// tagged as something like "v0.1.2"
|
||||||
newVersion != BuildConfig.VERSION_NAME
|
newVersion != BuildConfig.VERSION_NAME
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,11 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||||
import kotlinx.coroutines.Dispatchers
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import kotlinx.coroutines.withContext
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.int
|
import kotlinx.serialization.json.int
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@ -16,14 +19,16 @@ import java.util.Date
|
|||||||
|
|
||||||
internal class ExtensionGithubApi {
|
internal class ExtensionGithubApi {
|
||||||
|
|
||||||
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
private val preferences: PreferencesHelper by injectLazy()
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
suspend fun findExtensions(): List<Extension.Available> {
|
suspend fun findExtensions(): List<Extension.Available> {
|
||||||
val service: ExtensionGithubService = ExtensionGithubService.create()
|
return withIOContext {
|
||||||
|
networkService.client
|
||||||
return withContext(Dispatchers.IO) {
|
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||||
val response = service.getRepo()
|
.await()
|
||||||
parseResponse(response)
|
.parseAs<JsonArray>()
|
||||||
|
.let { parseResponse(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,18 +70,18 @@ internal class ExtensionGithubApi {
|
|||||||
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
|
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
|
||||||
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
|
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
|
||||||
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
|
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
|
||||||
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
|
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
|
||||||
|
|
||||||
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
|
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getApkUrl(extension: Extension.Available): String {
|
fun getApkUrl(extension: Extension.Available): String {
|
||||||
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
|
return "${REPO_URL_PREFIX}apk/${extension.apkName}"
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val BASE_URL = "https://raw.githubusercontent.com/"
|
const val BASE_URL = "https://raw.githubusercontent.com/"
|
||||||
const val REPO_URL_PREFIX = "${BASE_URL}inorichi/tachiyomi-extensions/repo/"
|
const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.extension.api
|
|
||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to get the extension repo listing from GitHub.
|
|
||||||
*/
|
|
||||||
interface ExtensionGithubService {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun create(): ExtensionGithubService {
|
|
||||||
val network: NetworkHelper by injectLazy()
|
|
||||||
val adapter = Retrofit.Builder()
|
|
||||||
.baseUrl(ExtensionGithubApi.BASE_URL)
|
|
||||||
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
|
|
||||||
.client(network.client)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return adapter.create(ExtensionGithubService::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GET("${ExtensionGithubApi.REPO_URL_PREFIX}index.min.json")
|
|
||||||
suspend fun getRepo(): JsonArray
|
|
||||||
}
|
|
@ -18,6 +18,7 @@ sealed class Extension {
|
|||||||
override val versionCode: Int,
|
override val versionCode: Int,
|
||||||
override val lang: String,
|
override val lang: String,
|
||||||
override val isNsfw: Boolean,
|
override val isNsfw: Boolean,
|
||||||
|
val pkgFactory: String?,
|
||||||
val sources: List<Source>,
|
val sources: List<Source>,
|
||||||
val hasUpdate: Boolean = false,
|
val hasUpdate: Boolean = false,
|
||||||
val isObsolete: Boolean = false,
|
val isObsolete: Boolean = false,
|
||||||
|
@ -31,6 +31,7 @@ internal object ExtensionLoader {
|
|||||||
|
|
||||||
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
|
private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||||
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||||
const val LIB_VERSION_MIN = 1.2
|
const val LIB_VERSION_MIN = 1.2
|
||||||
const val LIB_VERSION_MAX = 1.2
|
const val LIB_VERSION_MAX = 1.2
|
||||||
@ -162,7 +163,7 @@ internal object ExtensionLoader {
|
|||||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Timber.e(e, "Extension load error: $extName.")
|
Timber.w(e, "Extension load error: $extName ($it)")
|
||||||
return LoadResult.Error(e)
|
return LoadResult.Error(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,7 +185,8 @@ internal object ExtensionLoader {
|
|||||||
versionCode,
|
versionCode,
|
||||||
lang,
|
lang,
|
||||||
isNsfw,
|
isNsfw,
|
||||||
sources,
|
sources = sources,
|
||||||
|
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||||
isUnofficial = signatureHash != officialSignature
|
isUnofficial = signatureHash != officialSignature
|
||||||
)
|
)
|
||||||
return LoadResult.Success(extension)
|
return LoadResult.Success(extension)
|
||||||
|
@ -99,7 +99,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
|||||||
|
|
||||||
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
|
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
|
||||||
webview.settings.userAgentString = request.header("User-Agent")
|
webview.settings.userAgentString = request.header("User-Agent")
|
||||||
?: HttpSource.DEFAULT_USERAGENT
|
?: HttpSource.DEFAULT_USER_AGENT
|
||||||
|
|
||||||
webview.webViewClient = object : WebViewClientCompat() {
|
webview.webViewClient = object : WebViewClientCompat() {
|
||||||
override fun onPageFinished(view: WebView, url: String) {
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
@ -171,6 +171,6 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
|
||||||
private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance")
|
private val COOKIE_NAMES = listOf("cf_clearance")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java
|
||||||
|
*/
|
||||||
|
|
||||||
|
const val PREF_DOH_CLOUDFLARE = 1
|
||||||
|
const val PREF_DOH_GOOGLE = 2
|
||||||
|
|
||||||
|
fun OkHttpClient.Builder.dohCloudflare() = dns(
|
||||||
|
DnsOverHttps.Builder().client(build())
|
||||||
|
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
||||||
|
.bootstrapDnsHosts(
|
||||||
|
InetAddress.getByName("162.159.36.1"),
|
||||||
|
InetAddress.getByName("162.159.46.1"),
|
||||||
|
InetAddress.getByName("1.1.1.1"),
|
||||||
|
InetAddress.getByName("1.0.0.1"),
|
||||||
|
InetAddress.getByName("162.159.132.53"),
|
||||||
|
InetAddress.getByName("2606:4700:4700::1111"),
|
||||||
|
InetAddress.getByName("2606:4700:4700::1001"),
|
||||||
|
InetAddress.getByName("2606:4700:4700::0064"),
|
||||||
|
InetAddress.getByName("2606:4700:4700::6400")
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun OkHttpClient.Builder.dohGoogle() = dns(
|
||||||
|
DnsOverHttps.Builder().client(build())
|
||||||
|
.url("https://dns.google/dns-query".toHttpUrl())
|
||||||
|
.bootstrapDnsHosts(
|
||||||
|
InetAddress.getByName("8.8.4.4"),
|
||||||
|
InetAddress.getByName("8.8.8.8")
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
@ -4,13 +4,10 @@ import android.content.Context
|
|||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.InetAddress
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class NetworkHelper(context: Context) {
|
class NetworkHelper(context: Context) {
|
||||||
@ -38,25 +35,9 @@ class NetworkHelper(context: Context) {
|
|||||||
builder.addInterceptor(httpLoggingInterceptor)
|
builder.addInterceptor(httpLoggingInterceptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preferences.enableDoh()) {
|
when (preferences.dohProvider()) {
|
||||||
builder.dns(
|
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
|
||||||
DnsOverHttps.Builder().client(builder.build())
|
PREF_DOH_GOOGLE -> builder.dohGoogle()
|
||||||
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
|
||||||
.bootstrapDnsHosts(
|
|
||||||
listOf(
|
|
||||||
InetAddress.getByName("162.159.36.1"),
|
|
||||||
InetAddress.getByName("162.159.46.1"),
|
|
||||||
InetAddress.getByName("1.1.1.1"),
|
|
||||||
InetAddress.getByName("1.0.0.1"),
|
|
||||||
InetAddress.getByName("162.159.132.53"),
|
|
||||||
InetAddress.getByName("2606:4700:4700::1111"),
|
|
||||||
InetAddress.getByName("2606:4700:4700::1001"),
|
|
||||||
InetAddress.getByName("2606:4700:4700::0064"),
|
|
||||||
InetAddress.getByName("2606:4700:4700::6400")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.build()
|
builder.build()
|
||||||
|
@ -1,19 +1,27 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.Callback
|
import okhttp3.Callback
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.Producer
|
import rx.Producer
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.fullType
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
fun Call.asObservable(): Observable<Response> {
|
fun Call.asObservable(): Observable<Response> {
|
||||||
return Observable.unsafeCreate { subscriber ->
|
return Observable.unsafeCreate { subscriber ->
|
||||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||||
@ -52,12 +60,12 @@ fun Call.asObservable(): Observable<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||||
suspend fun Call.await(assertSuccess: Boolean = false): Response {
|
suspend fun Call.await(): Response {
|
||||||
return suspendCancellableCoroutine { continuation ->
|
return suspendCancellableCoroutine { continuation ->
|
||||||
enqueue(
|
enqueue(
|
||||||
object : Callback {
|
object : Callback {
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
if (assertSuccess && !response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -105,3 +113,12 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
|
|||||||
|
|
||||||
return progressClient.newCall(request)
|
return progressClient.newCall(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <reified T> Response.parseAs(): T {
|
||||||
|
// Avoiding Injekt.get<Json>() due to compiler issues
|
||||||
|
val json = Injekt.getInstance<Json>(fullType<Json>().type)
|
||||||
|
this.use {
|
||||||
|
val responseBody = it.body?.string().orEmpty()
|
||||||
|
return json.decodeFromString(responseBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -12,7 +12,7 @@ class UserAgentInterceptor : Interceptor {
|
|||||||
val newRequest = originalRequest
|
val newRequest = originalRequest
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.removeHeader("User-Agent")
|
.removeHeader("User-Agent")
|
||||||
.addHeader("User-Agent", HttpSource.DEFAULT_USERAGENT)
|
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
|
||||||
.build()
|
.build()
|
||||||
chain.proceed(newRequest)
|
chain.proceed(newRequest)
|
||||||
} else {
|
} else {
|
||||||
|
@ -27,7 +27,7 @@ import java.util.zip.ZipFile
|
|||||||
class LocalSource(private val context: Context) : CatalogueSource {
|
class LocalSource(private val context: Context) : CatalogueSource {
|
||||||
companion object {
|
companion object {
|
||||||
const val ID = 0L
|
const val ID = 0L
|
||||||
const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
|
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
|
||||||
|
|
||||||
private const val COVER_NAME = "cover.jpg"
|
private const val COVER_NAME = "cover.jpg"
|
||||||
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
|
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
|
||||||
|
@ -49,7 +49,7 @@ open class SourceManager(private val context: Context) {
|
|||||||
LocalSource(context)
|
LocalSource(context)
|
||||||
)
|
)
|
||||||
|
|
||||||
private inner class StubSource(override val id: Long) : Source {
|
inner class StubSource(override val id: Long) : Source {
|
||||||
|
|
||||||
override val name: String
|
override val name: String
|
||||||
get() = id.toString()
|
get() = id.toString()
|
||||||
|
@ -74,7 +74,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||||
*/
|
*/
|
||||||
protected open fun headersBuilder() = Headers.Builder().apply {
|
protected open fun headersBuilder() = Headers.Builder().apply {
|
||||||
add("User-Agent", DEFAULT_USERAGENT)
|
add("User-Agent", DEFAULT_USER_AGENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -371,6 +371,6 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
override fun getFilterList() = FilterList()
|
override fun getFilterList() = FilterList()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_USERAGENT = "Mozilla/5.0 (Windows NT 6.3; WOW64)"
|
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.ui.base.activity
|
package eu.kanade.tachiyomi.ui.base.activity
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
||||||
@ -13,7 +12,6 @@ abstract class BaseRxActivity<VB : ViewBinding, P : BasePresenter<*>> : NucleusA
|
|||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
private val secureActivityDelegate = SecureActivityDelegate(this)
|
private val secureActivityDelegate = SecureActivityDelegate(this)
|
||||||
|
|
||||||
val scope = lifecycleScope
|
|
||||||
lateinit var binding: VB
|
lateinit var binding: VB
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -4,26 +4,16 @@ import android.content.res.Configuration
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
|
||||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
|
import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values
|
||||||
|
|
||||||
abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
|
abstract class BaseThemedActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
val scope = lifecycleScope
|
val isDarkMode: Boolean by lazy {
|
||||||
lateinit var binding: VB
|
|
||||||
|
|
||||||
@Suppress("LeakingThis")
|
|
||||||
private val secureActivityDelegate = SecureActivityDelegate(this)
|
|
||||||
|
|
||||||
private val isDarkMode: Boolean by lazy {
|
|
||||||
val themeMode = preferences.themeMode().get()
|
val themeMode = preferences.themeMode().get()
|
||||||
(themeMode == Values.ThemeMode.dark) ||
|
(themeMode == Values.ThemeMode.dark) ||
|
||||||
(
|
(
|
||||||
@ -62,11 +52,6 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
@Suppress("LeakingThis")
|
|
||||||
LocaleHelper.updateConfiguration(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
setTheme(
|
setTheme(
|
||||||
when {
|
when {
|
||||||
@ -76,13 +61,5 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
secureActivityDelegate.onCreate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
secureActivityDelegate.onResume()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user