mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-26 19:17:51 +02:00
Compare commits
202 Commits
Author | SHA1 | Date | |
---|---|---|---|
e863e8c64b | |||
f5b591430c | |||
8cfaf8eb51 | |||
675c0cefc3 | |||
1a52385b78 | |||
372e500590 | |||
cc1a317439 | |||
6d650518a1 | |||
7940117577 | |||
b0f87fdd21 | |||
dc92ffed87 | |||
4af578e310 | |||
e22825d818 | |||
e2da6259e7 | |||
d149017c60 | |||
afc400121b | |||
ef993515c6 | |||
edb1d21ddc | |||
ba8abd94a8 | |||
c6d4e4c15f | |||
09f0ac866f | |||
7ed25704d6 | |||
2196dac63e | |||
c8f70efded | |||
ea97488670 | |||
c2255b0a0f | |||
f754b081ce | |||
07771cb5e4 | |||
690d8e43ae | |||
82f14a7d59 | |||
b284384f0a | |||
1ae0d1b5d0 | |||
9de08c8166 | |||
a2d007f2a9 | |||
774f818bbb | |||
0ec7121b8f | |||
d7d46f4447 | |||
45fad147bf | |||
3664195c71 | |||
fce3cd00a1 | |||
33b3be0d0e | |||
cfd1b4a6c6 | |||
d45fefd6f0 | |||
f125ab01ee | |||
be001d090c | |||
971d8a7e40 | |||
a2cf210a52 | |||
3eec207166 | |||
b5d83bdb56 | |||
2c495c4119 | |||
7c72d6cb7c | |||
8362bf0886 | |||
1a8155c45b | |||
3f2f946019 | |||
2c14a8dee1 | |||
917a283bd1 | |||
3e403d5ab3 | |||
746d35b52b | |||
9a7a03e327 | |||
a051079c6a | |||
7b3c18bb97 | |||
52daf3d58c | |||
f41bde5ee1 | |||
6151318ac1 | |||
b45c322729 | |||
b00e8768dc | |||
156feb6e8e | |||
e942b8a402 | |||
abdb67a123 | |||
ee20787c5e | |||
ec4e631760 | |||
02b430a5bf | |||
7878053df2 | |||
12a593c3c6 | |||
6b1f130750 | |||
bde4c0a648 | |||
5ae4621da1 | |||
5ea8d0546e | |||
8a064c118f | |||
2f91c27df2 | |||
763bd54707 | |||
0ea3cc7ce4 | |||
0de3558ab3 | |||
069f4e12d8 | |||
ae4dfc9956 | |||
ee711dc0fb | |||
c316e7faab | |||
7083b3d912 | |||
2d3a1b6a9e | |||
0df23ab878 | |||
7ed8de2ef4 | |||
d935e22f0d | |||
0e26abf7a6 | |||
59aef13200 | |||
9d1f6c4416 | |||
b9f7660a91 | |||
18b5250ed1 | |||
f683f21ee2 | |||
bd033db84c | |||
ab036312a4 | |||
634da15191 | |||
cea1720ea0 | |||
3f2f542265 | |||
b77edb2b5b | |||
1b699bb814 | |||
333c035fed | |||
ce29914c56 | |||
70e5361146 | |||
e7d6dfff53 | |||
eebfad5a95 | |||
77c0a93ac6 | |||
63a3e126b3 | |||
3ea84cf0ce | |||
7fa80ae556 | |||
925f71af15 | |||
c666dd623d | |||
2cd8733212 | |||
4b2a9bc621 | |||
12a9d0575d | |||
edcfa28b0b | |||
3155829994 | |||
d25707554e | |||
38df44ef4b | |||
df683375b1 | |||
cc3cbbc4bb | |||
6922394b8e | |||
24fd82d773 | |||
57aefcd917 | |||
b3854ad382 | |||
5f5fc77877 | |||
0493e77cff | |||
6240fe1dfc | |||
beb7f90908 | |||
a3917972b4 | |||
7094fef37f | |||
0f41e56a24 | |||
52b283283f | |||
ebb15bf96c | |||
6c527d52fb | |||
b8ea57e097 | |||
909aed4262 | |||
4d2fff9538 | |||
9a45983f17 | |||
11926014da | |||
72002c13d6 | |||
6ed767ae84 | |||
3826b307f7 | |||
887b157056 | |||
d36dd39743 | |||
dd008bc13a | |||
50b282f58b | |||
f8a7efbce7 | |||
7d2caeb270 | |||
708e71a35a | |||
4eaccc966e | |||
3670d649b8 | |||
90ab04e81d | |||
26b8df5354 | |||
11a8046c5f | |||
da16110e1c | |||
914b686c8e | |||
27133520fc | |||
24b967ad5c | |||
ca4b4a3f1e | |||
faef35ec47 | |||
326d4c2641 | |||
83436c9550 | |||
2084822731 | |||
071bad1232 | |||
ae1a76da2b | |||
fbc6965c4e | |||
57a5862840 | |||
91fbccdbaa | |||
0ab0dd95ae | |||
bc41040fd3 | |||
4c8dfd0c0c | |||
2b9dbfb390 | |||
84d546b724 | |||
63053b9940 | |||
2256030a2a | |||
79da33b597 | |||
7d67450e58 | |||
8aa11951bf | |||
f23f22ab01 | |||
96a64c7bd2 | |||
d1bb0fdf1d | |||
feca30d7ed | |||
b650151693 | |||
bb3afd0dc9 | |||
5e77ae208d | |||
24e5a4d7ec | |||
1d10d29fa9 | |||
9b00e91773 | |||
cd73c30d6f | |||
7bbba0c7d9 | |||
7907a4fc24 | |||
2f94f62a56 | |||
85791a9336 | |||
a4eba50cfd | |||
03980b2f27 | |||
664e5cfb59 | |||
b9736df7e0 |
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@ -3,7 +3,7 @@
|
||||
I acknowledge that:
|
||||
|
||||
- I have updated:
|
||||
- To the latest version of the app (stable is v0.11.1)
|
||||
- To the latest version of the app (stable is v0.12.3)
|
||||
- All extensions
|
||||
- I have tried the troubleshooting guide: https://tachiyomi.org/help/guides/troubleshooting-problems/
|
||||
- If this is an issue with an extension, that I should be opening an issue in https://github.com/tachiyomiorg/tachiyomi-extensions
|
||||
|
104
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
104
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
@ -3,57 +3,6 @@ description: Report an issue in Tachiyomi
|
||||
labels: [Bug]
|
||||
body:
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
|
||||
required: true
|
||||
- label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.11.1](https://github.com/tachiyomiorg/tachiyomi/releases/tag/v0.11.1)**.
|
||||
required: true
|
||||
- label: I have updated all installed extensions.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: tachiyomi-version
|
||||
attributes:
|
||||
label: Tachiyomi version
|
||||
description: You can find your Tachiyomi version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.11.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: You can find this somewhere in your Android settings.
|
||||
placeholder: |
|
||||
Example: "Android 11"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: List your device and model.
|
||||
placeholder: |
|
||||
Example: "Google Pixel 5"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
@ -84,7 +33,7 @@ body:
|
||||
label: Actual behavior
|
||||
description: Explain what actually happens.
|
||||
placeholder: |
|
||||
Example:
|
||||
Example:
|
||||
"This happened instead..."
|
||||
validations:
|
||||
required: true
|
||||
@ -98,9 +47,60 @@ body:
|
||||
placeholder: |
|
||||
You can paste the crash logs in pure text or upload it as an attachment.
|
||||
|
||||
- type: input
|
||||
id: tachiyomi-version
|
||||
attributes:
|
||||
label: Tachiyomi version
|
||||
description: You can find your Tachiyomi version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.12.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android version
|
||||
description: You can find this somewhere in your Android settings.
|
||||
placeholder: |
|
||||
Example: "Android 11"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: List your device and model.
|
||||
placeholder: |
|
||||
Example: "Google Pixel 5"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
|
||||
required: true
|
||||
- label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.12.3](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
|
||||
required: true
|
||||
- label: I have updated all installed extensions.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
34
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
34
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@ -3,23 +3,6 @@ description: Suggest a feature to improve Tachiyomi
|
||||
labels: [Feature request]
|
||||
body:
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.11.1](https://github.com/tachiyomiorg/tachiyomi/releases/tag/v0.11.1)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
@ -37,3 +20,20 @@ body:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.12.3](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
<!--
|
||||
Please include a summary of the change and which issue is fixed.
|
||||
Also make sure you've tested your code and also done a self-review of it.
|
||||
Don't forget to check all base themes and tablet mode for relevant changes.
|
||||
|
||||
If your changes are visual, please provide images below:
|
||||
|
||||
### Images
|
||||
| Image 1 | Image 2 |
|
||||
| ------- | ------- |
|
||||
|  |  |
|
||||
-->
|
BIN
.github/readme-images/screens.png
vendored
BIN
.github/readme-images/screens.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 454 KiB |
33
.github/workflows/build_pull_request.yml
vendored
Normal file
33
.github/workflows/build_pull_request.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: PR build check
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build app
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
|
||||
- name: Copy CI gradle.properties
|
||||
run: |
|
||||
mkdir -p ~/.gradle
|
||||
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Build app
|
||||
uses: gradle/gradle-command-action@v1
|
||||
with:
|
||||
arguments: assembleStandardRelease
|
||||
distributions-cache-enabled: true
|
||||
dependencies-cache-enabled: true
|
||||
configuration-cache-enabled: true
|
@ -5,23 +5,10 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check_wrapper:
|
||||
name: Validate Gradle Wrapper
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
build:
|
||||
name: Build app
|
||||
needs: check_wrapper
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@ -33,6 +20,9 @@ jobs:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
@ -44,10 +34,10 @@ jobs:
|
||||
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Build app
|
||||
uses: eskatos/gradle-command-action@v1
|
||||
uses: gradle/gradle-command-action@v1
|
||||
with:
|
||||
arguments: assembleStandardRelease
|
||||
wrapper-cache-enabled: true
|
||||
distributions-cache-enabled: true
|
||||
dependencies-cache-enabled: true
|
||||
configuration-cache-enabled: true
|
||||
|
||||
@ -55,13 +45,10 @@ jobs:
|
||||
|
||||
- name: Get tag name
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||
id: get_tag_name
|
||||
run: |
|
||||
set -x
|
||||
echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
# TODO: need to support multiple APKs
|
||||
|
||||
- name: Sign APK
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
@ -75,9 +62,23 @@ jobs:
|
||||
- name: Clean up build artifacts
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||
run: |
|
||||
cp ${{ env.SIGNED_RELEASE_FILE }} tachiyomi-${{ env.VERSION_TAG }}.apk
|
||||
md5=`md5sum tachiyomi-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_MD5=$md5" >> $GITHUB_ENV
|
||||
set -e
|
||||
|
||||
mv app/build/outputs/apk/standard/release/app-standard-universal-release-unsigned-signed.apk tachiyomi-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum tachiyomi-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_UNIVERSAL_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
cp app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release-unsigned-signed.apk tachiyomi-arm64-v8a-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum tachiyomi-arm64-v8a-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_ARM64_V8A_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
cp app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release-unsigned-signed.apk tachiyomi-armeabi-v7a-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum tachiyomi-armeabi-v7a-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_ARMEABI_V7A_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
cp app/build/outputs/apk/standard/release/app-standard-x86-release-unsigned-signed.apk tachiyomi-x86-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum tachiyomi-x86-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_X86_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'tachiyomiorg/tachiyomi'
|
||||
@ -86,9 +87,21 @@ jobs:
|
||||
tag_name: ${{ env.VERSION_TAG }}
|
||||
name: Tachiyomi ${{ env.VERSION_TAG }}
|
||||
body: |
|
||||
MD5: ${{ env.APK_MD5 }}
|
||||
---
|
||||
|
||||
### Checksums
|
||||
|
||||
| Variant | SHA-256 |
|
||||
| ------- | ------- |
|
||||
| Universal | ${{ env.APK_UNIVERSAL_SHA }}
|
||||
| arm64-v8a | ${{ env.APK_ARM64_V8A_SHA }}
|
||||
| armeabi-v7a | ${{ env.APK_ARMEABI_V7A_SHA }}
|
||||
| x86 | ${{ env.APK_X86_SHA }} |
|
||||
files: |
|
||||
tachiyomi-${{ env.VERSION_TAG }}.apk
|
||||
tachiyomi-arm64-v8a-${{ env.VERSION_TAG }}.apk
|
||||
tachiyomi-armeabi-v7a-${{ env.VERSION_TAG }}.apk
|
||||
tachiyomi-x86-${{ env.VERSION_TAG }}.apk
|
||||
draft: true
|
||||
prerelease: false
|
||||
env:
|
15
.github/workflows/cancel_pull_request.yml
vendored
Normal file
15
.github/workflows/cancel_pull_request.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
name: Cancel old pull request workflows
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR build check"]
|
||||
types:
|
||||
- requested
|
||||
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: styfle/cancel-workflow-action@0.8.0
|
||||
with:
|
||||
workflow_id: ${{ github.event.workflow.id }}
|
2
.github/workflows/issue_closer.yml
vendored
2
.github/workflows/issue_closer.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
"regex": ".*(aniyomi|anime).*",
|
||||
"regex": "^(?!.*myanimelist.*).*(aniyomi|anime).*$",
|
||||
"ignoreCase": true,
|
||||
"message": "Tachiyomi does not support anime, and has no plans to support anime. In addition Tachiyomi is not affiliated with Aniyomi https://github.com/jmir1/aniyomi"
|
||||
}
|
||||
|
@ -1,76 +1,126 @@
|
||||
# Code of Conduct
|
||||
# Contributor Covenant 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.
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community 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
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or 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
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
## Enforcement 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.
|
||||
Community moderators are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
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.
|
||||
Community moderators 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, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## 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.
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## 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.
|
||||
reported to the community moderators responsible for enforcement at
|
||||
the [Tachiyomi Discord server](https://discord.gg/tachiyomi).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
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.
|
||||
All community moderators are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community moderators will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community moderators, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## 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
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/),
|
||||
version 2.1, available at
|
||||
[v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[FAQ](https://www.contributor-covenant.org/faq). Translations are available
|
||||
at [translations](https://www.contributor-covenant.org/translations).
|
||||
|
@ -6,8 +6,6 @@
|
||||
# Tachiyomi
|
||||
Tachiyomi is a free and open source manga reader for Android 6.0 and above.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
Features include:
|
||||
@ -38,7 +36,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
|
||||
|
||||
<details><summary>Bugs</summary>
|
||||
|
||||
* Include version (More > About > Version)
|
||||
* 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)
|
||||
|
@ -29,8 +29,8 @@ android {
|
||||
minSdk = AndroidConfig.minSdk
|
||||
targetSdk = AndroidConfig.targetSdk
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
versionCode = 66
|
||||
versionName = "0.12.0"
|
||||
versionCode = 69
|
||||
versionName = "0.12.3"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
@ -47,7 +47,7 @@ android {
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = false
|
||||
isEnable = true
|
||||
reset()
|
||||
include(*SUPPORTED_ABIS.toTypedArray())
|
||||
isUniversalApk = true
|
||||
@ -79,7 +79,7 @@ android {
|
||||
getByName("debugFull").res.srcDirs("src/debug/res")
|
||||
}
|
||||
|
||||
flavorDimensions("default")
|
||||
flavorDimensions.add("default")
|
||||
|
||||
productFlavors {
|
||||
create("standard") {
|
||||
@ -87,18 +87,20 @@ android {
|
||||
dimension = "default"
|
||||
}
|
||||
create("dev") {
|
||||
resConfigs("en", "xxhdpi")
|
||||
resourceConfigurations.addAll(listOf("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")
|
||||
resources.excludes.addAll(listOf(
|
||||
"META-INF/DEPENDENCIES",
|
||||
"LICENSE.txt",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/LICENSE.txt",
|
||||
"META-INF/NOTICE",
|
||||
"META-INF/*.kotlin_module",
|
||||
))
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
@ -126,10 +128,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(kotlin("reflect", version = BuildPluginsVersion.KOTLIN))
|
||||
|
||||
val coroutinesVersion = "1.5.1"
|
||||
val coroutinesVersion = "1.5.2"
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
|
||||
|
||||
@ -137,37 +138,31 @@ dependencies {
|
||||
implementation("org.tachiyomi:source-api:1.1")
|
||||
|
||||
// AndroidX libraries
|
||||
implementation("androidx.annotation:annotation:1.3.0-alpha01")
|
||||
implementation("androidx.annotation:annotation:1.3.0-beta01")
|
||||
implementation("androidx.appcompat:appcompat:1.4.0-alpha03")
|
||||
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")
|
||||
implementation("androidx.browser:browser:1.4.0-beta01")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.1")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
|
||||
implementation("androidx.core:core-ktx:1.7.0-alpha01")
|
||||
implementation("androidx.core:core-splashscreen:1.0.0-alpha01")
|
||||
implementation("androidx.preference:preference-ktx:1.1.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.2.1")
|
||||
implementation("androidx.core:core-ktx:1.7.0-beta02")
|
||||
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.0-alpha01")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
|
||||
implementation("androidx.viewpager:viewpager:1.1.0-alpha01")
|
||||
|
||||
val lifecycleVersion = "2.4.0-alpha01"
|
||||
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
|
||||
val lifecycleVersion = "2.4.0-beta01"
|
||||
implementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
|
||||
|
||||
// Job scheduling
|
||||
implementation("androidx.work:work-runtime-ktx:2.6.0-beta01")
|
||||
implementation("androidx.work:work-runtime-ktx:2.6.0")
|
||||
|
||||
// UI library
|
||||
implementation("com.google.android.material:material:1.5.0-alpha01")
|
||||
|
||||
"standardImplementation"("com.google.firebase:firebase-core:19.0.0")
|
||||
|
||||
// ReactiveX
|
||||
// RX
|
||||
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")
|
||||
implementation("ru.beryukhov:flowreactivenetwork:1.0.4")
|
||||
|
||||
// Network client
|
||||
val okhttpVersion = "4.9.1"
|
||||
@ -179,24 +174,26 @@ dependencies {
|
||||
// TLS 1.3 support for Android < 10
|
||||
implementation("org.conscrypt:conscrypt-android:2.5.2")
|
||||
|
||||
// JSON
|
||||
val kotlinSerializationVersion = "1.2.2"
|
||||
// Data serialization (JSON, protobuf)
|
||||
val kotlinSerializationVersion = "1.3.0"
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
|
||||
|
||||
// TODO: remove these once they're no longer used in any extensions
|
||||
implementation("com.google.code.gson:gson:2.8.7")
|
||||
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
|
||||
|
||||
// JavaScript engine
|
||||
implementation("com.squareup.duktape:duktape-android:1.3.0")
|
||||
implementation("com.squareup.duktape:duktape-android:1.4.0")
|
||||
|
||||
// HTML parser
|
||||
implementation("org.jsoup:jsoup:1.14.2")
|
||||
|
||||
// Disk
|
||||
implementation("com.jakewharton:disklrucache:2.0.2")
|
||||
implementation("com.github.tachiyomiorg:unifile:17bec43")
|
||||
implementation("com.github.junrar:junrar:7.4.0")
|
||||
|
||||
// HTML parser
|
||||
implementation("org.jsoup:jsoup:1.14.1")
|
||||
|
||||
// Database
|
||||
implementation("androidx.sqlite:sqlite-ktx:2.1.0")
|
||||
implementation("com.github.inorichi.storio:storio-common:8be19de@aar")
|
||||
@ -204,6 +201,7 @@ dependencies {
|
||||
implementation("com.github.requery:sqlite-android:3.36.0")
|
||||
|
||||
// Preferences
|
||||
implementation("androidx.preference:preference-ktx:1.1.1")
|
||||
implementation("com.github.tfcporciuncula.flow-preferences:flow-preferences:1.4.0")
|
||||
|
||||
// Model View Presenter
|
||||
@ -214,7 +212,7 @@ dependencies {
|
||||
// Dependency injection
|
||||
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
|
||||
|
||||
// Image library
|
||||
// Image loading
|
||||
val coilVersion = "1.3.2"
|
||||
implementation("io.coil-kt:coil:$coilVersion")
|
||||
implementation("io.coil-kt:coil-gif:$coilVersion")
|
||||
@ -224,22 +222,19 @@ dependencies {
|
||||
}
|
||||
implementation("com.github.tachiyomiorg:image-decoder:7481a4a")
|
||||
|
||||
// Logging
|
||||
implementation("com.jakewharton.timber:timber:4.7.1")
|
||||
|
||||
// Crash reports
|
||||
implementation("ch.acra:acra-http:5.8.1")
|
||||
|
||||
// Sort
|
||||
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
|
||||
|
||||
// UI
|
||||
// UI libraries
|
||||
implementation("com.google.android.material:material:1.5.0-alpha04")
|
||||
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("com.github.tachiyomiorg:DirectionalViewPager:1.0.0") {
|
||||
exclude(group = "androidx.viewpager", module = "viewpager")
|
||||
}
|
||||
implementation("dev.chrisbanes.insetter:insetter:0.6.0")
|
||||
|
||||
// Conductor
|
||||
@ -256,8 +251,20 @@ dependencies {
|
||||
implementation("io.github.reactivecircus.flowbinding:flowbinding-swiperefreshlayout:$flowbindingVersion")
|
||||
implementation("io.github.reactivecircus.flowbinding:flowbinding-viewpager:$flowbindingVersion")
|
||||
|
||||
// Logging
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
|
||||
// Crash reports/analytics
|
||||
implementation("ch.acra:acra-http:5.8.1")
|
||||
"standardImplementation"("com.google.firebase:firebase-analytics:19.0.1")
|
||||
|
||||
// Licenses
|
||||
implementation("com.mikepenz:aboutlibraries:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
|
||||
implementation("com.mikepenz:aboutlibraries-core:${BuildPluginsVersion.ABOUTLIB_PLUGIN}")
|
||||
|
||||
// Shizuku
|
||||
val shizukuVersion = "12.0.0"
|
||||
implementation("dev.rikka.shizuku:api:$shizukuVersion")
|
||||
implementation("dev.rikka.shizuku:provider:$shizukuVersion")
|
||||
|
||||
// Tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
@ -18,6 +18,7 @@
|
||||
<!-- For managing extensions -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<!-- To view extension packages in API 30+ -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
|
||||
@ -188,6 +189,9 @@
|
||||
android:name=".data.backup.BackupRestoreService"
|
||||
android:exported="false" />
|
||||
|
||||
<service android:name=".extension.util.ExtensionInstallService"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
@ -198,6 +202,19 @@
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<meta-data android:name="android.webkit.WebView.EnableSafeBrowsing"
|
||||
android:value="false" />
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut"
|
||||
android:value="true" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@ -0,0 +1,100 @@
|
||||
package com.google.android.material.appbar
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.marginTop
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.findChild
|
||||
import eu.kanade.tachiyomi.widget.ElevationAppBarLayout
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Hide toolbar on scroll behavior for [AppBarLayout].
|
||||
*
|
||||
* Inside this package to access some package-private methods.
|
||||
*/
|
||||
class HideToolbarOnScrollBehavior : AppBarLayout.Behavior() {
|
||||
|
||||
@ViewCompat.NestedScrollType
|
||||
private var lastStartedType: Int = 0
|
||||
|
||||
private var offsetAnimator: ValueAnimator? = null
|
||||
|
||||
private var toolbarHeight: Int = 0
|
||||
|
||||
override fun onStartNestedScroll(
|
||||
parent: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
directTargetChild: View,
|
||||
target: View,
|
||||
nestedScrollAxes: Int,
|
||||
type: Int
|
||||
): Boolean {
|
||||
lastStartedType = type
|
||||
offsetAnimator?.cancel()
|
||||
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
|
||||
}
|
||||
|
||||
override fun onStopNestedScroll(
|
||||
parent: CoordinatorLayout,
|
||||
layout: AppBarLayout,
|
||||
target: View,
|
||||
type: Int
|
||||
) {
|
||||
super.onStopNestedScroll(parent, layout, target, type)
|
||||
if (toolbarHeight == 0) {
|
||||
toolbarHeight = layout.findChild<Toolbar>()?.height ?: 0
|
||||
}
|
||||
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
|
||||
animateToolbarVisibility(
|
||||
parent,
|
||||
layout,
|
||||
getTopBottomOffsetForScrollingSibling(layout) > -toolbarHeight / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFlingFinished(parent: CoordinatorLayout, layout: AppBarLayout) {
|
||||
super.onFlingFinished(parent, layout)
|
||||
animateToolbarVisibility(
|
||||
parent,
|
||||
layout,
|
||||
getTopBottomOffsetForScrollingSibling(layout) > -toolbarHeight / 2
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTopBottomOffsetForScrollingSibling(abl: AppBarLayout): Int {
|
||||
return topBottomOffsetForScrollingSibling - abl.marginTop
|
||||
}
|
||||
|
||||
private fun animateToolbarVisibility(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
isVisible: Boolean
|
||||
) {
|
||||
val current = getTopBottomOffsetForScrollingSibling(child)
|
||||
val target = if (isVisible) 0 else -toolbarHeight
|
||||
if (current == target) return
|
||||
|
||||
offsetAnimator?.cancel()
|
||||
offsetAnimator = ValueAnimator().apply {
|
||||
interpolator = DecelerateInterpolator()
|
||||
duration = (150 * child.context.animatorDurationScale).roundToLong()
|
||||
addUpdateListener {
|
||||
setHeaderTopBottomOffset(coordinatorLayout, child, it.animatedValue as Int)
|
||||
}
|
||||
doOnEnd {
|
||||
if ((child as? ElevationAppBarLayout)?.isTransparentWhenNotLifted == true) {
|
||||
child.isLifted = !isVisible
|
||||
}
|
||||
}
|
||||
setIntValues(current, target)
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
@ -12,9 +12,8 @@ import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import coil.ImageLoader
|
||||
@ -30,6 +29,8 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.system.notification
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@ -43,14 +44,14 @@ import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.security.Security
|
||||
|
||||
open class App : Application(), LifecycleObserver, ImageLoaderFactory {
|
||||
open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
private val disableIncognitoReceiver = DisableIncognitoReceiver()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
super<Application>.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
|
||||
// TLS 1.3 support for Android < 10
|
||||
@ -114,25 +115,23 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this).apply {
|
||||
componentRegistry {
|
||||
add(TachiyomiImageDecoder(this@App.resources))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(ImageDecoderDecoder(this@App))
|
||||
} else {
|
||||
add(GifDecoder())
|
||||
}
|
||||
add(TachiyomiImageDecoder(this@App.resources))
|
||||
add(ByteBufferFetcher())
|
||||
add(MangaCoverFetcher())
|
||||
}
|
||||
okHttpClient(Injekt.get<NetworkHelper>().coilClient)
|
||||
crossfade(300)
|
||||
crossfade((300 * this@App.animatorDurationScale).toInt())
|
||||
allowRgb565(getSystemService<ActivityManager>()!!.isLowRamDevice)
|
||||
}.build()
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
|
||||
@Suppress("unused")
|
||||
fun onAppBackgrounded() {
|
||||
if (preferences.lockAppAfter().get() >= 0) {
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (!AuthenticatorUtil.isAuthenticating && preferences.lockAppAfter().get() >= 0) {
|
||||
SecureActivityDelegate.locked = true
|
||||
}
|
||||
}
|
||||
@ -176,8 +175,6 @@ open class App : Application(), LifecycleObserver, ImageLoaderFactory {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE"
|
||||
}
|
||||
}
|
||||
|
||||
private const val ACTION_DISABLE_INCOGNITO_MODE = "tachi.action.DISABLE_INCOGNITO_MODE"
|
||||
|
@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.job.DelayedTrackingStore
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
@ -23,6 +24,8 @@ class AppModule(val app: Application) : InjektModule {
|
||||
override fun InjektRegistrar.registerInjectables() {
|
||||
addSingleton(app)
|
||||
|
||||
addSingletonFactory { Json { ignoreUnknownKeys = true } }
|
||||
|
||||
addSingletonFactory { PreferencesHelper(app) }
|
||||
|
||||
addSingletonFactory { DatabaseHelper(app) }
|
||||
@ -41,7 +44,7 @@ class AppModule(val app: Application) : InjektModule {
|
||||
|
||||
addSingletonFactory { TrackManager(app) }
|
||||
|
||||
addSingletonFactory { Json { ignoreUnknownKeys = true } }
|
||||
addSingletonFactory { DelayedTrackingStore(app) }
|
||||
|
||||
// Asynchronously init expensive components for a faster cold start
|
||||
ContextCompat.getMainExecutor(app).execute {
|
||||
|
@ -32,23 +32,20 @@ object Migrations {
|
||||
fun upgrade(preferences: PreferencesHelper): Boolean {
|
||||
val context = preferences.context
|
||||
|
||||
// Cancel app updater job for debug builds that don't include it
|
||||
if (BuildConfig.DEBUG && !BuildConfig.INCLUDE_UPDATER) {
|
||||
UpdaterJob.cancelTask(context)
|
||||
}
|
||||
|
||||
val oldVersion = preferences.lastVersionCode().get()
|
||||
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||
preferences.lastVersionCode().set(BuildConfig.VERSION_CODE)
|
||||
|
||||
// Always set up background tasks to ensure they're running
|
||||
if (BuildConfig.INCLUDE_UPDATER) {
|
||||
UpdaterJob.setupTask(context)
|
||||
}
|
||||
ExtensionUpdateJob.setupTask(context)
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
BackupCreatorJob.setupTask(context)
|
||||
|
||||
// Fresh install
|
||||
if (oldVersion == 0) {
|
||||
// Set up default background tasks
|
||||
if (BuildConfig.INCLUDE_UPDATER) {
|
||||
UpdaterJob.setupTask(context)
|
||||
}
|
||||
ExtensionUpdateJob.setupTask(context)
|
||||
LibraryUpdateJob.setupTask(context)
|
||||
return false
|
||||
}
|
||||
|
||||
@ -232,11 +229,7 @@ object Migrations {
|
||||
putString(PreferenceKeys.librarySortingDirection, newSortingDirection.name)
|
||||
}
|
||||
}
|
||||
if (oldVersion < 65) {
|
||||
if (preferences.lang().get() in listOf("en-US", "en-GB")) {
|
||||
preferences.lang().set("en")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.annotations
|
||||
|
||||
// TODO: remove this when no longer used in extensions
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class Nsfw
|
||||
|
@ -53,6 +53,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
||||
backup = Backup(
|
||||
backupManga(databaseManga, flags),
|
||||
backupCategories(),
|
||||
emptyList(),
|
||||
backupExtensionInfo(databaseManga)
|
||||
)
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory
|
||||
import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory
|
||||
import eu.kanade.tachiyomi.data.backup.full.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.full.models.BackupSource
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
@ -33,7 +34,8 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBa
|
||||
}
|
||||
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap()
|
||||
var backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources
|
||||
sourceMapping = backupMaps.map { it.sourceId to it.name }.toMap()
|
||||
|
||||
// Restore individual manga
|
||||
backup.backupManga.forEach {
|
||||
@ -62,7 +64,7 @@ class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBa
|
||||
val manga = backupManga.getMangaImpl()
|
||||
val chapters = backupManga.getChaptersImpl()
|
||||
val categories = backupManga.categories
|
||||
val history = backupManga.history
|
||||
val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history
|
||||
val tracks = backupManga.getTrackingImpl()
|
||||
|
||||
try {
|
||||
|
@ -8,5 +8,6 @@ data class Backup(
|
||||
@ProtoNumber(1) val backupManga: List<BackupManga>,
|
||||
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
|
||||
@ProtoNumber(100) var backupBrokenSources: List<BrokenBackupSource> = emptyList(),
|
||||
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList()
|
||||
)
|
||||
|
@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@Serializable
|
||||
data class BackupHistory(
|
||||
data class BrokenBackupHistory(
|
||||
@ProtoNumber(0) var url: String,
|
||||
@ProtoNumber(1) var lastRead: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupHistory(
|
||||
@ProtoNumber(1) var url: String,
|
||||
@ProtoNumber(2) var lastRead: Long
|
||||
)
|
||||
|
@ -33,8 +33,9 @@ data class BackupManga(
|
||||
// Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x
|
||||
@ProtoNumber(100) var favorite: Boolean = true,
|
||||
@ProtoNumber(101) var chapterFlags: Int = 0,
|
||||
@ProtoNumber(102) var history: List<BackupHistory> = emptyList(),
|
||||
@ProtoNumber(103) var viewer_flags: Int? = null
|
||||
@ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(),
|
||||
@ProtoNumber(103) var viewer_flags: Int? = null,
|
||||
@ProtoNumber(104) var history: List<BackupHistory> = emptyList()
|
||||
) {
|
||||
fun getMangaImpl(): MangaImpl {
|
||||
return MangaImpl().apply {
|
||||
|
@ -5,9 +5,15 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@Serializable
|
||||
data class BackupSource(
|
||||
data class BrokenBackupSource(
|
||||
@ProtoNumber(0) var name: String = "",
|
||||
@ProtoNumber(1) var sourceId: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupSource(
|
||||
@ProtoNumber(1) var name: String = "",
|
||||
@ProtoNumber(2) var sourceId: Long
|
||||
) {
|
||||
companion object {
|
||||
fun copyFrom(source: Source): BackupSource {
|
||||
|
@ -32,8 +32,7 @@ data class BackupTracking(
|
||||
media_id = this@BackupTracking.mediaId
|
||||
library_id = this@BackupTracking.libraryId
|
||||
title = this@BackupTracking.title
|
||||
// convert from float to int because of 1.x types
|
||||
last_chapter_read = this@BackupTracking.lastChapterRead.toInt()
|
||||
last_chapter_read = this@BackupTracking.lastChapterRead
|
||||
total_chapters = this@BackupTracking.totalChapters
|
||||
score = this@BackupTracking.score
|
||||
status = this@BackupTracking.status
|
||||
@ -51,8 +50,7 @@ data class BackupTracking(
|
||||
// forced not null so its compatible with 1.x backup system
|
||||
libraryId = track.library_id!!,
|
||||
title = track.title,
|
||||
// convert to float for 1.x
|
||||
lastChapterRead = track.last_chapter_read.toFloat(),
|
||||
lastChapterRead = track.last_chapter_read,
|
||||
totalChapters = track.total_chapters,
|
||||
score = track.score,
|
||||
status = track.status,
|
||||
|
@ -13,13 +13,12 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.util.Date
|
||||
|
||||
@ -28,8 +27,8 @@ class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : Abstract
|
||||
override suspend fun performRestore(uri: Uri): Boolean {
|
||||
// Read the json and create a Json Object,
|
||||
// cannot use the backupManager json deserializer one because its not initialized yet
|
||||
val backupObject = Json.decodeFromString<JsonObject>(
|
||||
context.contentResolver.openInputStream(uri)!!.source().buffer().use { it.readUtf8() }
|
||||
val backupObject = Json.decodeFromStream<JsonObject>(
|
||||
context.contentResolver.openInputStream(uri)!!
|
||||
)
|
||||
|
||||
// Get parser version
|
||||
|
@ -5,9 +5,7 @@ import android.net.Uri
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator
|
||||
import eu.kanade.tachiyomi.data.backup.legacy.models.Backup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
|
||||
class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
||||
/**
|
||||
@ -19,8 +17,8 @@ class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() {
|
||||
override fun validate(context: Context, uri: Uri): Results {
|
||||
val backupManager = LegacyBackupManager(context)
|
||||
|
||||
val backup = backupManager.parser.decodeFromString<Backup>(
|
||||
context.contentResolver.openInputStream(uri)!!.source().buffer().use { it.readUtf8() }
|
||||
val backup = backupManager.parser.decodeFromStream<Backup>(
|
||||
context.contentResolver.openInputStream(uri)!!
|
||||
)
|
||||
|
||||
if (backup.version == null) {
|
||||
|
@ -10,6 +10,7 @@ import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@ -46,7 +47,7 @@ open class TrackBaseSerializer<T : Track> : KSerializer<T> {
|
||||
sync_id = jsonObject[SYNC]!!.jsonPrimitive.int
|
||||
media_id = jsonObject[MEDIA]!!.jsonPrimitive.int
|
||||
library_id = jsonObject[LIBRARY]!!.jsonPrimitive.long
|
||||
last_chapter_read = jsonObject[LAST_READ]!!.jsonPrimitive.int
|
||||
last_chapter_read = jsonObject[LAST_READ]!!.jsonPrimitive.float
|
||||
tracking_url = jsonObject[TRACKING_URL]!!.jsonPrimitive.content
|
||||
} as T
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import coil.network.HttpException
|
||||
import coil.request.get
|
||||
import coil.size.Size
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher.Companion.USE_CUSTOM_COVER
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
|
@ -20,7 +20,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
/**
|
||||
* Version of the database.
|
||||
*/
|
||||
const val DATABASE_VERSION = 12
|
||||
const val DATABASE_VERSION = 13
|
||||
}
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
|
||||
@ -85,6 +85,12 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
if (oldVersion < 12) {
|
||||
db.execSQL(MangaTable.addNextUpdateCol)
|
||||
}
|
||||
if (oldVersion < 13) {
|
||||
db.execSQL(TrackTable.renameTableToTemp)
|
||||
db.execSQL(TrackTable.createTableQuery)
|
||||
db.execSQL(TrackTable.insertFromTempTable)
|
||||
db.execSQL(TrackTable.dropTempTable)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SupportSQLiteDatabase) {
|
||||
|
@ -71,7 +71,7 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
|
||||
media_id = cursor.getInt(cursor.getColumnIndex(COL_MEDIA_ID))
|
||||
library_id = cursor.getLong(cursor.getColumnIndex(COL_LIBRARY_ID))
|
||||
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
|
||||
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
|
||||
last_chapter_read = cursor.getFloat(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
|
||||
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
|
||||
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
|
||||
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
|
||||
|
@ -1,5 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
|
||||
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
|
||||
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
|
||||
@ -37,6 +39,6 @@ interface Category : Serializable {
|
||||
this.name = name
|
||||
}
|
||||
|
||||
fun createDefault(): Category = create("Default").apply { id = 0 }
|
||||
fun createDefault(context: Context): Category = create(context.getString(R.string.label_default)).apply { id = 0 }
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,8 @@ interface Manga : SManga {
|
||||
}
|
||||
|
||||
fun getGenres(): List<String>? {
|
||||
return genre?.split(", ")?.map { it.trim() }
|
||||
if (genre.isNullOrBlank()) return null
|
||||
return genre?.split(", ")?.map { it.trim() }?.filterNot { it.isBlank() }?.distinct()
|
||||
}
|
||||
|
||||
private fun setChapterFlags(flag: Int, mask: Int) {
|
||||
|
@ -16,7 +16,7 @@ interface Track : Serializable {
|
||||
|
||||
var title: String
|
||||
|
||||
var last_chapter_read: Int
|
||||
var last_chapter_read: Float
|
||||
|
||||
var total_chapters: Int
|
||||
|
||||
|
@ -14,7 +14,7 @@ class TrackImpl : Track {
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Int = 0
|
||||
override var last_chapter_read: Float = 0F
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
|
@ -7,7 +7,13 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.*
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaCoverLastModifiedPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaNextUpdatedPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
|
||||
@ -75,7 +81,7 @@ interface MangaQueries : DbProvider {
|
||||
|
||||
fun updateChapterFlags(manga: List<Manga>) = db.put()
|
||||
.objects(manga)
|
||||
.withPutResolver(MangaFlagsPutResolver(MangaTable.COL_CHAPTER_FLAGS, Manga::chapter_flags, true))
|
||||
.withPutResolver(MangaFlagsPutResolver(MangaTable.COL_CHAPTER_FLAGS, Manga::chapter_flags))
|
||||
.prepare()
|
||||
|
||||
fun updateViewerFlags(manga: Manga) = db.put()
|
||||
@ -85,7 +91,7 @@ interface MangaQueries : DbProvider {
|
||||
|
||||
fun updateViewerFlags(manga: List<Manga>) = db.put()
|
||||
.objects(manga)
|
||||
.withPutResolver(MangaFlagsPutResolver(MangaTable.COL_VIEWER, Manga::viewer_flags, true))
|
||||
.withPutResolver(MangaFlagsPutResolver(MangaTable.COL_VIEWER, Manga::viewer_flags))
|
||||
.prepare()
|
||||
|
||||
fun updateNextUpdated(manga: Manga) = db.put()
|
||||
|
@ -27,9 +27,7 @@ class HistoryLastReadPutResolver : HistoryPutResolver() {
|
||||
.build()
|
||||
)
|
||||
|
||||
val putResult: PutResult
|
||||
|
||||
putResult = cursor.use { putCursor ->
|
||||
cursor.use { putCursor ->
|
||||
if (putCursor.count == 0) {
|
||||
val insertQuery = mapToInsertQuery(history)
|
||||
val insertedId = db.lowLevel().insert(insertQuery, mapToContentValues(history))
|
||||
@ -39,25 +37,15 @@ class HistoryLastReadPutResolver : HistoryPutResolver() {
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
}
|
||||
|
||||
putResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates update query
|
||||
* @param obj history object
|
||||
*/
|
||||
override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder()
|
||||
.table(HistoryTable.TABLE)
|
||||
.where("${HistoryTable.COL_CHAPTER_ID} = ?")
|
||||
.whereArgs(obj.chapter_id)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Create content query
|
||||
* @param history object
|
||||
*/
|
||||
fun mapToUpdateContentValues(history: History) =
|
||||
private fun mapToUpdateContentValues(history: History) =
|
||||
contentValuesOf(
|
||||
HistoryTable.COL_LAST_READ to history.last_read
|
||||
)
|
||||
|
@ -10,7 +10,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
class MangaFlagsPutResolver(private val colName: String, private val fieldGetter: KProperty1<Manga, Int>, private val updateAll: Boolean = false) : PutResolver<Manga>() {
|
||||
class MangaFlagsPutResolver(private val colName: String, private val fieldGetter: KProperty1<Manga, Int>) : PutResolver<Manga>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(manga)
|
||||
@ -20,21 +20,11 @@ class MangaFlagsPutResolver(private val colName: String, private val fieldGetter
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(manga: Manga): UpdateQuery {
|
||||
val builder = UpdateQuery.builder()
|
||||
|
||||
return if (updateAll) {
|
||||
builder
|
||||
.table(MangaTable.TABLE)
|
||||
.build()
|
||||
} else {
|
||||
builder
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(manga: Manga) =
|
||||
contentValuesOf(
|
||||
|
@ -1,6 +1,6 @@
|
||||
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.operations.put.PutResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||
@ -25,7 +25,7 @@ class MangaNextUpdatedPutResolver : PutResolver<Manga>() {
|
||||
.whereArgs(manga.id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||
put(MangaTable.COL_NEXT_UPDATE, manga.next_update)
|
||||
}
|
||||
fun mapToContentValues(manga: Manga) = contentValuesOf(
|
||||
MangaTable.COL_NEXT_UPDATE to manga.next_update
|
||||
)
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ object TrackTable {
|
||||
$COL_MEDIA_ID INTEGER NOT NULL,
|
||||
$COL_LIBRARY_ID INTEGER,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_LAST_CHAPTER_READ INTEGER NOT NULL,
|
||||
$COL_LAST_CHAPTER_READ REAL NOT NULL,
|
||||
$COL_TOTAL_CHAPTERS INTEGER NOT NULL,
|
||||
$COL_STATUS INTEGER NOT NULL,
|
||||
$COL_SCORE FLOAT NOT NULL,
|
||||
@ -62,4 +62,19 @@ object TrackTable {
|
||||
|
||||
val addFinishDate: String
|
||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0"
|
||||
|
||||
val renameTableToTemp: String
|
||||
get() =
|
||||
"ALTER TABLE $TABLE RENAME TO ${TABLE}_tmp"
|
||||
|
||||
val insertFromTempTable: String
|
||||
get() =
|
||||
"""
|
||||
|INSERT INTO $TABLE($COL_ID,$COL_MANGA_ID,$COL_SYNC_ID,$COL_MEDIA_ID,$COL_LIBRARY_ID,$COL_TITLE,$COL_LAST_CHAPTER_READ,$COL_TOTAL_CHAPTERS,$COL_STATUS,$COL_SCORE,$COL_TRACKING_URL,$COL_START_DATE,$COL_FINISH_DATE)
|
||||
|SELECT $COL_ID,$COL_MANGA_ID,$COL_SYNC_ID,$COL_MEDIA_ID,$COL_LIBRARY_ID,$COL_TITLE,$COL_LAST_CHAPTER_READ,$COL_TOTAL_CHAPTERS,$COL_STATUS,$COL_SCORE,$COL_TRACKING_URL,$COL_START_DATE,$COL_FINISH_DATE
|
||||
|FROM ${TABLE}_tmp
|
||||
""".trimMargin()
|
||||
|
||||
val dropTempTable: String
|
||||
get() = "DROP TABLE ${TABLE}_tmp"
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.download.model.Download
|
||||
@ -15,6 +16,8 @@ import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
@ -24,7 +27,10 @@ import uy.kohesive.injekt.injectLazy
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
class DownloadManager(private val context: Context) {
|
||||
class DownloadManager(
|
||||
private val context: Context,
|
||||
private val db: DatabaseHelper = Injekt.get()
|
||||
) {
|
||||
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
@ -217,7 +223,7 @@ class DownloadManager(private val context: Context) {
|
||||
* @param download the download to cancel.
|
||||
*/
|
||||
fun deletePendingDownload(download: Download) {
|
||||
deleteChapters(listOf(download.chapter), download.manga, download.source)
|
||||
deleteChapters(listOf(download.chapter), download.manga, download.source, true)
|
||||
}
|
||||
|
||||
fun deletePendingDownloads(vararg downloads: Download) {
|
||||
@ -225,7 +231,7 @@ class DownloadManager(private val context: Context) {
|
||||
downloadsByManga.map { entry ->
|
||||
val manga = entry.value.first().manga
|
||||
val source = entry.value.first().source
|
||||
deleteChapters(entry.value.map { it.chapter }, manga, source)
|
||||
deleteChapters(entry.value.map { it.chapter }, manga, source, true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,9 +241,15 @@ class DownloadManager(private val context: Context) {
|
||||
* @param chapters the list of chapters to delete.
|
||||
* @param manga the manga of the chapters.
|
||||
* @param source the source of the chapters.
|
||||
* @param isCancelling true if it's simply cancelling a download
|
||||
*/
|
||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source): List<Chapter> {
|
||||
val filteredChapters = getChaptersToDelete(chapters)
|
||||
fun deleteChapters(chapters: List<Chapter>, manga: Manga, source: Source, isCancelling: Boolean = false): List<Chapter> {
|
||||
val filteredChapters = if (isCancelling) {
|
||||
chapters
|
||||
} else {
|
||||
getChaptersToDelete(chapters, manga)
|
||||
}
|
||||
|
||||
launchIO {
|
||||
removeFromDownloadQueue(filteredChapters)
|
||||
|
||||
@ -290,7 +302,7 @@ class DownloadManager(private val context: Context) {
|
||||
* @param manga the manga of the chapters.
|
||||
*/
|
||||
fun enqueueDeleteChapters(chapters: List<Chapter>, manga: Manga) {
|
||||
pendingDeleter.addChapters(getChaptersToDelete(chapters), manga)
|
||||
pendingDeleter.addChapters(getChaptersToDelete(chapters, manga), manga)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -330,8 +342,17 @@ class DownloadManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChaptersToDelete(chapters: List<Chapter>): List<Chapter> {
|
||||
return if (!preferences.removeBookmarkedChapters()) {
|
||||
private fun getChaptersToDelete(chapters: List<Chapter>, manga: Manga): List<Chapter> {
|
||||
// Retrieve the categories that are set to exclude from being deleted on read
|
||||
val categoriesToExclude = preferences.removeExcludeCategories().get().map(String::toInt)
|
||||
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
.mapNotNull { it.id }
|
||||
.takeUnless { it.isEmpty() }
|
||||
?: listOf(0)
|
||||
|
||||
return if (categoriesForManga.intersect(categoriesToExclude).isNotEmpty()) {
|
||||
chapters.filterNot { it.read }
|
||||
} else if (!preferences.removeBookmarkedChapters()) {
|
||||
chapters.filterNot { it.bookmark }
|
||||
} else {
|
||||
chapters
|
||||
|
@ -52,7 +52,7 @@ internal class DownloadNotifier(private val context: Context) {
|
||||
/**
|
||||
* Updated when error is thrown
|
||||
*/
|
||||
var errorThrown = false
|
||||
private var errorThrown = false
|
||||
|
||||
/**
|
||||
* Updated when paused
|
||||
|
@ -4,27 +4,32 @@ import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkInfo.State.CONNECTED
|
||||
import android.net.NetworkInfo.State.DISCONNECTED
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.lang.plusAssign
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
||||
import eu.kanade.tachiyomi.util.system.connectivityManager
|
||||
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
|
||||
import eu.kanade.tachiyomi.util.system.isOnline
|
||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||
import eu.kanade.tachiyomi.util.system.notification
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import ru.beryukhov.reactivenetwork.ReactiveNetwork
|
||||
import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
@ -80,16 +85,15 @@ class DownloadService : Service() {
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
/**
|
||||
* Subscriptions to store while the service is running.
|
||||
*/
|
||||
private lateinit var subscriptions: CompositeSubscription
|
||||
private lateinit var ioScope: CoroutineScope
|
||||
|
||||
/**
|
||||
* Called when the service is created.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
startForeground(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS, getPlaceholderNotification())
|
||||
wakeLock = acquireWakeLock(javaClass.name)
|
||||
runningRelay.call(true)
|
||||
@ -102,6 +106,7 @@ class DownloadService : Service() {
|
||||
* Called when the service is destroyed.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
ioScope?.cancel()
|
||||
runningRelay.call(false)
|
||||
subscriptions.unsubscribe()
|
||||
downloadManager.stopDownloads()
|
||||
@ -129,44 +134,43 @@ class DownloadService : Service() {
|
||||
* @see onNetworkStateChanged
|
||||
*/
|
||||
private fun listenNetworkChanges() {
|
||||
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ state ->
|
||||
onNetworkStateChanged(state)
|
||||
},
|
||||
{
|
||||
ReactiveNetwork()
|
||||
.observeNetworkConnectivity(applicationContext)
|
||||
.onEach {
|
||||
withUIContext {
|
||||
onNetworkStateChanged()
|
||||
}
|
||||
}
|
||||
.catch { error ->
|
||||
withUIContext {
|
||||
Timber.e(error)
|
||||
toast(R.string.download_queue_error)
|
||||
stopSelf()
|
||||
}
|
||||
)
|
||||
}
|
||||
.launchIn(ioScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the network state changes.
|
||||
*
|
||||
* @param connectivity the new network state.
|
||||
*/
|
||||
private fun onNetworkStateChanged(connectivity: Connectivity) {
|
||||
when (connectivity.state) {
|
||||
CONNECTED -> {
|
||||
if (preferences.downloadOnlyOverWifi() && connectivityManager.activeNetworkInfo?.type != ConnectivityManager.TYPE_WIFI) {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||
} else {
|
||||
val started = downloadManager.startDownloads()
|
||||
if (!started) stopSelf()
|
||||
}
|
||||
}
|
||||
DISCONNECTED -> {
|
||||
downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
|
||||
}
|
||||
else -> {
|
||||
/* Do nothing */
|
||||
private fun onNetworkStateChanged() {
|
||||
if (isOnline()) {
|
||||
if (preferences.downloadOnlyOverWifi() && !isConnectedToWifi()) {
|
||||
stopDownloads(R.string.download_notifier_text_only_wifi)
|
||||
} else {
|
||||
val started = downloadManager.startDownloads()
|
||||
if (!started) stopSelf()
|
||||
}
|
||||
} else {
|
||||
stopDownloads(R.string.download_notifier_no_network)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDownloads(@StringRes string: Int) {
|
||||
downloadManager.stopDownloads(getString(string))
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to downloader status. Enables or disables the wake lock depending on the status.
|
||||
*/
|
||||
|
@ -51,7 +51,7 @@ class LibraryUpdateNotifier(private val context: Context) {
|
||||
* Cached progress notification to avoid creating a lot.
|
||||
*/
|
||||
val progressNotificationBuilder by lazy {
|
||||
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
|
||||
context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) {
|
||||
setContentTitle(context.getString(R.string.app_name))
|
||||
setSmallIcon(R.drawable.ic_refresh_24dp)
|
||||
setLargeIcon(notificationBitmap)
|
||||
@ -101,7 +101,7 @@ class LibraryUpdateNotifier(private val context: Context) {
|
||||
|
||||
context.notificationManager.notify(
|
||||
Notifications.ID_LIBRARY_ERROR,
|
||||
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
|
||||
context.notificationBuilder(Notifications.CHANNEL_LIBRARY_ERROR) {
|
||||
setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_error, errors.size, errors.size))
|
||||
setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(
|
||||
|
@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.library
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import java.util.Collections
|
||||
import kotlin.Comparator
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
|
@ -294,48 +294,46 @@ class LibraryUpdateService(
|
||||
return@async
|
||||
}
|
||||
|
||||
currentlyUpdatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
progressCount,
|
||||
manga,
|
||||
) { manga ->
|
||||
try {
|
||||
val (newChapters, _) = updateManga(manga)
|
||||
|
||||
try {
|
||||
val (newChapters, _) = updateManga(manga)
|
||||
if (newChapters.isNotEmpty()) {
|
||||
if (manga.shouldDownloadNewChapters(db, preferences)) {
|
||||
downloadChapters(manga, newChapters)
|
||||
hasDownloads.set(true)
|
||||
}
|
||||
|
||||
if (newChapters.isNotEmpty()) {
|
||||
if (manga.shouldDownloadNewChapters(db, preferences)) {
|
||||
downloadChapters(manga, newChapters)
|
||||
hasDownloads.set(true)
|
||||
// Convert to the manga that contains new chapters
|
||||
newUpdates.add(
|
||||
manga to newChapters.sortedByDescending { ch -> ch.source_order }
|
||||
.toTypedArray()
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to the manga that contains new chapters
|
||||
newUpdates.add(manga to newChapters.sortedByDescending { ch -> ch.source_order }.toTypedArray())
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = when (e) {
|
||||
is NoChaptersException -> {
|
||||
getString(R.string.no_chapters_error)
|
||||
}
|
||||
is SourceManager.SourceNotInstalledException -> {
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
getString(R.string.loader_not_implemented_error)
|
||||
}
|
||||
else -> {
|
||||
e.message
|
||||
}
|
||||
}
|
||||
failedUpdates.add(manga to errorMessage)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = if (e is NoChaptersException) {
|
||||
getString(R.string.no_chapters_error)
|
||||
} else if (e is SourceManager.SourceNotInstalledException) {
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
getString(R.string.loader_not_implemented_error)
|
||||
} else {
|
||||
e.message
|
||||
|
||||
if (preferences.autoUpdateTrackers()) {
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
failedUpdates.add(manga to errorMessage)
|
||||
}
|
||||
|
||||
if (preferences.autoUpdateTrackers()) {
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
|
||||
currentlyUpdatingManga.remove(manga)
|
||||
progressCount.andIncrement
|
||||
notifier.showProgressNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -352,7 +350,7 @@ class LibraryUpdateService(
|
||||
}
|
||||
}
|
||||
|
||||
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
val errorFile = writeErrorFile(failedUpdates)
|
||||
notifier.showUpdateErrorNotification(
|
||||
failedUpdates.map { it.first.title },
|
||||
@ -418,36 +416,35 @@ class LibraryUpdateService(
|
||||
return@async
|
||||
}
|
||||
|
||||
currentlyUpdatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
|
||||
sourceManager.get(manga.source)?.let { source ->
|
||||
try {
|
||||
val networkManga =
|
||||
source.getMangaDetails(manga.toMangaInfo())
|
||||
val sManga = networkManga.toSManga()
|
||||
manga.prepUpdateCover(coverCache, sManga, true)
|
||||
sManga.thumbnail_url?.let {
|
||||
manga.thumbnail_url = it
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
progressCount,
|
||||
manga,
|
||||
) { manga ->
|
||||
sourceManager.get(manga.source)?.let { source ->
|
||||
try {
|
||||
val networkManga =
|
||||
source.getMangaDetails(manga.toMangaInfo())
|
||||
val sManga = networkManga.toSManga()
|
||||
manga.prepUpdateCover(coverCache, sManga, true)
|
||||
sManga.thumbnail_url?.let {
|
||||
manga.thumbnail_url = it
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
Timber.e(e)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
currentlyUpdatingManga.remove(manga)
|
||||
progressCount.andIncrement
|
||||
notifier.showProgressNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
currentlyUpdatingManga.remove(manga)
|
||||
progressCount.andIncrement
|
||||
notifier.showProgressNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -506,6 +503,38 @@ class LibraryUpdateService(
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingManga: CopyOnWriteArrayList<LibraryManga>,
|
||||
completed: AtomicInteger,
|
||||
manga: LibraryManga,
|
||||
block: suspend (LibraryManga) -> Unit,
|
||||
) {
|
||||
if (updateJob?.isActive != true) {
|
||||
return
|
||||
}
|
||||
|
||||
updatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
|
||||
block(manga)
|
||||
|
||||
if (updateJob?.isActive != true) {
|
||||
return
|
||||
}
|
||||
|
||||
updatingManga.remove(manga)
|
||||
completed.andIncrement
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes basic file of update errors to cache dir.
|
||||
*/
|
||||
|
@ -1,12 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.notification
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH
|
||||
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.system.notificationManager
|
||||
import eu.kanade.tachiyomi.util.system.buildNotificationChannel
|
||||
import eu.kanade.tachiyomi.util.system.buildNotificationChannelGroup
|
||||
|
||||
/**
|
||||
* Class to manage the basic information of all the notifications used in the app.
|
||||
@ -23,8 +24,10 @@ object Notifications {
|
||||
/**
|
||||
* Notification channel and ids used by the library updater.
|
||||
*/
|
||||
const val CHANNEL_LIBRARY = "library_channel"
|
||||
private const val GROUP_LIBRARY = "group_library"
|
||||
const val CHANNEL_LIBRARY_PROGRESS = "library_progress_channel"
|
||||
const val ID_LIBRARY_PROGRESS = -101
|
||||
const val CHANNEL_LIBRARY_ERROR = "library_errors_channel"
|
||||
const val ID_LIBRARY_ERROR = -102
|
||||
|
||||
/**
|
||||
@ -50,6 +53,7 @@ object Notifications {
|
||||
*/
|
||||
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
|
||||
const val ID_UPDATES_TO_EXTS = -401
|
||||
const val ID_EXTENSION_INSTALLER = -402
|
||||
|
||||
/**
|
||||
* Notification channel and ids used by the backup/restore system.
|
||||
@ -76,99 +80,90 @@ object Notifications {
|
||||
|
||||
private val deprecatedChannels = listOf(
|
||||
"downloader_channel",
|
||||
"backup_restore_complete_channel"
|
||||
"backup_restore_complete_channel",
|
||||
"library_channel",
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates the notification channels introduced in Android Oreo.
|
||||
* This won't do anything on Android versions that don't support notification channels.
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val notificationService = NotificationManagerCompat.from(context)
|
||||
|
||||
listOf(
|
||||
NotificationChannelGroup(GROUP_BACKUP_RESTORE, context.getString(R.string.group_backup_restore)),
|
||||
NotificationChannelGroup(GROUP_DOWNLOADER, context.getString(R.string.group_downloader))
|
||||
).forEach(context.notificationManager::createNotificationChannelGroup)
|
||||
|
||||
listOf(
|
||||
NotificationChannel(
|
||||
CHANNEL_COMMON,
|
||||
context.getString(R.string.channel_common),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
),
|
||||
NotificationChannel(
|
||||
CHANNEL_LIBRARY,
|
||||
context.getString(R.string.channel_library),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
setShowBadge(false)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_DOWNLOADER_PROGRESS,
|
||||
context.getString(R.string.channel_progress),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_DOWNLOADER
|
||||
setShowBadge(false)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_DOWNLOADER_COMPLETE,
|
||||
context.getString(R.string.channel_complete),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_DOWNLOADER
|
||||
setShowBadge(false)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_DOWNLOADER_ERROR,
|
||||
context.getString(R.string.channel_errors),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_DOWNLOADER
|
||||
setShowBadge(false)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_NEW_CHAPTERS,
|
||||
context.getString(R.string.channel_new_chapters),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
),
|
||||
NotificationChannel(
|
||||
CHANNEL_UPDATES_TO_EXTS,
|
||||
context.getString(R.string.channel_ext_updates),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
),
|
||||
NotificationChannel(
|
||||
CHANNEL_BACKUP_RESTORE_PROGRESS,
|
||||
context.getString(R.string.channel_progress),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
group = GROUP_BACKUP_RESTORE
|
||||
setShowBadge(false)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_BACKUP_RESTORE_COMPLETE,
|
||||
context.getString(R.string.channel_complete),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
group = GROUP_BACKUP_RESTORE
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
},
|
||||
NotificationChannel(
|
||||
CHANNEL_CRASH_LOGS,
|
||||
context.getString(R.string.channel_crash_logs),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
),
|
||||
NotificationChannel(
|
||||
CHANNEL_INCOGNITO_MODE,
|
||||
context.getString(R.string.pref_incognito_mode),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
notificationService.createNotificationChannelGroupsCompat(
|
||||
listOf(
|
||||
buildNotificationChannelGroup(GROUP_BACKUP_RESTORE) {
|
||||
setName(context.getString(R.string.label_backup))
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_DOWNLOADER) {
|
||||
setName(context.getString(R.string.download_notifier_downloader_title))
|
||||
},
|
||||
buildNotificationChannelGroup(GROUP_LIBRARY) {
|
||||
setName(context.getString(R.string.label_library))
|
||||
},
|
||||
)
|
||||
).forEach(context.notificationManager::createNotificationChannel)
|
||||
)
|
||||
|
||||
notificationService.createNotificationChannelsCompat(
|
||||
listOf(
|
||||
buildNotificationChannel(CHANNEL_COMMON, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_common))
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_progress))
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_LIBRARY_ERROR, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_errors))
|
||||
setGroup(GROUP_LIBRARY)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_NEW_CHAPTERS, IMPORTANCE_DEFAULT) {
|
||||
setName(context.getString(R.string.channel_new_chapters))
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_progress))
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_COMPLETE, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_complete))
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_DOWNLOADER_ERROR, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_errors))
|
||||
setGroup(GROUP_DOWNLOADER)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_PROGRESS, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.channel_progress))
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_BACKUP_RESTORE_COMPLETE, IMPORTANCE_HIGH) {
|
||||
setName(context.getString(R.string.channel_complete))
|
||||
setGroup(GROUP_BACKUP_RESTORE)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_CRASH_LOGS, IMPORTANCE_HIGH) {
|
||||
setName(context.getString(R.string.channel_crash_logs))
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) {
|
||||
setName(context.getString(R.string.pref_incognito_mode))
|
||||
},
|
||||
buildNotificationChannel(CHANNEL_UPDATES_TO_EXTS, IMPORTANCE_DEFAULT) {
|
||||
setName(context.getString(R.string.channel_ext_updates))
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
// Delete old notification channels
|
||||
deprecatedChannels.forEach(context.notificationManager::deleteNotificationChannel)
|
||||
deprecatedChannels.forEach(notificationService::deleteNotificationChannel)
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,8 @@ object PreferenceKeys {
|
||||
|
||||
const val grayscale = "pref_grayscale"
|
||||
|
||||
const val invertedColors = "pref_inverted_colors"
|
||||
|
||||
const val defaultReadingMode = "pref_default_reading_mode_key"
|
||||
|
||||
const val defaultOrientationType = "pref_default_orientation_type_key"
|
||||
@ -87,6 +89,8 @@ object PreferenceKeys {
|
||||
|
||||
const val showNavigationOverlayOnStart = "reader_navigation_overlay_on_start"
|
||||
|
||||
const val readerHideThreshold = "reader_hide_threshold"
|
||||
|
||||
const val webtoonSidePadding = "webtoon_side_padding"
|
||||
|
||||
const val portraitColumns = "pref_library_columns_portrait_key"
|
||||
@ -147,11 +151,12 @@ object PreferenceKeys {
|
||||
const val librarySortingMode = "library_sorting_mode"
|
||||
const val librarySortingDirection = "library_sorting_ascending"
|
||||
|
||||
const val migrationSortingMode = "pref_migration_sorting"
|
||||
const val migrationSortingDirection = "pref_migration_direction"
|
||||
|
||||
const val automaticExtUpdates = "automatic_ext_updates"
|
||||
|
||||
const val showNsfwSource = "show_nsfw_source"
|
||||
const val showNsfwExtension = "show_nsfw_extension"
|
||||
const val labelNsfwExtension = "label_nsfw_extension"
|
||||
|
||||
const val startScreen = "start_screen"
|
||||
|
||||
@ -169,17 +174,15 @@ object PreferenceKeys {
|
||||
|
||||
const val autoUpdateTrackers = "auto_update_trackers"
|
||||
|
||||
const val showLibraryUpdateErrors = "show_library_update_errors"
|
||||
|
||||
const val downloadNew = "download_new"
|
||||
|
||||
const val downloadNewCategories = "download_new_categories"
|
||||
const val downloadNewCategoriesExclude = "download_new_categories_exclude"
|
||||
const val removeExcludeCategories = "remove_exclude_categories"
|
||||
|
||||
const val libraryDisplayMode = "pref_display_mode_library"
|
||||
|
||||
const val lang = "app_language"
|
||||
|
||||
const val relativeTime: String = "relative_time"
|
||||
const val dateFormat = "app_date_format"
|
||||
|
||||
const val defaultCategory = "default_category"
|
||||
@ -220,6 +223,10 @@ object PreferenceKeys {
|
||||
|
||||
const val incognitoMode = "incognito_mode"
|
||||
|
||||
const val tabletUiMode = "tablet_ui_mode"
|
||||
|
||||
const val extensionInstaller = "extension_installer"
|
||||
|
||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||
|
||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||
|
@ -22,15 +22,16 @@ object PreferenceValues {
|
||||
/* ktlint-enable experimental:enum-entry-name-case */
|
||||
|
||||
enum class AppTheme(val titleResId: Int?) {
|
||||
DEFAULT(R.string.theme_default),
|
||||
DEFAULT(R.string.label_default),
|
||||
MONET(R.string.theme_monet),
|
||||
BLUE(R.string.theme_blue),
|
||||
GREEN_APPLE(R.string.theme_greenapple),
|
||||
MIDNIGHT_DUSK(R.string.theme_midnightdusk),
|
||||
STRAWBERRY_DAIQUIRI(R.string.theme_strawberrydaiquiri),
|
||||
TAKO(R.string.theme_tako),
|
||||
YINYANG(R.string.theme_yinyang),
|
||||
YOTSUBA(R.string.theme_yotsuba),
|
||||
TAKO(R.string.theme_tako),
|
||||
GREEN_APPLE(R.string.theme_greenapple),
|
||||
TEALTURQUOISE(R.string.theme_tealturquoise),
|
||||
YINYANG(R.string.theme_yinyang),
|
||||
BLUE(R.string.theme_blue),
|
||||
|
||||
// Deprecated
|
||||
DARK_BLUE(null),
|
||||
@ -43,4 +44,23 @@ object PreferenceValues {
|
||||
VERTICAL(shouldInvertVertical = true),
|
||||
BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true),
|
||||
}
|
||||
|
||||
enum class ReaderHideThreshold(val threshold: Int) {
|
||||
HIGHEST(5),
|
||||
HIGH(13),
|
||||
LOW(31),
|
||||
LOWEST(47),
|
||||
}
|
||||
|
||||
enum class TabletUiMode {
|
||||
ALWAYS,
|
||||
LANDSCAPE,
|
||||
NEVER,
|
||||
}
|
||||
|
||||
enum class ExtensionInstaller {
|
||||
LEGACY,
|
||||
PACKAGEINSTALLER,
|
||||
SHIZUKU
|
||||
}
|
||||
}
|
||||
|
@ -9,14 +9,17 @@ import com.tfcporciuncula.flow.FlowSharedPreferences
|
||||
import com.tfcporciuncula.flow.Preference
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues.ThemeMode.*
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues.ThemeMode.system
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.sources.MigrationSourcesController
|
||||
import eu.kanade.tachiyomi.ui.library.setting.DisplayModeSetting
|
||||
import eu.kanade.tachiyomi.ui.library.setting.SortDirectionSetting
|
||||
import eu.kanade.tachiyomi.ui.library.setting.SortModeSetting
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.util.system.MiuiUtil
|
||||
import eu.kanade.tachiyomi.util.system.isTablet
|
||||
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@ -85,8 +88,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun autoUpdateTrackers() = prefs.getBoolean(Keys.autoUpdateTrackers, false)
|
||||
|
||||
fun showLibraryUpdateErrors() = prefs.getBoolean(Keys.showLibraryUpdateErrors, true)
|
||||
|
||||
fun themeMode() = flowPrefs.getEnum(Keys.themeMode, system)
|
||||
|
||||
fun appTheme() = flowPrefs.getEnum(Keys.appTheme, Values.AppTheme.DEFAULT)
|
||||
@ -129,6 +130,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun grayscale() = flowPrefs.getBoolean(Keys.grayscale, false)
|
||||
|
||||
fun invertedColors() = flowPrefs.getBoolean(Keys.invertedColors, false)
|
||||
|
||||
fun defaultReadingMode() = prefs.getInt(Keys.defaultReadingMode, ReadingModeType.RIGHT_TO_LEFT.flagValue)
|
||||
|
||||
fun defaultOrientationType() = prefs.getInt(Keys.defaultOrientationType, OrientationType.FREE.flagValue)
|
||||
@ -167,6 +170,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun showNavigationOverlayOnStart() = flowPrefs.getBoolean(Keys.showNavigationOverlayOnStart, false)
|
||||
|
||||
fun readerHideTreshold() = flowPrefs.getEnum(Keys.readerHideThreshold, Values.ReaderHideThreshold.LOW)
|
||||
|
||||
fun portraitColumns() = flowPrefs.getInt(Keys.portraitColumns, 0)
|
||||
|
||||
fun landscapeColumns() = flowPrefs.getInt(Keys.landscapeColumns, 0)
|
||||
@ -185,7 +190,7 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun sourceDisplayMode() = flowPrefs.getEnum(Keys.sourceDisplayMode, DisplayModeSetting.COMPACT_GRID)
|
||||
|
||||
fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("en", Locale.getDefault().language))
|
||||
fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("all", "en", Locale.getDefault().language))
|
||||
|
||||
fun trackUsername(sync: TrackService) = prefs.getString(Keys.trackUsername(sync.id), "")
|
||||
|
||||
@ -204,6 +209,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun backupsDirectory() = flowPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString())
|
||||
|
||||
fun relativeTime() = flowPrefs.getInt(Keys.relativeTime, 7)
|
||||
|
||||
fun dateFormat(format: String = flowPrefs.getString(Keys.dateFormat, "").get()): DateFormat = when (format) {
|
||||
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
@ -225,6 +232,8 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun removeBookmarkedChapters() = prefs.getBoolean(Keys.removeBookmarkedChapters, false)
|
||||
|
||||
fun removeExcludeCategories() = flowPrefs.getStringSet(Keys.removeExcludeCategories, emptySet())
|
||||
|
||||
fun libraryUpdateInterval() = flowPrefs.getInt(Keys.libraryUpdateInterval, 24)
|
||||
|
||||
fun libraryUpdateRestriction() = flowPrefs.getStringSet(Keys.libraryUpdateRestriction, setOf(UNMETERED_NETWORK))
|
||||
@ -259,14 +268,16 @@ class PreferencesHelper(val context: Context) {
|
||||
fun librarySortingMode() = flowPrefs.getEnum(Keys.librarySortingMode, SortModeSetting.ALPHABETICAL)
|
||||
fun librarySortingAscending() = flowPrefs.getEnum(Keys.librarySortingDirection, SortDirectionSetting.ASCENDING)
|
||||
|
||||
fun migrationSortingMode() = flowPrefs.getEnum(Keys.migrationSortingMode, MigrationSourcesController.SortSetting.ALPHABETICAL)
|
||||
fun migrationSortingDirection() = flowPrefs.getEnum(Keys.migrationSortingDirection, MigrationSourcesController.DirectionSetting.ASCENDING)
|
||||
|
||||
fun automaticExtUpdates() = flowPrefs.getBoolean(Keys.automaticExtUpdates, true)
|
||||
|
||||
fun showNsfwSource() = flowPrefs.getBoolean(Keys.showNsfwSource, true)
|
||||
fun showNsfwExtension() = flowPrefs.getBoolean(Keys.showNsfwExtension, true)
|
||||
fun labelNsfwExtension() = prefs.getBoolean(Keys.labelNsfwExtension, true)
|
||||
|
||||
fun extensionUpdatesCount() = flowPrefs.getInt("ext_updates_count", 0)
|
||||
|
||||
fun lastAppCheck() = flowPrefs.getLong("last_app_check", 0)
|
||||
fun lastExtCheck() = flowPrefs.getLong("last_ext_check", 0)
|
||||
|
||||
fun searchPinnedSourcesOnly() = prefs.getBoolean(Keys.searchPinnedSourcesOnly, false)
|
||||
@ -280,8 +291,6 @@ class PreferencesHelper(val context: Context) {
|
||||
fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet())
|
||||
fun downloadNewCategoriesExclude() = flowPrefs.getStringSet(Keys.downloadNewCategoriesExclude, emptySet())
|
||||
|
||||
fun lang() = flowPrefs.getString(Keys.lang, "")
|
||||
|
||||
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
||||
|
||||
fun categorisedDisplaySettings() = flowPrefs.getBoolean(Keys.categorizedDisplay, false)
|
||||
@ -312,6 +321,16 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false)
|
||||
|
||||
fun tabletUiMode() = flowPrefs.getEnum(
|
||||
Keys.tabletUiMode,
|
||||
if (context.applicationContext.isTablet()) Values.TabletUiMode.ALWAYS else Values.TabletUiMode.NEVER
|
||||
)
|
||||
|
||||
fun extensionInstaller() = flowPrefs.getEnum(
|
||||
Keys.extensionInstaller,
|
||||
if (MiuiUtil.isMiui()) Values.ExtensionInstaller.LEGACY else Values.ExtensionInstaller.PACKAGEINSTALLER
|
||||
)
|
||||
|
||||
fun setChapterSettingsDefault(manga: Manga) {
|
||||
prefs.edit {
|
||||
putInt(Keys.defaultChapterFilterByRead, manga.readFilter)
|
||||
|
@ -182,6 +182,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getLibManga(track, getUsername().toInt())
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.title = remoteTrack.title
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
return track
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", track.media_id)
|
||||
put("progress", track.last_chapter_read)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
}
|
||||
}
|
||||
@ -89,7 +89,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("listId", track.library_id)
|
||||
put("progress", track.last_chapter_read)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
put("score", track.score.toInt())
|
||||
put("startedAt", createDate(track.started_reading_date))
|
||||
@ -110,12 +110,12 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||
|id
|
||||
|title {
|
||||
|romaji
|
||||
|userPreferred
|
||||
|}
|
||||
|coverImage {
|
||||
|large
|
||||
|}
|
||||
|type
|
||||
|format
|
||||
|status
|
||||
|chapters
|
||||
|description
|
||||
@ -175,12 +175,12 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
|media {
|
||||
|id
|
||||
|title {
|
||||
|romaji
|
||||
|userPreferred
|
||||
|}
|
||||
|coverImage {
|
||||
|large
|
||||
|}
|
||||
|type
|
||||
|format
|
||||
|status
|
||||
|chapters
|
||||
|description
|
||||
@ -264,10 +264,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||
return ALManga(
|
||||
struct["id"]!!.jsonPrimitive.int,
|
||||
struct["title"]!!.jsonObject["romaji"]!!.jsonPrimitive.content,
|
||||
struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content,
|
||||
struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content,
|
||||
struct["description"]!!.jsonPrimitive.contentOrNull,
|
||||
struct["type"]!!.jsonPrimitive.content,
|
||||
struct["format"]!!.jsonPrimitive.content.replace("_", "-"),
|
||||
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
||||
parseDate(struct, "startDate"),
|
||||
struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0
|
||||
|
@ -10,10 +10,10 @@ import java.util.Locale
|
||||
|
||||
data class ALManga(
|
||||
val media_id: Int,
|
||||
val title_romaji: String,
|
||||
val title_user_pref: String,
|
||||
val image_url_lge: String,
|
||||
val description: String?,
|
||||
val type: String,
|
||||
val format: String,
|
||||
val publishing_status: String,
|
||||
val start_date_fuzzy: Long,
|
||||
val total_chapters: Int
|
||||
@ -21,13 +21,13 @@ data class ALManga(
|
||||
|
||||
fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply {
|
||||
media_id = this@ALManga.media_id
|
||||
title = title_romaji
|
||||
title = title_user_pref
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
cover_url = image_url_lge
|
||||
summary = description ?: ""
|
||||
tracking_url = AnilistApi.mangaUrl(media_id)
|
||||
publishing_status = this@ALManga.publishing_status
|
||||
publishing_type = type
|
||||
publishing_type = format
|
||||
if (start_date_fuzzy != 0L) {
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
@ -51,11 +51,12 @@ data class ALUserManga(
|
||||
|
||||
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
|
||||
media_id = manga.media_id
|
||||
title = manga.title_user_pref
|
||||
status = toTrackStatus()
|
||||
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.toFloat()
|
||||
library_id = this@ALUserManga.library_id
|
||||
total_chapters = manga.total_chapters
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
||||
|
||||
// chapter update
|
||||
val body = FormBody.Builder()
|
||||
.add("watched_eps", track.last_chapter_read.toString())
|
||||
.add("watched_eps", track.last_chapter_read.toInt().toString())
|
||||
.build()
|
||||
authClient.newCall(
|
||||
POST(
|
||||
@ -143,7 +143,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
|
||||
} else {
|
||||
json.decodeFromString<Collection>(responseBody).let {
|
||||
track.status = it.status?.id!!
|
||||
track.last_chapter_read = it.ep_status!!
|
||||
track.last_chapter_read = it.ep_status!!.toFloat()
|
||||
track.score = it.rating!!
|
||||
track
|
||||
}
|
||||
|
@ -0,0 +1,50 @@
|
||||
package eu.kanade.tachiyomi.data.track.job
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import timber.log.Timber
|
||||
|
||||
class DelayedTrackingStore(context: Context) {
|
||||
|
||||
/**
|
||||
* Preference file where queued tracking updates are stored.
|
||||
*/
|
||||
private val preferences = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE)
|
||||
|
||||
fun addItem(track: Track) {
|
||||
val trackId = track.id.toString()
|
||||
val (_, lastChapterRead) = preferences.getString(trackId, "0:0.0")!!.split(":")
|
||||
if (track.last_chapter_read > lastChapterRead.toFloat()) {
|
||||
val value = "${track.manga_id}:${track.last_chapter_read}"
|
||||
Timber.i("Queuing track item: $trackId, $value")
|
||||
preferences.edit {
|
||||
putString(trackId, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun getItems(): List<DelayedTrackingItem> {
|
||||
return (preferences.all as Map<String, String>).entries
|
||||
.map {
|
||||
val (mangaId, lastChapterRead) = it.value.split(":")
|
||||
DelayedTrackingItem(
|
||||
trackId = it.key.toLong(),
|
||||
mangaId = mangaId.toLong(),
|
||||
lastChapterRead = lastChapterRead.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class DelayedTrackingItem(
|
||||
val trackId: Long,
|
||||
val mangaId: Long,
|
||||
val lastChapterRead: Float,
|
||||
)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package eu.kanade.tachiyomi.data.track.job
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DelayedTrackingUpdateJob(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val db = Injekt.get<DatabaseHelper>()
|
||||
val trackManager = Injekt.get<TrackManager>()
|
||||
val delayedTrackingStore = Injekt.get<DelayedTrackingStore>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val tracks = delayedTrackingStore.getItems().mapNotNull {
|
||||
val manga = db.getManga(it.mangaId).executeAsBlocking() ?: return@withContext
|
||||
db.getTracks(manga).executeAsBlocking()
|
||||
.find { track -> track.id == it.trackId }
|
||||
?.also { track ->
|
||||
track.last_chapter_read = it.lastChapterRead
|
||||
}
|
||||
}
|
||||
|
||||
tracks.forEach { track ->
|
||||
try {
|
||||
val service = trackManager.getService(track.sync_id)
|
||||
if (service != null && service.isLogged) {
|
||||
service.update(track, true)
|
||||
db.insertTrack(track).executeAsBlocking()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
|
||||
delayedTrackingStore.clear()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DelayedTrackingUpdate"
|
||||
|
||||
fun setupTask(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<DelayedTrackingUpdateJob>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 20, TimeUnit.SECONDS)
|
||||
.addTag(TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
@ -36,7 +36,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
put("type", "libraryEntries")
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("progress", track.last_chapter_read)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
}
|
||||
putJsonObject("relationships") {
|
||||
putJsonObject("user") {
|
||||
@ -82,7 +82,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
put("id", track.media_id)
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("progress", track.last_chapter_read)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("ratingTwenty", track.toKitsuScore())
|
||||
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
||||
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
||||
|
@ -25,7 +25,7 @@ class KitsuSearchManga(obj: JsonObject) {
|
||||
// posterImage is sometimes a jsonNull object instead
|
||||
null
|
||||
}
|
||||
private val synopsis = obj["synopsis"]!!.jsonPrimitive.content
|
||||
private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull
|
||||
private var startDate = obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(Date(it.toLong() * 1000))
|
||||
@ -38,7 +38,7 @@ class KitsuSearchManga(obj: JsonObject) {
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = original ?: ""
|
||||
summary = synopsis
|
||||
summary = synopsis ?: ""
|
||||
tracking_url = KitsuApi.mangaUrl(media_id)
|
||||
publishing_status = if (endDate == null) {
|
||||
"Publishing"
|
||||
@ -79,7 +79,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
||||
finished_reading_date = KitsuDateHelper.parse(finishedAt)
|
||||
status = toTrackStatus()
|
||||
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
|
||||
last_chapter_read = progress
|
||||
last_chapter_read = progress.toFloat()
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (status) {
|
||||
|
@ -38,20 +38,22 @@ class KomgaApi(private val client: OkHttpClient) {
|
||||
}
|
||||
|
||||
val progress = client
|
||||
.newCall(GET("$url/read-progress/tachiyomi"))
|
||||
.await()
|
||||
.parseAs<ReadProgressDto>()
|
||||
.newCall(GET("${url.replace("/api/v1/series/", "/api/v2/series/")}/read-progress/tachiyomi"))
|
||||
.await().let {
|
||||
if (url.contains("/api/v1/series/")) it.parseAs<ReadProgressV2Dto>()
|
||||
else it.parseAs<ReadProgressDto>().toV2()
|
||||
}
|
||||
|
||||
track.apply {
|
||||
cover_url = "$url/thumbnail"
|
||||
tracking_url = url
|
||||
total_chapters = progress.booksCount
|
||||
total_chapters = progress.maxNumberSort.toInt()
|
||||
status = when (progress.booksCount) {
|
||||
progress.booksUnreadCount -> Komga.UNREAD
|
||||
progress.booksReadCount -> Komga.COMPLETED
|
||||
else -> Komga.READING
|
||||
}
|
||||
last_chapter_read = progress.lastReadContinuousIndex
|
||||
last_chapter_read = progress.lastReadContinuousNumberSort
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not get item: $url")
|
||||
@ -60,11 +62,14 @@ class KomgaApi(private val client: OkHttpClient) {
|
||||
}
|
||||
|
||||
suspend fun updateProgress(track: Track): Track {
|
||||
val progress = ReadProgressUpdateDto(track.last_chapter_read)
|
||||
val payload = json.encodeToString(progress)
|
||||
val payload = if (track.tracking_url.contains("/api/v1/series/")) {
|
||||
json.encodeToString(ReadProgressUpdateV2Dto(track.last_chapter_read))
|
||||
} else {
|
||||
json.encodeToString(ReadProgressUpdateDto(track.last_chapter_read.toInt()))
|
||||
}
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.url("${track.tracking_url}/read-progress/tachiyomi")
|
||||
.url("${track.tracking_url.replace("/api/v1/series/", "/api/v2/series/")}/read-progress/tachiyomi")
|
||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
)
|
||||
|
@ -63,6 +63,11 @@ data class ReadProgressUpdateDto(
|
||||
val lastBookRead: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReadProgressUpdateV2Dto(
|
||||
val lastBookNumberSortRead: Float,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReadListDto(
|
||||
val id: String,
|
||||
@ -80,4 +85,23 @@ data class ReadProgressDto(
|
||||
val booksUnreadCount: Int,
|
||||
val booksInProgressCount: Int,
|
||||
val lastReadContinuousIndex: Int,
|
||||
) {
|
||||
fun toV2() = ReadProgressV2Dto(
|
||||
booksCount,
|
||||
booksReadCount,
|
||||
booksUnreadCount,
|
||||
booksInProgressCount,
|
||||
lastReadContinuousIndex.toFloat(),
|
||||
booksCount.toFloat(),
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReadProgressV2Dto(
|
||||
val booksCount: Int,
|
||||
val booksReadCount: Int,
|
||||
val booksUnreadCount: Int,
|
||||
val booksInProgressCount: Int,
|
||||
val lastReadContinuousNumberSort: Float,
|
||||
val maxNumberSort: Float,
|
||||
)
|
||||
|
@ -16,7 +16,7 @@ class TrackSearch : Track {
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Int = 0
|
||||
override var last_chapter_read: Float = 0F
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@ -117,7 +118,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
||||
.add("status", track.toMyAnimeListStatus() ?: "reading")
|
||||
.add("is_rereading", (track.status == MyAnimeList.REREADING).toString())
|
||||
.add("score", track.score.toString())
|
||||
.add("num_chapters_read", track.last_chapter_read.toString())
|
||||
.add("num_chapters_read", track.last_chapter_read.toInt().toString())
|
||||
convertToIsoDate(track.started_reading_date)?.let {
|
||||
formBodyBuilder.add("start_date", it)
|
||||
}
|
||||
@ -205,7 +206,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
|
||||
return track.apply {
|
||||
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
|
||||
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content)
|
||||
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int
|
||||
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.float
|
||||
score = obj["score"]!!.jsonPrimitive.int.toFloat()
|
||||
obj["start_date"]?.let {
|
||||
started_reading_date = parseDate(it.jsonPrimitive.content)
|
||||
|
@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@ -35,7 +36,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
||||
put("user_id", user_id)
|
||||
put("target_id", track.media_id)
|
||||
put("target_type", "Manga")
|
||||
put("chapters", track.last_chapter_read)
|
||||
put("chapters", track.last_chapter_read.toInt())
|
||||
put("score", track.score.toInt())
|
||||
put("status", track.toShikimoriStatus())
|
||||
}
|
||||
@ -89,7 +90,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
|
||||
title = mangas["name"]!!.jsonPrimitive.content
|
||||
media_id = obj["id"]!!.jsonPrimitive.int
|
||||
total_chapters = mangas["chapters"]!!.jsonPrimitive.int
|
||||
last_chapter_read = obj["chapters"]!!.jsonPrimitive.int
|
||||
last_chapter_read = obj["chapters"]!!.jsonPrimitive.float
|
||||
score = (obj["score"]!!.jsonPrimitive.int).toFloat()
|
||||
status = toTrackStatus(obj["status"]!!.jsonPrimitive.content)
|
||||
tracking_url = baseUrl + mangas["url"]!!.jsonPrimitive.content
|
||||
|
@ -1,16 +1,19 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
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
|
||||
import java.util.Date
|
||||
|
||||
class GithubUpdateChecker {
|
||||
class AppUpdateChecker {
|
||||
|
||||
private val networkService: NetworkHelper by injectLazy()
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
private val repo: String by lazy {
|
||||
if (BuildConfig.DEBUG) {
|
||||
@ -20,18 +23,20 @@ class GithubUpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdate(): GithubUpdateResult {
|
||||
suspend fun checkForUpdate(): AppUpdateResult {
|
||||
return withIOContext {
|
||||
networkService.client
|
||||
.newCall(GET("https://api.github.com/repos/$repo/releases/latest"))
|
||||
.await()
|
||||
.parseAs<GithubRelease>()
|
||||
.let {
|
||||
preferences.lastAppCheck().set(Date().time)
|
||||
|
||||
// Check if latest version is different from current version
|
||||
if (isNewVersion(it.version)) {
|
||||
GithubUpdateResult.NewUpdate(it)
|
||||
AppUpdateResult.NewUpdate(it)
|
||||
} else {
|
||||
GithubUpdateResult.NoNewUpdate
|
||||
AppUpdateResult.NoNewUpdate
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
sealed class AppUpdateResult {
|
||||
class NewUpdate(val release: GithubRelease) : AppUpdateResult()
|
||||
object NoNewUpdate : AppUpdateResult()
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.updater
|
||||
|
||||
sealed class GithubUpdateResult {
|
||||
class NewUpdate(val release: GithubRelease) : GithubUpdateResult()
|
||||
object NoNewUpdate : GithubUpdateResult()
|
||||
}
|
@ -8,6 +8,7 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -16,9 +17,9 @@ class UpdaterJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
|
||||
override fun doWork() = runBlocking {
|
||||
try {
|
||||
val result = GithubUpdateChecker().checkForUpdate()
|
||||
val result = AppUpdateChecker().checkForUpdate()
|
||||
|
||||
if (result is GithubUpdateResult.NewUpdate) {
|
||||
if (result is AppUpdateResult.NewUpdate) {
|
||||
UpdaterNotifier(context).promptUpdate(result.release.getDownloadLink())
|
||||
}
|
||||
Result.success()
|
||||
@ -31,12 +32,18 @@ class UpdaterJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
private const val TAG = "UpdateChecker"
|
||||
|
||||
fun setupTask(context: Context) {
|
||||
// Never check for updates in debug builds that don't include the updater
|
||||
if (BuildConfig.DEBUG && !BuildConfig.INCLUDE_UPDATER) {
|
||||
cancelTask(context)
|
||||
return
|
||||
}
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<UpdaterJob>(
|
||||
3,
|
||||
7,
|
||||
TimeUnit.DAYS,
|
||||
3,
|
||||
TimeUnit.HOURS
|
||||
|
@ -227,14 +227,26 @@ class ExtensionManager(
|
||||
return installExtension(availableExt)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: Extension) {
|
||||
installer.cancelInstall(extension.pkgName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the result of the installation of an extension.
|
||||
* Sets to "installing" status of an extension installation.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param result Whether the extension was installed or not.
|
||||
*/
|
||||
fun setInstalling(downloadId: Long) {
|
||||
installer.updateInstallStep(downloadId, InstallStep.Installing)
|
||||
}
|
||||
|
||||
fun setInstallationResult(downloadId: Long, result: Boolean) {
|
||||
installer.setInstallationResult(downloadId, result)
|
||||
val step = if (result) InstallStep.Installed else InstallStep.Error
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
installer.updateInstallStep(downloadId, step)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -73,9 +73,9 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
|
||||
.build()
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<ExtensionUpdateJob>(
|
||||
12,
|
||||
TimeUnit.HOURS,
|
||||
1,
|
||||
2,
|
||||
TimeUnit.DAYS,
|
||||
3,
|
||||
TimeUnit.HOURS
|
||||
)
|
||||
.addTag(TAG)
|
||||
|
@ -10,10 +10,7 @@ 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.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.Serializable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Date
|
||||
|
||||
@ -27,8 +24,8 @@ internal class ExtensionGithubApi {
|
||||
networkService.client
|
||||
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
|
||||
.await()
|
||||
.parseAs<JsonArray>()
|
||||
.let { parseResponse(it) }
|
||||
.parseAs<List<ExtensionJsonObject>>()
|
||||
.toExtensions()
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,24 +52,23 @@ internal class ExtensionGithubApi {
|
||||
return extensionsWithUpdate
|
||||
}
|
||||
|
||||
private fun parseResponse(json: JsonArray): List<Extension.Available> {
|
||||
return json
|
||||
.filter { element ->
|
||||
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
|
||||
val libVersion = versionName.substringBeforeLast('.').toDouble()
|
||||
private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
|
||||
return this
|
||||
.filter {
|
||||
val libVersion = it.version.substringBeforeLast('.').toDouble()
|
||||
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
|
||||
}
|
||||
.map { element ->
|
||||
val name = element.jsonObject["name"]!!.jsonPrimitive.content.substringAfter("Tachiyomi: ")
|
||||
val pkgName = element.jsonObject["pkg"]!!.jsonPrimitive.content
|
||||
val apkName = element.jsonObject["apk"]!!.jsonPrimitive.content
|
||||
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content
|
||||
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int
|
||||
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content
|
||||
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1
|
||||
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
|
||||
|
||||
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
|
||||
.map {
|
||||
Extension.Available(
|
||||
name = it.name.substringAfter("Tachiyomi: "),
|
||||
pkgName = it.pkg,
|
||||
versionName = it.version,
|
||||
versionCode = it.code,
|
||||
lang = it.lang,
|
||||
isNsfw = it.nsfw == 1,
|
||||
apkName = it.apk,
|
||||
iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,3 +78,14 @@ internal class ExtensionGithubApi {
|
||||
}
|
||||
|
||||
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
|
||||
|
||||
@Serializable
|
||||
private data class ExtensionJsonObject(
|
||||
val name: String,
|
||||
val pkg: String,
|
||||
val apk: String,
|
||||
val version: String,
|
||||
val code: Long,
|
||||
val lang: String,
|
||||
val nsfw: Int,
|
||||
)
|
||||
|
@ -0,0 +1,170 @@
|
||||
package eu.kanade.tachiyomi.extension.installer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Base implementation class for extension installer. To be used inside a foreground [Service].
|
||||
*/
|
||||
abstract class Installer(private val service: Service) {
|
||||
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1).takeIf { it >= 0 } ?: return
|
||||
cancelQueue(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installer readiness. If false, queue check will not run.
|
||||
*
|
||||
* @see checkQueue
|
||||
*/
|
||||
abstract var ready: Boolean
|
||||
|
||||
/**
|
||||
* Add an item to install queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
fun addToQueue(downloadId: Long, uri: Uri) {
|
||||
queue.add(Entry(downloadId, uri))
|
||||
checkQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceeds to install the APK of this entry inside this method. Call [continueQueue]
|
||||
* when the install process for this entry is finished to continue the queue.
|
||||
*
|
||||
* @param entry The [Entry] of item to process
|
||||
* @see continueQueue
|
||||
*/
|
||||
@CallSuper
|
||||
open fun processEntry(entry: Entry) {
|
||||
extensionManager.setInstalling(entry.downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before queue continues. Override this to handle when the removed entry is
|
||||
* currently being processed.
|
||||
*
|
||||
* @return true if this entry can be removed from queue.
|
||||
*/
|
||||
open fun cancelEntry(entry: Entry): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the queue to continue processing the next entry and updates the install step
|
||||
* of the completed entry ([waitingInstall]) to [ExtensionManager].
|
||||
*
|
||||
* @param resultStep new install step for the processed entry.
|
||||
* @see waitingInstall
|
||||
*/
|
||||
fun continueQueue(resultStep: InstallStep) {
|
||||
val completedEntry = waitingInstall.getAndSet(null)
|
||||
if (completedEntry != null) {
|
||||
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
|
||||
checkQueue()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the queue. The provided service will be stopped if the queue is empty.
|
||||
* Will not be run when not ready.
|
||||
*
|
||||
* @see ready
|
||||
*/
|
||||
fun checkQueue() {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
service.stopSelf()
|
||||
return
|
||||
}
|
||||
val nextEntry = queue.first()
|
||||
if (waitingInstall.compareAndSet(null, nextEntry)) {
|
||||
queue.removeFirst()
|
||||
processEntry(nextEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when the provided service is destroyed.
|
||||
*/
|
||||
@CallSuper
|
||||
open fun onDestroy() {
|
||||
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
|
||||
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
|
||||
queue.clear()
|
||||
waitingInstall.set(null)
|
||||
}
|
||||
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.get()
|
||||
|
||||
/**
|
||||
* Cancels queue for the provided download ID if exists.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.get()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
// Currently processing removed entry, continue queue
|
||||
this.waitingInstall.set(null)
|
||||
checkQueue()
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install item to queue.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
* @param uri Uri of APK to install
|
||||
*/
|
||||
data class Entry(val downloadId: Long, val uri: Uri)
|
||||
|
||||
init {
|
||||
val filter = IntentFilter(ACTION_CANCEL_QUEUE)
|
||||
LocalBroadcastManager.getInstance(service).registerReceiver(cancelReceiver, filter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_CANCEL_QUEUE = "Installer.action.CANCEL_QUEUE"
|
||||
private const val EXTRA_DOWNLOAD_ID = "Installer.extra.DOWNLOAD_ID"
|
||||
|
||||
/**
|
||||
* Attempts to cancel the installation entry for the provided download ID.
|
||||
*
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
fun cancelInstallQueue(context: Context, downloadId: Long) {
|
||||
val intent = Intent(ACTION_CANCEL_QUEUE)
|
||||
intent.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
package eu.kanade.tachiyomi.extension.installer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.lang.use
|
||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||
import timber.log.Timber
|
||||
|
||||
class PackageInstallerInstaller(private val service: Service) : Installer(service) {
|
||||
|
||||
private val packageInstaller = service.packageManager.packageInstaller
|
||||
|
||||
private val packageActionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val userAction = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
if (userAction == null) {
|
||||
Timber.e("Fatal error for $intent")
|
||||
continueQueue(InstallStep.Error)
|
||||
return
|
||||
}
|
||||
userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
service.startActivity(userAction)
|
||||
}
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> {
|
||||
continueQueue(InstallStep.Idle)
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> continueQueue(InstallStep.Installed)
|
||||
else -> continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSession: Pair<Entry, Int>? = null
|
||||
|
||||
// Always ready
|
||||
override var ready = true
|
||||
|
||||
override fun processEntry(entry: Entry) {
|
||||
super.processEntry(entry)
|
||||
activeSession = null
|
||||
try {
|
||||
val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
activeSession = entry to packageInstaller.createSession(installParams)
|
||||
val fileSize = service.getUriSize(entry.uri) ?: throw IllegalStateException()
|
||||
installParams.setSize(fileSize)
|
||||
|
||||
val inputStream = service.contentResolver.openInputStream(entry.uri) ?: throw IllegalStateException()
|
||||
val session = packageInstaller.openSession(activeSession!!.second)
|
||||
val outputStream = session.openWrite(entry.downloadId.toString(), 0, fileSize)
|
||||
session.use {
|
||||
arrayOf(inputStream, outputStream).use {
|
||||
inputStream.copyTo(outputStream)
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
|
||||
val intentSender = PendingIntent.getBroadcast(
|
||||
service,
|
||||
activeSession!!.second,
|
||||
Intent(INSTALL_ACTION),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
).intentSender
|
||||
session.commit(intentSender)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
|
||||
activeSession?.let { (_, sessionId) ->
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelEntry(entry: Entry): Boolean {
|
||||
activeSession?.let { (activeEntry, sessionId) ->
|
||||
if (activeEntry == entry) {
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
service.unregisterReceiver(packageActionReceiver)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
init {
|
||||
service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION))
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_ACTION = "PackageInstallerInstaller.INSTALL_ACTION"
|
@ -0,0 +1,127 @@
|
||||
package eu.kanade.tachiyomi.extension.installer
|
||||
|
||||
import android.app.Service
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.getUriSize
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import rikka.shizuku.Shizuku
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStream
|
||||
|
||||
class ShizukuInstaller(private val service: Service) : Installer(service) {
|
||||
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val shizukuDeadListener = Shizuku.OnBinderDeadListener {
|
||||
Timber.e("Shizuku was killed prematurely")
|
||||
service.stopSelf()
|
||||
}
|
||||
|
||||
private val shizukuPermissionListener = object : Shizuku.OnRequestPermissionResultListener {
|
||||
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
|
||||
if (requestCode == SHIZUKU_PERMISSION_REQUEST_CODE) {
|
||||
if (grantResult == PackageManager.PERMISSION_GRANTED) {
|
||||
ready = true
|
||||
checkQueue()
|
||||
} else {
|
||||
service.stopSelf()
|
||||
}
|
||||
Shizuku.removeRequestPermissionResultListener(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var ready = false
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override fun processEntry(entry: Entry) {
|
||||
super.processEntry(entry)
|
||||
ioScope.launch {
|
||||
var sessionId: String? = null
|
||||
try {
|
||||
val size = service.getUriSize(entry.uri) ?: throw IllegalStateException()
|
||||
service.contentResolver.openInputStream(entry.uri)!!.use {
|
||||
val createCommand = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
"pm install-create --user current -i ${service.packageName} -S $size"
|
||||
} else {
|
||||
"pm install-create -i ${service.packageName} -S $size"
|
||||
}
|
||||
val createResult = exec(createCommand)
|
||||
sessionId = SESSION_ID_REGEX.find(createResult.out)?.value
|
||||
?: throw RuntimeException("Failed to create install session")
|
||||
|
||||
val writeResult = exec("pm install-write -S $size $sessionId base -", it)
|
||||
if (writeResult.resultCode != 0) {
|
||||
throw RuntimeException("Failed to write APK to session $sessionId")
|
||||
}
|
||||
|
||||
val commitResult = exec("pm install-commit $sessionId")
|
||||
if (commitResult.resultCode != 0) {
|
||||
throw RuntimeException("Failed to commit install session $sessionId")
|
||||
}
|
||||
|
||||
continueQueue(InstallStep.Installed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to install extension ${entry.downloadId} ${entry.uri}")
|
||||
if (sessionId != null) {
|
||||
exec("pm install-abandon $sessionId")
|
||||
}
|
||||
continueQueue(InstallStep.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't cancel if entry is already started installing
|
||||
override fun cancelEntry(entry: Entry): Boolean = getActiveEntry() != entry
|
||||
|
||||
override fun onDestroy() {
|
||||
Shizuku.removeBinderDeadListener(shizukuDeadListener)
|
||||
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
|
||||
ioScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun exec(command: String, stdin: InputStream? = null): ShellResult {
|
||||
@Suppress("DEPRECATION")
|
||||
val process = Shizuku.newProcess(arrayOf("sh", "-c", command), null, null)
|
||||
if (stdin != null) {
|
||||
process.outputStream.use { stdin.copyTo(it) }
|
||||
}
|
||||
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||
val resultCode = process.waitFor()
|
||||
return ShellResult(resultCode, output)
|
||||
}
|
||||
|
||||
private data class ShellResult(val resultCode: Int, val out: String)
|
||||
|
||||
init {
|
||||
Shizuku.addBinderDeadListener(shizukuDeadListener)
|
||||
ready = if (Shizuku.pingBinder()) {
|
||||
if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) {
|
||||
true
|
||||
} else {
|
||||
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
|
||||
Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
false
|
||||
}
|
||||
} else {
|
||||
Timber.e("Shizuku is not ready to use.")
|
||||
service.toast(R.string.ext_installer_shizuku_stopped)
|
||||
service.stopSelf()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SHIZUKU_PERMISSION_REQUEST_CODE = 14045
|
||||
private val SESSION_ID_REGEX = Regex("(?<=\\[).+?(?=])")
|
@ -7,7 +7,7 @@ sealed class Extension {
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Int
|
||||
abstract val versionCode: Long
|
||||
abstract val lang: String?
|
||||
abstract val isNsfw: Boolean
|
||||
|
||||
@ -15,7 +15,7 @@ sealed class Extension {
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Int,
|
||||
override val versionCode: Long,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
val pkgFactory: String?,
|
||||
@ -29,7 +29,7 @@ sealed class Extension {
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Int,
|
||||
override val versionCode: Long,
|
||||
override val lang: String,
|
||||
override val isNsfw: Boolean,
|
||||
val apkName: String,
|
||||
@ -40,7 +40,7 @@ sealed class Extension {
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Int,
|
||||
override val versionCode: Long,
|
||||
val signatureHash: String,
|
||||
override val lang: String? = null,
|
||||
override val isNsfw: Boolean = false
|
||||
|
@ -1,9 +1,9 @@
|
||||
package eu.kanade.tachiyomi.extension.model
|
||||
|
||||
enum class InstallStep {
|
||||
Pending, Downloading, Installing, Installed, Error;
|
||||
Idle, Pending, Downloading, Installing, Installed, Error;
|
||||
|
||||
fun isCompleted(): Boolean {
|
||||
return this == Installed || this == Error
|
||||
return this == Installed || this == Error || this == Idle
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
@ -40,13 +41,14 @@ class ExtensionInstallActivity : Activity() {
|
||||
|
||||
private fun checkInstallationResult(resultCode: Int) {
|
||||
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||
val success = resultCode == RESULT_OK
|
||||
|
||||
val extensionManager = Injekt.get<ExtensionManager>()
|
||||
extensionManager.setInstallationResult(downloadId, success)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INSTALL_REQUEST_CODE = 500
|
||||
val newStep = when (resultCode) {
|
||||
RESULT_OK -> InstallStep.Installed
|
||||
RESULT_CANCELED -> InstallStep.Idle
|
||||
else -> InstallStep.Error
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, newStep)
|
||||
}
|
||||
}
|
||||
|
||||
private const val INSTALL_REQUEST_CODE = 500
|
||||
|
@ -0,0 +1,82 @@
|
||||
package eu.kanade.tachiyomi.extension.util
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.installer.PackageInstallerInstaller
|
||||
import eu.kanade.tachiyomi.extension.installer.ShizukuInstaller
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller.Companion.EXTRA_DOWNLOAD_ID
|
||||
import eu.kanade.tachiyomi.util.system.notificationBuilder
|
||||
import timber.log.Timber
|
||||
|
||||
class ExtensionInstallService : Service() {
|
||||
|
||||
private var installer: Installer? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val notification = notificationBuilder(Notifications.CHANNEL_DOWNLOADER_PROGRESS) {
|
||||
setSmallIcon(R.drawable.ic_tachi)
|
||||
setAutoCancel(false)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setContentTitle(getString(R.string.ext_install_service_notif))
|
||||
setProgress(100, 100, true)
|
||||
}.build()
|
||||
startForeground(Notifications.ID_EXTENSION_INSTALLER, notification)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val uri = intent?.data
|
||||
val id = intent?.getLongExtra(EXTRA_DOWNLOAD_ID, -1)?.takeIf { it != -1L }
|
||||
val installerUsed = intent?.getSerializableExtra(EXTRA_INSTALLER) as? PreferenceValues.ExtensionInstaller
|
||||
if (uri == null || id == null || installerUsed == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (installer == null) {
|
||||
installer = when (installerUsed) {
|
||||
PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER -> PackageInstallerInstaller(this)
|
||||
PreferenceValues.ExtensionInstaller.SHIZUKU -> ShizukuInstaller(this)
|
||||
else -> {
|
||||
Timber.e("Not implemented for installer $installerUsed")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
installer!!.addToQueue(id, uri)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
installer?.onDestroy()
|
||||
installer = null
|
||||
}
|
||||
|
||||
override fun onBind(i: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_INSTALLER = "EXTRA_INSTALLER"
|
||||
|
||||
fun getIntent(
|
||||
context: Context,
|
||||
downloadId: Long,
|
||||
uri: Uri,
|
||||
installer: PreferenceValues.ExtensionInstaller
|
||||
): Intent {
|
||||
return Intent(context, ExtensionInstallService::class.java)
|
||||
.setDataAndType(uri, ExtensionInstaller.APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.putExtra(EXTRA_INSTALLER, installer)
|
||||
}
|
||||
}
|
||||
}
|
@ -7,15 +7,21 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -47,6 +53,8 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
*/
|
||||
private val downloadsRelay = PublishRelay.create<Pair<Long, InstallStep>>()
|
||||
|
||||
private val installerPref = Injekt.get<PreferencesHelper>().extensionInstaller()
|
||||
|
||||
/**
|
||||
* Adds the given extension to the downloads queue and returns an observable containing its
|
||||
* step in the installation process.
|
||||
@ -79,8 +87,6 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
.map { it.second }
|
||||
// Poll download status
|
||||
.mergeWith(pollStatus(id))
|
||||
// Force an error if the download takes more than 3 minutes
|
||||
.mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error })
|
||||
// Stop when the application is installed or errors
|
||||
.takeUntil { it.isCompleted() }
|
||||
// Always notify on main thread
|
||||
@ -126,12 +132,29 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
* @param uri The uri of the extension to install.
|
||||
*/
|
||||
fun installApk(downloadId: Long, uri: Uri) {
|
||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
when (val installer = installerPref.get()) {
|
||||
PreferenceValues.ExtensionInstaller.LEGACY -> {
|
||||
val intent = Intent(context, ExtensionInstallActivity::class.java)
|
||||
.setDataAndType(uri, APK_MIME)
|
||||
.putExtra(EXTRA_DOWNLOAD_ID, downloadId)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
context.startActivity(intent)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else -> {
|
||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels extension install and remove from download manager and installer.
|
||||
*/
|
||||
fun cancelInstall(pkgName: String) {
|
||||
val downloadId = activeDownloads.remove(pkgName) ?: return
|
||||
downloadManager.remove(downloadId)
|
||||
Installer.cancelInstallQueue(context, downloadId)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -147,13 +170,12 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the result of the installation of an extension.
|
||||
* Sets the step of the installation of an extension.
|
||||
*
|
||||
* @param downloadId The id of the download.
|
||||
* @param result Whether the extension was installed or not.
|
||||
* @param step New install step.
|
||||
*/
|
||||
fun setInstallationResult(downloadId: Long, result: Boolean) {
|
||||
val step = if (result) InstallStep.Installed else InstallStep.Error
|
||||
fun updateInstallStep(downloadId: Long, step: InstallStep) {
|
||||
downloadsRelay.call(downloadId to step)
|
||||
}
|
||||
|
||||
@ -216,9 +238,7 @@ internal class ExtensionInstaller(private val context: Context) {
|
||||
val uri = downloadManager.getUriForDownloadedFile(id)
|
||||
|
||||
// Set next installation step
|
||||
if (uri != null) {
|
||||
downloadsRelay.call(id to InstallStep.Installing)
|
||||
} else {
|
||||
if (uri == null) {
|
||||
Timber.e("Couldn't locate downloaded APK")
|
||||
downloadsRelay.call(id to InstallStep.Error)
|
||||
return
|
||||
|
@ -4,8 +4,8 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import dalvik.system.PathClassLoader
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
@ -103,7 +103,7 @@ internal object ExtensionLoader {
|
||||
|
||||
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = pkgInfo.versionCode
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
val exception = Exception("Missing versionName for extension $extName")
|
||||
@ -153,13 +153,7 @@ internal object ExtensionLoader {
|
||||
try {
|
||||
when (val obj = Class.forName(it, false, classLoader).newInstance()) {
|
||||
is Source -> listOf(obj)
|
||||
is SourceFactory -> {
|
||||
if (isSourceNsfw(obj)) {
|
||||
emptyList()
|
||||
} else {
|
||||
obj.createSources()
|
||||
}
|
||||
}
|
||||
is SourceFactory -> obj.createSources()
|
||||
else -> throw Exception("Unknown source class type! ${obj.javaClass}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@ -167,7 +161,6 @@ internal object ExtensionLoader {
|
||||
return LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
.filter { !isSourceNsfw(it) }
|
||||
|
||||
val langs = sources.filterIsInstance<CatalogueSource>()
|
||||
.map { it.lang }
|
||||
@ -214,22 +207,4 @@ internal object ExtensionLoader {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a Source or SourceFactory is annotated with @Nsfw.
|
||||
*/
|
||||
private fun isSourceNsfw(clazz: Any): Boolean {
|
||||
if (loadNsfwSource) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (clazz !is Source && clazz !is SourceFactory) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Annotations are proxied, hence this janky way of checking for them
|
||||
return clazz.javaClass.annotations
|
||||
.flatMap { it.javaClass.interfaces.map { it.simpleName } }
|
||||
.firstOrNull { it == Nsfw::class.java.simpleName } != null
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import java.net.InetAddress
|
||||
|
||||
const val PREF_DOH_CLOUDFLARE = 1
|
||||
const val PREF_DOH_GOOGLE = 2
|
||||
const val PREF_DOH_ADGUARD = 3
|
||||
|
||||
fun OkHttpClient.Builder.dohCloudflare() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
@ -38,3 +39,16 @@ fun OkHttpClient.Builder.dohGoogle() = dns(
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
// AdGuard "Default" DNS works too but for the sake of making sure no site is blacklisted, i picked "Unfiltered"
|
||||
fun OkHttpClient.Builder.dohAdGuard() = dns(
|
||||
DnsOverHttps.Builder().client(build())
|
||||
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
InetAddress.getByName("94.140.14.140"),
|
||||
InetAddress.getByName("94.140.14.141"),
|
||||
InetAddress.getByName("2a10:50c0::1:ff"),
|
||||
InetAddress.getByName("2a10:50c0::2:ff"),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
@ -41,6 +41,7 @@ class NetworkHelper(context: Context) {
|
||||
when (preferences.dohProvider()) {
|
||||
PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
|
||||
PREF_DOH_GOOGLE -> builder.dohGoogle()
|
||||
PREF_DOH_ADGUARD -> builder.dohAdGuard()
|
||||
}
|
||||
|
||||
return builder
|
||||
|
@ -15,10 +15,8 @@ import rx.Producer
|
||||
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.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||
|
@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import com.github.junrar.Archive
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
@ -15,8 +14,16 @@ import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
||||
import eu.kanade.tachiyomi.util.system.ImageUtil
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
@ -68,6 +75,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
}
|
||||
}
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override val id = ID
|
||||
override val name = context.getString(R.string.local_source)
|
||||
override val lang = ""
|
||||
@ -157,16 +166,15 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
.flatten()
|
||||
.firstOrNull { it.extension == "json" }
|
||||
?.apply {
|
||||
val reader = this.inputStream().bufferedReader()
|
||||
val json = JsonParser.parseReader(reader).asJsonObject
|
||||
val obj = json.decodeFromStream<JsonObject>(inputStream())
|
||||
|
||||
manga.title = json["title"]?.asString ?: manga.title
|
||||
manga.author = json["author"]?.asString ?: manga.author
|
||||
manga.artist = json["artist"]?.asString ?: manga.artist
|
||||
manga.description = json["description"]?.asString ?: manga.description
|
||||
manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
|
||||
manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title
|
||||
manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author
|
||||
manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist
|
||||
manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description
|
||||
manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
|
||||
?: manga.genre
|
||||
manga.status = json["status"]?.asInt ?: manga.status
|
||||
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
|
||||
}
|
||||
|
||||
return Observable.just(manga)
|
||||
@ -263,18 +271,13 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
||||
throw Exception(context.getString(R.string.chapter_not_found))
|
||||
}
|
||||
|
||||
private fun getFormat(file: File): Format {
|
||||
val extension = file.extension
|
||||
return if (file.isDirectory) {
|
||||
Format.Directory(file)
|
||||
} else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
|
||||
Format.Zip(file)
|
||||
} else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
|
||||
Format.Rar(file)
|
||||
} else if (extension.equals("epub", true)) {
|
||||
Format.Epub(file)
|
||||
} else {
|
||||
throw Exception(context.getString(R.string.local_invalid_format))
|
||||
private fun getFormat(file: File) = with(file) {
|
||||
when {
|
||||
isDirectory -> Format.Directory(this)
|
||||
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
|
||||
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
|
||||
extension.equals("epub", true) -> Format.Epub(this)
|
||||
else -> throw Exception(context.getString(R.string.local_invalid_format))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,6 @@ import rx.Observable
|
||||
open class SourceManager(private val context: Context) {
|
||||
|
||||
private val sourcesMap = mutableMapOf<Long, Source>()
|
||||
|
||||
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
|
||||
|
||||
init {
|
||||
|
@ -5,7 +5,7 @@ import android.os.Bundle
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import eu.kanade.tachiyomi.util.system.prepareTabletUiContext
|
||||
import nucleus.view.NucleusAppCompatActivity
|
||||
|
||||
abstract class BaseRxActivity<VB : ViewBinding, P : BasePresenter<*>> : NucleusAppCompatActivity<P>() {
|
||||
@ -16,7 +16,7 @@ abstract class BaseRxActivity<VB : ViewBinding, P : BasePresenter<*>> : NucleusA
|
||||
lateinit var binding: VB
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
super.attachBaseContext(LocaleHelper.createLocaleWrapper(newBase))
|
||||
super.attachBaseContext(newBase.prepareTabletUiContext())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -6,7 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import eu.kanade.tachiyomi.util.system.prepareTabletUiContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
abstract class BaseThemedActivity : AppCompatActivity() {
|
||||
@ -14,7 +14,7 @@ abstract class BaseThemedActivity : AppCompatActivity() {
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
super.attachBaseContext(LocaleHelper.createLocaleWrapper(newBase))
|
||||
super.attachBaseContext(newBase.prepareTabletUiContext())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -24,8 +24,13 @@ abstract class BaseThemedActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
fun AppCompatActivity.applyAppTheme(preferences: PreferencesHelper) {
|
||||
getThemeResIds(preferences.appTheme().get(), preferences.themeDarkAmoled().get())
|
||||
.forEach { setTheme(it) }
|
||||
}
|
||||
|
||||
fun getThemeResIds(appTheme: PreferenceValues.AppTheme, isAmoled: Boolean): List<Int> {
|
||||
val resIds = mutableListOf<Int>()
|
||||
when (preferences.appTheme().get()) {
|
||||
when (appTheme) {
|
||||
PreferenceValues.AppTheme.MONET -> {
|
||||
resIds += R.style.Theme_Tachiyomi_Monet
|
||||
}
|
||||
@ -45,6 +50,9 @@ abstract class BaseThemedActivity : AppCompatActivity() {
|
||||
PreferenceValues.AppTheme.TAKO -> {
|
||||
resIds += R.style.Theme_Tachiyomi_Tako
|
||||
}
|
||||
PreferenceValues.AppTheme.TEALTURQUOISE -> {
|
||||
resIds += R.style.Theme_Tachiyomi_TealTurquoise
|
||||
}
|
||||
PreferenceValues.AppTheme.YINYANG -> {
|
||||
resIds += R.style.Theme_Tachiyomi_YinYang
|
||||
}
|
||||
@ -56,13 +64,11 @@ abstract class BaseThemedActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (preferences.themeDarkAmoled().get()) {
|
||||
if (isAmoled) {
|
||||
resIds += R.style.ThemeOverlay_Tachiyomi_Amoled
|
||||
}
|
||||
|
||||
resIds.forEach {
|
||||
setTheme(it)
|
||||
}
|
||||
return resIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
package eu.kanade.tachiyomi.ui.base.controller
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import com.bluelinelabs.conductor.Router
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
|
||||
fun Router.popControllerWithTag(tag: String): Boolean {
|
||||
val controller = getControllerWithTag(tag)
|
||||
@ -34,10 +34,12 @@ fun Controller.withFadeTransaction(): RouterTransaction {
|
||||
}
|
||||
|
||||
fun Controller.openInBrowser(url: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(intent)
|
||||
} catch (e: Throwable) {
|
||||
activity?.toast(e.message)
|
||||
}
|
||||
activity?.openInBrowser(url.toUri())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [MainActivity]'s app bar height
|
||||
*/
|
||||
fun Controller.getMainAppBarHeight(): Int {
|
||||
return (activity as? MainActivity)?.binding?.appbar?.measuredHeight ?: 0
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
package eu.kanade.tachiyomi.ui.base.controller
|
||||
|
||||
interface NoToolbarElevationController
|
||||
interface NoAppBarElevationController
|
@ -1,3 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.base.controller
|
||||
|
||||
interface ToolbarLiftOnScrollController
|
@ -92,6 +92,11 @@ class BrowseController :
|
||||
}
|
||||
|
||||
fun setExtensionUpdateBadge() {
|
||||
/* It's possible to switch to the Library controller by the time setExtensionUpdateBadge
|
||||
is called, resulting in a badge being put on the category tabs (if enabled).
|
||||
This check prevents that from happening */
|
||||
if (router.backstack.last().controller !is BrowseController) return
|
||||
|
||||
(activity as? MainActivity)?.binding?.tabs?.apply {
|
||||
val updates = preferences.extensionUpdatesCount().get()
|
||||
if (updates > 0) {
|
||||
|
@ -22,5 +22,6 @@ class ExtensionAdapter(controller: ExtensionController) :
|
||||
|
||||
interface OnButtonClickListener {
|
||||
fun onButtonClick(position: Int)
|
||||
fun onCancelButtonClick(position: Int)
|
||||
}
|
||||
}
|
||||
|
@ -119,6 +119,11 @@ open class ExtensionController :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelButtonClick(position: Int) {
|
||||
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return
|
||||
presenter.cancelInstallUpdateExtension(extension)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.browse_extensions, menu)
|
||||
|
||||
|
@ -1,31 +1,28 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.extension
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import coil.clear
|
||||
import coil.load
|
||||
import eu.davidea.viewholders.FlexibleViewHolder
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.databinding.ExtensionCardItemBinding
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
FlexibleViewHolder(view, adapter) {
|
||||
|
||||
private val binding = ExtensionCardItemBinding.bind(view)
|
||||
|
||||
private val shouldLabelNsfw by lazy {
|
||||
Injekt.get<PreferencesHelper>().labelNsfwExtension()
|
||||
}
|
||||
|
||||
init {
|
||||
binding.extButton.setOnClickListener {
|
||||
adapter.buttonClickListener.onButtonClick(bindingAdapterPosition)
|
||||
}
|
||||
binding.cancelButton.setOnClickListener {
|
||||
adapter.buttonClickListener.onCancelButtonClick(bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: ExtensionItem) {
|
||||
@ -38,7 +35,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
extension is Extension.Untrusted -> itemView.context.getString(R.string.ext_untrusted)
|
||||
extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.ext_unofficial)
|
||||
extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.ext_obsolete)
|
||||
extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
|
||||
extension.isNsfw -> itemView.context.getString(R.string.ext_nsfw_short)
|
||||
else -> ""
|
||||
}.uppercase()
|
||||
|
||||
@ -48,44 +45,40 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
|
||||
} else {
|
||||
extension.getApplicationIcon(itemView.context)?.let { binding.image.setImageDrawable(it) }
|
||||
}
|
||||
bindButton(item)
|
||||
bindButtons(item)
|
||||
}
|
||||
|
||||
@Suppress("ResourceType")
|
||||
fun bindButton(item: ExtensionItem) = with(binding.extButton) {
|
||||
isEnabled = true
|
||||
isClickable = true
|
||||
|
||||
fun bindButtons(item: ExtensionItem) = with(binding.extButton) {
|
||||
val extension = item.extension
|
||||
|
||||
val installStep = item.installStep
|
||||
if (installStep != null) {
|
||||
setText(
|
||||
when (installStep) {
|
||||
InstallStep.Pending -> R.string.ext_pending
|
||||
InstallStep.Downloading -> R.string.ext_downloading
|
||||
InstallStep.Installing -> R.string.ext_installing
|
||||
InstallStep.Installed -> R.string.ext_installed
|
||||
InstallStep.Error -> R.string.action_retry
|
||||
}
|
||||
)
|
||||
if (installStep != InstallStep.Error) {
|
||||
isEnabled = false
|
||||
isClickable = false
|
||||
}
|
||||
} else if (extension is Extension.Installed) {
|
||||
when {
|
||||
extension.hasUpdate -> {
|
||||
setText(R.string.ext_update)
|
||||
}
|
||||
else -> {
|
||||
setText(R.string.action_settings)
|
||||
setText(
|
||||
when (installStep) {
|
||||
InstallStep.Pending -> R.string.ext_pending
|
||||
InstallStep.Downloading -> R.string.ext_downloading
|
||||
InstallStep.Installing -> R.string.ext_installing
|
||||
InstallStep.Installed -> R.string.ext_installed
|
||||
InstallStep.Error -> R.string.action_retry
|
||||
InstallStep.Idle -> {
|
||||
when (extension) {
|
||||
is Extension.Installed -> {
|
||||
if (extension.hasUpdate) {
|
||||
R.string.ext_update
|
||||
} else {
|
||||
R.string.action_settings
|
||||
}
|
||||
}
|
||||
is Extension.Untrusted -> R.string.ext_trust
|
||||
is Extension.Available -> R.string.ext_install
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (extension is Extension.Untrusted) {
|
||||
setText(R.string.ext_trust)
|
||||
} else {
|
||||
setText(R.string.ext_install)
|
||||
}
|
||||
)
|
||||
|
||||
val isIdle = installStep == InstallStep.Idle || installStep == InstallStep.Error
|
||||
binding.cancelButton.isVisible = !isIdle
|
||||
isEnabled = isIdle
|
||||
isClickable = isIdle
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
data class ExtensionItem(
|
||||
val extension: Extension,
|
||||
val header: ExtensionGroupItem? = null,
|
||||
val installStep: InstallStep? = null
|
||||
val installStep: InstallStep = InstallStep.Idle
|
||||
) :
|
||||
AbstractSectionableItem<ExtensionHolder, ExtensionGroupItem>(header) {
|
||||
|
||||
@ -49,7 +49,7 @@ data class ExtensionItem(
|
||||
if (payloads == null || payloads.isEmpty()) {
|
||||
holder.bind(this)
|
||||
} else {
|
||||
holder.bindButton(this)
|
||||
holder.bindButtons(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,14 +55,14 @@ open class ExtensionPresenter(
|
||||
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
|
||||
val context = Injekt.get<Application>()
|
||||
val activeLangs = preferences.enabledLanguages().get()
|
||||
val showNsfwExtensions = preferences.showNsfwExtension().get()
|
||||
val showNsfwSources = preferences.showNsfwSource().get()
|
||||
|
||||
val (installed, untrusted, available) = tuple
|
||||
|
||||
val items = mutableListOf<ExtensionItem>()
|
||||
|
||||
val updatesSorted = installed.filter { it.hasUpdate && (showNsfwExtensions || !it.isNsfw) }.sortedBy { it.name }
|
||||
val installedSorted = installed.filter { !it.hasUpdate && (showNsfwExtensions || !it.isNsfw) }.sortedWith(compareBy({ !it.isObsolete }, { it.name }))
|
||||
val updatesSorted = installed.filter { it.hasUpdate && (showNsfwSources || !it.isNsfw) }.sortedBy { it.name }
|
||||
val installedSorted = installed.filter { !it.hasUpdate && (showNsfwSources || !it.isNsfw) }.sortedWith(compareBy({ !it.isObsolete }, { it.name }))
|
||||
val untrustedSorted = untrusted.sortedBy { it.name }
|
||||
val availableSorted = available
|
||||
// Filter out already installed extensions and disabled languages
|
||||
@ -70,21 +70,21 @@ open class ExtensionPresenter(
|
||||
installed.none { it.pkgName == avail.pkgName } &&
|
||||
untrusted.none { it.pkgName == avail.pkgName } &&
|
||||
(avail.lang in activeLangs || avail.lang == "all") &&
|
||||
(showNsfwExtensions || !avail.isNsfw)
|
||||
(showNsfwSources || !avail.isNsfw)
|
||||
}
|
||||
.sortedBy { it.pkgName }
|
||||
.sortedBy { it.name }
|
||||
|
||||
if (updatesSorted.isNotEmpty()) {
|
||||
val header = ExtensionGroupItem(context.getString(R.string.ext_updates_pending), updatesSorted.size, true)
|
||||
items += updatesSorted.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
|
||||
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
|
||||
|
||||
items += installedSorted.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
|
||||
items += untrustedSorted.map { extension ->
|
||||
@ -100,7 +100,7 @@ open class ExtensionPresenter(
|
||||
.forEach {
|
||||
val header = ExtensionGroupItem(it.key, it.value.size)
|
||||
items += it.value.map { extension ->
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
|
||||
ExtensionItem(extension, header, currentDownloads[extension.pkgName] ?: InstallStep.Idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -133,6 +133,10 @@ open class ExtensionPresenter(
|
||||
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
|
||||
}
|
||||
|
||||
fun cancelInstallUpdateExtension(extension: Extension) {
|
||||
extensionManager.cancelInstallUpdateExtension(extension)
|
||||
}
|
||||
|
||||
private fun Observable<InstallStep>.subscribeToInstallUpdate(extension: Extension) {
|
||||
this.doOnNext { currentDownloads[extension.pkgName] = it }
|
||||
.doOnUnsubscribe { currentDownloads.remove(extension.pkgName) }
|
||||
|
@ -33,13 +33,11 @@ class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
|
||||
.create()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SIGNATURE_KEY = "signature_key"
|
||||
const val PKGNAME_KEY = "pkgname_key"
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun trustSignature(signatureHash: String)
|
||||
fun uninstallExtension(pkgName: String)
|
||||
}
|
||||
}
|
||||
|
||||
private const val SIGNATURE_KEY = "signature_key"
|
||||
private const val PKGNAME_KEY = "pkgname_key"
|
||||
|
@ -2,10 +2,7 @@ package eu.kanade.tachiyomi.ui.browse.extension.details
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
@ -14,7 +11,6 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceGroupAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.preference.PreferenceScreen
|
||||
@ -34,7 +30,6 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.getPreferenceKey
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.util.preference.DSL
|
||||
@ -49,8 +44,7 @@ import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
NucleusController<ExtensionDetailControllerBinding, ExtensionDetailsPresenter>(bundle),
|
||||
ToolbarLiftOnScrollController {
|
||||
NucleusController<ExtensionDetailControllerBinding, ExtensionDetailsPresenter>(bundle) {
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
@ -70,7 +64,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
}
|
||||
|
||||
override fun createPresenter(): ExtensionDetailsPresenter {
|
||||
return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!)
|
||||
return ExtensionDetailsPresenter(this, args.getString(PKGNAME_KEY)!!)
|
||||
}
|
||||
|
||||
override fun getTitle(): String? {
|
||||
@ -108,72 +102,87 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
val isMultiLangSingleSource = isMultiSource && extension.sources.map { it.name }.distinct().size == 1
|
||||
|
||||
with(screen) {
|
||||
extension.sources
|
||||
.groupBy { (it as CatalogueSource).lang }
|
||||
.toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) })
|
||||
.forEach {
|
||||
val preferenceBlock = {
|
||||
it.value
|
||||
.sortedWith(compareBy({ !it.isEnabled() }, { it.name.lowercase() }))
|
||||
.forEach { source ->
|
||||
val sourcePrefs = mutableListOf<Preference>()
|
||||
|
||||
val block: (@DSL SwitchPreferenceCompat).() -> Unit = {
|
||||
key = source.getPreferenceKey()
|
||||
title = when {
|
||||
isMultiSource && !isMultiLangSingleSource -> source.toString()
|
||||
else -> LocaleHelper.getSourceDisplayName(it.key, context)
|
||||
}
|
||||
isPersistent = false
|
||||
isChecked = source.isEnabled()
|
||||
|
||||
onChange { newValue ->
|
||||
val checked = newValue as Boolean
|
||||
toggleSource(source, checked)
|
||||
true
|
||||
}
|
||||
|
||||
// React to enable/disable all changes
|
||||
preferences.disabledSources().asFlow()
|
||||
.onEach {
|
||||
val enabled = source.isEnabled()
|
||||
isChecked = enabled
|
||||
sourcePrefs.forEach { pref -> pref.isVisible = enabled }
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
|
||||
// Source enable/disable
|
||||
if (source is ConfigurableSource) {
|
||||
switchSettingsPreference {
|
||||
block()
|
||||
onSettingsClick = View.OnClickListener {
|
||||
router.pushController(
|
||||
SourcePreferencesController(source.id).withFadeTransaction()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switchPreference(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiSource && !isMultiLangSingleSource) {
|
||||
preferenceCategory {
|
||||
title = LocaleHelper.getSourceDisplayName(it.key, context)
|
||||
|
||||
preferenceBlock()
|
||||
}
|
||||
} else {
|
||||
preferenceBlock()
|
||||
}
|
||||
}
|
||||
if (isMultiSource && isMultiLangSingleSource.not()) {
|
||||
multiLanguagePreference(context, extension.sources)
|
||||
} else {
|
||||
singleLanguagePreference(context, extension.sources)
|
||||
}
|
||||
}
|
||||
|
||||
return PreferenceGroupAdapter(screen)
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.singleLanguagePreference(context: Context, sources: List<Source>) {
|
||||
sources
|
||||
.map { source -> LocaleHelper.getSourceDisplayName(source.lang, context) to source }
|
||||
.sortedWith(compareBy({ (_, source) -> !source.isEnabled() }, { (lang, _) -> lang.lowercase() }))
|
||||
.forEach { (lang, source) ->
|
||||
val preferenceBlock = {
|
||||
sourceSwitchPreference(source, LocaleHelper.getSourceDisplayName(lang, context))
|
||||
}
|
||||
|
||||
preferenceBlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.multiLanguagePreference(context: Context, sources: List<Source>) {
|
||||
sources
|
||||
.groupBy { (it as CatalogueSource).lang }
|
||||
.toSortedMap(compareBy { LocaleHelper.getSourceDisplayName(it, context) })
|
||||
.forEach { entry ->
|
||||
val preferenceBlock = {
|
||||
entry.value
|
||||
.sortedWith(compareBy({ source -> !source.isEnabled() }, { source -> source.name.lowercase() }))
|
||||
.forEach { source ->
|
||||
sourceSwitchPreference(source, source.toString())
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory {
|
||||
title = LocaleHelper.getSourceDisplayName(entry.key, context)
|
||||
|
||||
preferenceBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.sourceSwitchPreference(source: Source, name: String) {
|
||||
val block: (@DSL SwitchPreferenceCompat).() -> Unit = {
|
||||
key = source.getPreferenceKey()
|
||||
title = name
|
||||
isPersistent = false
|
||||
isChecked = source.isEnabled()
|
||||
|
||||
onChange { newValue ->
|
||||
val checked = newValue as Boolean
|
||||
toggleSource(source, checked)
|
||||
true
|
||||
}
|
||||
|
||||
// React to enable/disable all changes
|
||||
preferences.disabledSources().asFlow()
|
||||
.onEach {
|
||||
val enabled = source.isEnabled()
|
||||
isChecked = enabled
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
|
||||
// Source enable/disable
|
||||
if (source is ConfigurableSource) {
|
||||
switchSettingsPreference {
|
||||
block()
|
||||
onSettingsClick = View.OnClickListener {
|
||||
router.pushController(
|
||||
SourcePreferencesController(source.id).withFadeTransaction()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switchPreference(block)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
preferenceScreen = null
|
||||
super.onDestroyView(view)
|
||||
@ -190,7 +199,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
R.id.action_history -> openCommitHistory()
|
||||
R.id.action_enable_all -> toggleAllSources(true)
|
||||
R.id.action_disable_all -> toggleAllSources(false)
|
||||
R.id.action_open_in_settings -> openInSettings()
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
@ -221,13 +229,6 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
openInBrowser(url)
|
||||
}
|
||||
|
||||
private fun openInSettings() {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", presenter.pkgName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun Source.isEnabled(): Boolean {
|
||||
return id.toString() !in preferences.disabledSources().get()
|
||||
}
|
||||
@ -237,10 +238,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
|
||||
activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true)
|
||||
return ContextThemeWrapper(activity, tv.resourceId)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PKGNAME_KEY = "pkg_name"
|
||||
|
||||
private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
|
||||
}
|
||||
}
|
||||
|
||||
private const val PKGNAME_KEY = "pkg_name"
|
||||
private const val URL_EXTENSION_COMMITS = "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
|
||||
|
@ -34,25 +34,28 @@ class ExtensionDetailsHeaderAdapter(private val presenter: ExtensionDetailsPrese
|
||||
val extension = presenter.extension ?: return
|
||||
val context = view.context
|
||||
|
||||
extension.getApplicationIcon(context)?.let { binding.extensionIcon.setImageDrawable(it) }
|
||||
binding.extensionTitle.text = extension.name
|
||||
binding.extensionVersion.text = context.getString(R.string.ext_version_info, extension.versionName)
|
||||
binding.extensionLang.text = context.getString(R.string.ext_language_info, LocaleHelper.getSourceDisplayName(extension.lang, context))
|
||||
binding.extensionNsfw.isVisible = extension.isNsfw
|
||||
binding.extensionPkg.text = extension.pkgName
|
||||
extension.getApplicationIcon(context)?.let { binding.icon.setImageDrawable(it) }
|
||||
binding.title.text = extension.name
|
||||
binding.version.text = context.getString(R.string.ext_version_info, extension.versionName)
|
||||
binding.lang.text = context.getString(R.string.ext_language_info, LocaleHelper.getSourceDisplayName(extension.lang, context))
|
||||
binding.nsfw.isVisible = extension.isNsfw
|
||||
binding.pkgname.text = extension.pkgName
|
||||
|
||||
binding.extensionUninstallButton.clicks()
|
||||
binding.btnUninstall.clicks()
|
||||
.onEach { presenter.uninstallExtension() }
|
||||
.launchIn(presenter.presenterScope)
|
||||
binding.btnAppInfo.clicks()
|
||||
.onEach { presenter.openInSettings() }
|
||||
.launchIn(presenter.presenterScope)
|
||||
|
||||
if (extension.isObsolete) {
|
||||
binding.extensionWarningBanner.isVisible = true
|
||||
binding.extensionWarningBanner.setText(R.string.obsolete_extension_message)
|
||||
binding.warningBanner.isVisible = true
|
||||
binding.warningBanner.setText(R.string.obsolete_extension_message)
|
||||
}
|
||||
|
||||
if (extension.isUnofficial) {
|
||||
binding.extensionWarningBanner.isVisible = true
|
||||
binding.extensionWarningBanner.setText(R.string.unofficial_extension_message)
|
||||
binding.warningBanner.isVisible = true
|
||||
binding.warningBanner.setText(R.string.unofficial_extension_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,21 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.extension.details
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class ExtensionDetailsPresenter(
|
||||
val pkgName: String,
|
||||
private val extensionManager: ExtensionManager = Injekt.get()
|
||||
private val controller: ExtensionDetailsController,
|
||||
private val pkgName: String,
|
||||
) : BasePresenter<ExtensionDetailsController>() {
|
||||
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
|
||||
val extension = extensionManager.installedExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
@ -36,4 +40,11 @@ class ExtensionDetailsPresenter(
|
||||
val extension = extension ?: return
|
||||
extensionManager.uninstallExtension(extension.pkgName)
|
||||
}
|
||||
|
||||
fun openInSettings() {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", pkgName, null)
|
||||
}
|
||||
controller.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.getPreferenceKey
|
||||
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
|
||||
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
|
||||
import timber.log.Timber
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@ -113,6 +114,13 @@ class SourcePreferencesController(bundle: Bundle? = null) :
|
||||
pref.isIconSpaceReserved = false
|
||||
pref.order = Int.MAX_VALUE // reset to default order
|
||||
|
||||
// Apply incognito IME for EditTextPreference
|
||||
if (pref is EditTextPreference) {
|
||||
pref.setOnBindEditTextListener {
|
||||
it.setIncognito(viewScope)
|
||||
}
|
||||
}
|
||||
|
||||
newScreen.removePreference(pref)
|
||||
screen.addPreference(pref)
|
||||
}
|
||||
@ -159,9 +167,7 @@ class SourcePreferencesController(bundle: Bundle? = null) :
|
||||
// [key] isn't useful since there may be duplicates
|
||||
return preferenceScreen!!.getPreference(lastOpenPreferencePosition!!) as T
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOURCE_ID = "source_id"
|
||||
const val LASTOPENPREFERENCE_KEY = "last_open_preference"
|
||||
}
|
||||
}
|
||||
|
||||
private const val SOURCE_ID = "source_id"
|
||||
private const val LASTOPENPREFERENCE_KEY = "last_open_preference"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user