Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e1b3abcf6 | ||
|
|
97bb65aa35 | ||
|
|
98c1a377ca | ||
|
|
2d2ce3281e | ||
|
|
a28d3ba840 | ||
|
|
ab2e4a8e2d | ||
|
|
67ddba87b5 | ||
|
|
8164847fcb | ||
|
|
85b538bbd3 | ||
|
|
d00d700a4b | ||
|
|
ae66318843 | ||
|
|
1b9aa6a762 | ||
|
|
306ce83de4 | ||
|
|
89f9735ab9 | ||
|
|
843e1cc6a0 | ||
|
|
1a53d0d207 | ||
|
|
45c02d4fd6 | ||
|
|
862dee0845 | ||
|
|
809bbfeba7 | ||
|
|
959c740175 | ||
|
|
a718510c89 | ||
|
|
f7c2fd8af3 | ||
|
|
c76440f167 | ||
|
|
c3bd959ee5 | ||
|
|
0a25643b87 | ||
|
|
c805ca8aff | ||
|
|
699a1ab801 | ||
|
|
ee091aee0f | ||
|
|
673c1837e9 | ||
|
|
3998913c17 | ||
|
|
1e83d7128e | ||
|
|
355289a576 | ||
|
|
ba3a41f0ee | ||
|
|
fc6d0bd6ef | ||
|
|
15ef0ae362 | ||
|
|
a598f8562d | ||
|
|
2860fed5ab | ||
|
|
f247544439 | ||
|
|
fdc3d94d5c | ||
|
|
afc2ff7478 | ||
|
|
83072d4eee | ||
|
|
7e7be8d40a | ||
|
|
90d7555333 | ||
|
|
ba268cfe6d | ||
|
|
0a7a9beb84 | ||
|
|
b18b89db29 | ||
|
|
cb2d99bdc1 | ||
|
|
4dd6d68163 | ||
|
|
28ecc9d479 | ||
|
|
c917117db3 | ||
|
|
5c0d65dd60 | ||
|
|
dfc59aa643 | ||
|
|
6ab77e541f | ||
|
|
6224f8f4ab | ||
|
|
3d269c4220 | ||
|
|
176a802e96 | ||
|
|
417a06e140 | ||
|
|
5c4f034532 | ||
|
|
1d50855f52 | ||
|
|
0280450797 | ||
|
|
d0c907c7ff | ||
|
|
1302e17c01 | ||
|
|
33f83b7325 | ||
|
|
d2c49f5022 | ||
|
|
084883a308 | ||
|
|
67a73da720 | ||
|
|
f2447fd122 | ||
|
|
8ddcb003e0 | ||
|
|
0bff011f72 | ||
|
|
f41d1d7306 | ||
|
|
a30aa23b67 | ||
|
|
583d30eb5f | ||
|
|
c53b0986a9 | ||
|
|
85703c8c47 | ||
|
|
025ba0bf5b | ||
|
|
ac2def22d2 | ||
|
|
895cfc7bc6 | ||
|
|
49eb9d5077 | ||
|
|
5a3c165ff1 | ||
|
|
3946428df6 | ||
|
|
471d9784d6 | ||
|
|
0ba0afc0d0 | ||
|
|
5049b791fb | ||
|
|
9a3715c516 | ||
|
|
4ee276f636 | ||
|
|
d528b2d5d0 | ||
|
|
b3ee69d193 | ||
|
|
f882b78ff6 | ||
|
|
7e58101a3f | ||
|
|
5450eb9b2b | ||
|
|
69695e3c99 | ||
|
|
5b15059c04 | ||
|
|
f50d69cd60 | ||
|
|
df6f783a69 | ||
|
|
f0291d94f9 | ||
|
|
593f24b86f | ||
|
|
5bce898f99 | ||
|
|
2b444c262d | ||
|
|
e40a7c75d9 | ||
|
|
4a7c057260 | ||
|
|
229a00cd53 | ||
|
|
d2d0d880f1 | ||
|
|
c5e17c1d5b | ||
|
|
33ff2ebe06 | ||
|
|
5c13e188de | ||
|
|
60b03dcd0e | ||
|
|
4604a25f58 | ||
|
|
99029b8b04 | ||
|
|
70f46442c6 | ||
|
|
13cf42437f | ||
|
|
1e29f6dcb4 | ||
|
|
f25981792f | ||
|
|
934c8be3e4 | ||
|
|
b9994eacbc | ||
|
|
704ab0ee7e | ||
|
|
6d1d737f9f | ||
|
|
bc75156232 | ||
|
|
2101ea7a36 | ||
|
|
26921c8ae1 | ||
|
|
be1eecf3b5 | ||
|
|
2d4f154c9b | ||
|
|
d82cbe51ba | ||
|
|
d13ffa93ba | ||
|
|
2cce55953f | ||
|
|
07d100fcc0 | ||
|
|
cc04371181 | ||
|
|
ee50c88765 | ||
|
|
efc5b55ea7 | ||
|
|
94af66156a | ||
|
|
4861359f9e | ||
|
|
5af167dab4 | ||
|
|
db1f0bc6d7 | ||
|
|
b94a843ec5 | ||
|
|
b27d214f24 | ||
|
|
a81eb9d17b | ||
|
|
ec9b85f30f | ||
|
|
8ac2f33949 | ||
|
|
d84198ab28 | ||
|
|
34d339d9f1 | ||
|
|
bcfefc7c76 | ||
|
|
62539dc48e | ||
|
|
453ab11395 | ||
|
|
b2246b980a | ||
|
|
822a461acd | ||
|
|
25e5c0ab8e | ||
|
|
2a55db95f4 | ||
|
|
a144866182 | ||
|
|
be7a04a4cb | ||
|
|
fd1b861dab | ||
|
|
957ebd35aa | ||
|
|
29d8b11771 | ||
|
|
c87059e9d7 | ||
|
|
4babe2eec4 | ||
|
|
48fdaaca0c | ||
|
|
f0ee88b4d6 | ||
|
|
9210780b6b | ||
|
|
b479e28543 | ||
|
|
f93b98fc5e | ||
|
|
4721d1011b | ||
|
|
4da4443e67 | ||
|
|
e5343f4360 | ||
|
|
86323220dc | ||
|
|
ada8f3af75 | ||
|
|
fb940d3982 | ||
|
|
8e8729c25f | ||
|
|
ffdd063ca1 | ||
|
|
dfd5c6c70e | ||
|
|
3068aab681 | ||
|
|
e402587f8f | ||
|
|
00b1be879d | ||
|
|
e63bd49a4c | ||
|
|
8131170eb1 | ||
|
|
dc1abd3806 | ||
|
|
468fbd50da | ||
|
|
f2011f9b6f | ||
|
|
1e53a9b389 | ||
|
|
dc2504b67f | ||
|
|
2ae3e3b64c | ||
|
|
e6bdaf1172 | ||
|
|
36a66d9199 | ||
|
|
fb6fbb8df9 | ||
|
|
ef8449dc02 | ||
|
|
b59f8e9ade | ||
|
|
a49506f4a0 | ||
|
|
4ce3d25eed | ||
|
|
81b166bee1 | ||
|
|
37a587859a | ||
|
|
b7544630bb | ||
|
|
05c7a0fa39 | ||
|
|
eee7655f80 | ||
|
|
9390813c48 | ||
|
|
d032fbdd67 | ||
|
|
adcd73a590 | ||
|
|
87b405bf1f | ||
|
|
d8306e2c80 | ||
|
|
c03fe9b51e | ||
|
|
9099014d28 | ||
|
|
d7b3872486 | ||
|
|
4ae2af0bef | ||
|
|
c9bcd46b77 | ||
|
|
e531653634 | ||
|
|
a29e4c72b1 | ||
|
|
9e1f7ca051 | ||
|
|
fbbc347411 | ||
|
|
ea364536a4 | ||
|
|
575fc9e94c | ||
|
|
703c63125a | ||
|
|
b205b5e120 | ||
|
|
e27f36d2c8 | ||
|
|
d582eddc03 | ||
|
|
3b83d8744c | ||
|
|
68a5217b1c | ||
|
|
c01082b822 | ||
|
|
bb95c86320 | ||
|
|
d398926aeb | ||
|
|
7b23efb0ce | ||
|
|
7818ff197c | ||
|
|
95bfc4f8c5 | ||
|
|
c750016034 | ||
|
|
6298e501cd | ||
|
|
7c39b95025 | ||
|
|
02c1e630d1 | ||
|
|
8f3b0cc536 | ||
|
|
d0c5ef8e8a | ||
|
|
d628024637 | ||
|
|
fe1249630b | ||
|
|
cdbe3cf728 | ||
|
|
f5e36bc0cf | ||
|
|
4f9bfe13cb | ||
|
|
959da21b9d | ||
|
|
fae2b1f416 | ||
|
|
a054633d0a | ||
|
|
a4619547f1 | ||
|
|
5d53febb15 | ||
|
|
3da1587924 | ||
|
|
971d0307fe | ||
|
|
705d3afb78 | ||
|
|
bc34b4b9c1 | ||
|
|
a2ac01fcdc | ||
|
|
645ebddd4f | ||
|
|
d5d8a828ef | ||
|
|
96d4193d2d | ||
|
|
18e4afdff9 | ||
|
|
19a702ffc4 | ||
|
|
cfd44c256f | ||
|
|
4cea8d0090 | ||
|
|
30e166b29c | ||
|
|
c50f3d8c5b | ||
|
|
d9249eeedb | ||
|
|
51699cafe6 | ||
|
|
ce97976bdd | ||
|
|
7b8a12e3eb | ||
|
|
7276c37c54 | ||
|
|
4ec7de611c | ||
|
|
0295c40b02 | ||
|
|
035fa44523 | ||
|
|
60c6618db1 | ||
|
|
f35643b2b6 | ||
|
|
b25661c436 | ||
|
|
cea06f0c7e | ||
|
|
7de6a821d9 | ||
|
|
13be4e4eb1 | ||
|
|
691e7c3ba7 | ||
|
|
31aea1cdbf | ||
|
|
97d5642427 | ||
|
|
c68272c1a4 | ||
|
|
a69dc994b3 | ||
|
|
d79a71c764 | ||
|
|
35183de477 | ||
|
|
21b9fe3cb2 | ||
|
|
5762b0bfc3 | ||
|
|
3473e91747 | ||
|
|
6277d7d41d | ||
|
|
f22da2b771 | ||
|
|
3466bbd64a | ||
|
|
49fc87621c | ||
|
|
7e558f4478 | ||
|
|
307624ca9a | ||
|
|
40e66b919a | ||
|
|
cf2e0aadd5 | ||
|
|
1b523f6a78 | ||
|
|
6a9f6bb645 | ||
|
|
45602b05c6 | ||
|
|
8dc842e125 | ||
|
|
9dfc3a49fe | ||
|
|
97d4a61140 | ||
|
|
8b596e2274 | ||
|
|
123b86830b | ||
|
|
e11f5615a1 | ||
|
|
31fb2ed2b1 | ||
|
|
9c12d11c85 | ||
|
|
aea0e885c3 | ||
|
|
f34ff71145 | ||
|
|
820c16e86b | ||
|
|
a335348b1a | ||
|
|
7f134e1995 | ||
|
|
9e13c1c3db | ||
|
|
61161cef0f | ||
|
|
9ff74e789c | ||
|
|
45183df640 | ||
|
|
22f11994f5 | ||
|
|
bc7aa832b7 | ||
|
|
b32f78ea9f | ||
|
|
78002dac3a | ||
|
|
aca70354e7 | ||
|
|
d7a3471b9b | ||
|
|
e81092d42c | ||
|
|
d06df3c819 | ||
|
|
050bfea8f2 | ||
|
|
7b84c74abf | ||
|
|
009c49cd89 | ||
|
|
ca023f73f4 | ||
|
|
456dfd2aac | ||
|
|
0c0291127c | ||
|
|
fe0c74de23 | ||
|
|
df1efa7604 | ||
|
|
73122fc517 | ||
|
|
b506ba277c | ||
|
|
ea3aed8fae | ||
|
|
481bb8e7d4 | ||
|
|
0ed8c40dbf | ||
|
|
d267d7c3b0 | ||
|
|
a04206f600 | ||
|
|
8d64a6f1b2 | ||
|
|
b3f614e150 | ||
|
|
988fae56fd | ||
|
|
fa439fc62d | ||
|
|
c58b2d6156 | ||
|
|
62c56b7918 | ||
|
|
73882ab55d | ||
|
|
490ea2b521 | ||
|
|
7116871652 | ||
|
|
cca374c774 | ||
|
|
3e0dbf92d0 | ||
|
|
2747ae674c | ||
|
|
e85ec5aaba | ||
|
|
d4894e066b |
No files matched your search
@@ -0,0 +1 @@
|
||||
*.sh eol=lf
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Baseline profile generation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 0 * * 0'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'benchmark/**'
|
||||
- '.github/workflows/baseline-profile.yml'
|
||||
|
||||
jobs:
|
||||
baseline-profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Free disk space
|
||||
run: rm -rf ${ANDROID_SDK_ROOT}/ndk
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./app/src/main/rust"
|
||||
shared-key: ${{ runner.os }}
|
||||
save-if: "false"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-
|
||||
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew generateBaselineProfile
|
||||
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
|
||||
|
||||
- name: Update baseline profile
|
||||
if: github.ref == 'refs/heads/main'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config push.autoSetupRemote true
|
||||
git switch -c chore/baseline-profile
|
||||
if git commit -am "Update baseline profile"; then
|
||||
git push
|
||||
gh pr create -f
|
||||
fi
|
||||
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: reports
|
||||
path: benchmark/build/reports
|
||||
+21
-17
@@ -21,7 +21,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -42,9 +42,15 @@ jobs:
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
- name: Lint Check
|
||||
run: ./gradlew lintMarshmallowRelease
|
||||
|
||||
default:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -57,8 +63,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -81,6 +90,8 @@ jobs:
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleDefaultRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
@@ -101,17 +112,11 @@ jobs:
|
||||
name: default-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload x86
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-x86-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultRelease/mapping.txt
|
||||
path: app/build/outputs/mapping/defaultRelease
|
||||
|
||||
- name: Upload native debug symbols
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -131,8 +136,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -155,6 +163,8 @@ jobs:
|
||||
restore-keys: cmake-marshmallow-
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
run: ./gradlew assembleMarshmallowRelease
|
||||
|
||||
- name: Upload ARM64
|
||||
@@ -175,17 +185,11 @@ jobs:
|
||||
name: marshmallow-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload x86
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/marshmallowRelease/mapping.txt
|
||||
path: app/build/outputs/mapping/marshmallowRelease
|
||||
|
||||
- name: Upload native debug symbols
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -2,7 +2,7 @@ name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -18,8 +18,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -35,6 +38,8 @@ jobs:
|
||||
|
||||
- name: Gradle Build
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GithubToken }}
|
||||
with:
|
||||
arguments: assembleRelease
|
||||
|
||||
@@ -43,7 +48,6 @@ jobs:
|
||||
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86-release.apk EhViewer-${{ github.ref_name }}-default-x86.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
@@ -51,7 +55,6 @@ jobs:
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
@@ -64,7 +67,6 @@ jobs:
|
||||
EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-default-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
@@ -72,7 +74,6 @@ jobs:
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
@@ -0,0 +1,29 @@
|
||||
name: 'Update Chrome Version'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Chrome Version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd chrome-for-testing
|
||||
curl -OLf https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config push.autoSetupRemote true
|
||||
git switch -c chore/chrome
|
||||
if git commit -am "Update Chrome version for default User-Agent"; then
|
||||
git push
|
||||
gh pr create -f
|
||||
gh pr merge chore/chrome --auto -dr
|
||||
fi
|
||||
@@ -1,5 +1,103 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.11.7] - 2024-07-10
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 无法下载标题包含 # 或 % 的画廊
|
||||
* 下载页数较多的画廊时应用无响应
|
||||
* 退出搜索栏时文本闪烁
|
||||
|
||||
|
||||
## [1.11.6] - 2024-07-09 [YANKED]
|
||||
|
||||
### 改进
|
||||
|
||||
* 某些设备上无法加载长图时可尝试减小 设置-高级 中的硬件位图阈值 [#1321](https://github.com/FooIbar/EhViewer/issues/1321)
|
||||
* 清理下载冗余时显示确认对话框 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
|
||||
* 修改收藏时显示收藏备注 [#1392](https://github.com/FooIbar/EhViewer/issues/1392)
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 图片完整性校验误报 [#1286](https://github.com/FooIbar/EhViewer/issues/1286)
|
||||
* 某些情况下应用崩溃 [#1290](https://github.com/FooIbar/EhViewer/issues/1290) [#1302](https://github.com/FooIbar/EhViewer/issues/1302)
|
||||
* 某些情况下渲染问题 [#1284](https://github.com/FooIbar/EhViewer/issues/1284)
|
||||
* 无法下载标题过长的画廊 [#1340](https://github.com/FooIbar/EhViewer/issues/1340)
|
||||
* 某些界面拖动滚动条时黑屏 [#1329](https://github.com/FooIbar/EhViewer/issues/1329)
|
||||
* 搜索栏多行文本遮挡其他内容 [#1365](https://github.com/FooIbar/EhViewer/issues/1365)
|
||||
* CIFS Documents Provider 兼容问题 [#1356](https://github.com/FooIbar/EhViewer/issues/1356)
|
||||
* 清理下载冗余未按预期运行 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
|
||||
* 读取压缩包失败时崩溃
|
||||
* 某些设备上崩溃 [#1276](https://github.com/FooIbar/EhViewer/issues/1276)
|
||||
* 检查更新失效
|
||||
|
||||
|
||||
## [1.11.6-RC2] - 2024-06-06
|
||||
|
||||
### 改进
|
||||
|
||||
* 下载图片时执行完整性校验以防止文件损坏/被篡改
|
||||
|
||||
|
||||
## [1.11.6-RC1] - 2024-06-02
|
||||
|
||||
### 改进
|
||||
|
||||
* 下载列表默认显示全部下载项目
|
||||
* 替换搜索文本中的换行符为空格 [#1258](https://github.com/FooIbar/EhViewer/issues/1258)
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 评价画廊时未使用输入的评分
|
||||
* 搜索时意外选择 Non-H 类别 [#1246](https://github.com/FooIbar/EhViewer/issues/1246)
|
||||
* 侧边栏与导航抽屉重叠 [#1262](https://github.com/FooIbar/EhViewer/issues/1262)
|
||||
* 快速搜索/下载标签排序失效 [#1267](https://github.com/FooIbar/EhViewer/issues/1267)
|
||||
* 加载压缩包/种子列表出错时 UI 冻结
|
||||
* 低版本 Android 上某些 UI 被遮挡
|
||||
|
||||
|
||||
## [1.11.5] - 2024-05-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 不再支持 32 位 x86
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持按作者对下载分组
|
||||
* 由于 E 站不再强制要求人机验证,重新支持账号密码登录
|
||||
|
||||
### 改进
|
||||
|
||||
* 在登录时加载收藏名称 [#1077](https://github.com/FooIbar/EhViewer/issues/1077)
|
||||
* 优化归档和种子界面 UI
|
||||
* 禁止在 cookie 界面截图以防止泄露
|
||||
* 在缩略图模式下显示画廊页数
|
||||
* 记住最低评分和页数搜索参数
|
||||
* 更新 User-Agent 以缓解 IP 封禁 [#1182](https://github.com/FooIbar/EhViewer/issues/1182)
|
||||
* 账号密码登录支持自动填充
|
||||
* 更新中文(台灣)翻译
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 非触摸模式下无法退出搜索界面 [#1060](https://github.com/FooIbar/EhViewer/issues/1060)
|
||||
* 某些设备上无法安装更新 [#1067](https://github.com/FooIbar/EhViewer/issues/1067)
|
||||
* 某些设备上崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
|
||||
* 搜索记录中存在重复条目时崩溃 [#1130](https://github.com/FooIbar/EhViewer/issues/1130)
|
||||
* 条漫模式下缩小后点按区域偏移 [#127](https://github.com/FooIbar/EhViewer/issues/127)
|
||||
* 多窗口模式下点按区域偏移
|
||||
* 点击下载通知时未清除状态
|
||||
* 保存未完成加载的图片时崩溃 [#1154](https://github.com/FooIbar/EhViewer/issues/1154)
|
||||
* 历史记录界面无法在项目上滑动打开抽屉 [#464](https://github.com/FooIbar/EhViewer/issues/464)
|
||||
* 导入数据失败时显示导入成功 [#1174](https://github.com/FooIbar/EhViewer/issues/1174)
|
||||
* 某些条件下收藏界面崩溃 [#1190](https://github.com/FooIbar/EhViewer/issues/1190)
|
||||
* 反转点按区域功能失效 [#1217](https://github.com/FooIbar/EhViewer/issues/1217)
|
||||
|
||||
### 已知问题
|
||||
|
||||
* 某些设备上从后台返回时某些 UI 消失,可通过关闭列表项目动画部分缓解 [#1184](https://github.com/FooIbar/EhViewer/issues/1184)
|
||||
|
||||
|
||||
## [1.11.4] - 2024-04-21
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -85,13 +85,13 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
Here is the libraries
|
||||
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [AOSP & AndroidX](http://source.android.com/)
|
||||
- [AOSP & AndroidX](https://source.android.com/)
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
+87
-55
@@ -1,8 +1,6 @@
|
||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
@@ -17,18 +15,23 @@ plugins {
|
||||
alias(libs.plugins.spotless)
|
||||
alias(libs.plugins.aboutlibrariesPlugin)
|
||||
alias(libs.plugins.composeCompilerReportGenerator)
|
||||
alias(libs.plugins.baselineprofile)
|
||||
}
|
||||
|
||||
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
ndkVersion = "26.2.11394342"
|
||||
compileSdk = if (isRelease) 35 else 34
|
||||
buildToolsVersion = "35.0.0"
|
||||
ndkVersion = "27.0.11902837-rc1"
|
||||
androidResources.generateLocaleConfig = true
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
include(*supportedAbis)
|
||||
isUniversalApk = true
|
||||
} else {
|
||||
include("arm64-v8a", "x86_64")
|
||||
@@ -49,23 +52,28 @@ android {
|
||||
commandLine = "git rev-parse --short=7 HEAD".split(' ')
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
val buildTime by lazy {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC)
|
||||
formatter.format(Instant.now())
|
||||
}
|
||||
val commitTime = providers.exec {
|
||||
commandLine = "git log -1 --format=%ct".split(' ')
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
val repoName = providers.exec {
|
||||
commandLine = "git remote get-url origin".split(' ')
|
||||
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
|
||||
.removeSuffix(".git")
|
||||
|
||||
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
|
||||
.readText().substringBefore('.')
|
||||
|
||||
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
|
||||
?: System.getenv("GITHUB_TOKEN").orEmpty()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 180055
|
||||
versionName = "1.11.4"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
targetSdk = 35
|
||||
versionCode = 180058
|
||||
versionName = "1.11.7"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -84,10 +92,21 @@ android {
|
||||
)
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("long", "COMMIT_TIME", commitTime)
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
|
||||
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
|
||||
ndk {
|
||||
if (isRelease) {
|
||||
abiFilters.addAll(supportedAbis)
|
||||
}
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "api"
|
||||
@@ -111,34 +130,10 @@ android {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = listOf(
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
"-Xcontext-receivers",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.paging.ExperimentalPagingApi",
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
fatal += setOf("NewApi", "InlinedApi")
|
||||
error += setOf("InlinedApi")
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -155,11 +150,19 @@ android {
|
||||
isShrinkResources = true
|
||||
proguardFiles("proguard-rules.pro")
|
||||
signingConfig = signConfig
|
||||
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
buildConfigField("String", "BUILD_TIME", "\"\"")
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
create("benchmarkRelease") {
|
||||
initWith(buildTypes.getByName("release"))
|
||||
matchingFallbacks += listOf("release")
|
||||
applicationIdSuffix = ".benchmark"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isDebuggable = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +176,8 @@ android {
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
// https://youtrack.jetbrains.com/issue/KT-67216
|
||||
suppressKotlinVersionCompatibilityCheck = libs.versions.kotlin.get()
|
||||
enableIntrinsicRemember = true
|
||||
enableNonSkippingGroupOptimization = true
|
||||
enableExperimentalStrongSkippingMode = true
|
||||
enableStrongSkippingMode = true
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
@@ -191,6 +191,10 @@ androidComponents {
|
||||
}
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/activity
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -209,25 +213,25 @@ dependencies {
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.graphics.path)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
implementation(libs.photoview)
|
||||
implementation(libs.material.motion.core)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
@@ -244,10 +248,11 @@ dependencies {
|
||||
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
implementation(libs.reorderable)
|
||||
// implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
implementation(libs.arrow.resilience)
|
||||
|
||||
// https://coil-kt.github.io/coil/changelog/
|
||||
implementation(platform(libs.coil.bom))
|
||||
@@ -267,17 +272,42 @@ dependencies {
|
||||
|
||||
implementation(libs.cronet.embedded)
|
||||
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
"baselineProfile"(project(":benchmark"))
|
||||
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
freeCompilerArgs = listOf(
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xcontext-receivers",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.paging.ExperimentalPagingApi",
|
||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
|
||||
}
|
||||
|
||||
@@ -286,13 +316,15 @@ aboutLibraries {
|
||||
duplicationRule = GROUP
|
||||
}
|
||||
|
||||
val ktlintVersion = libs.ktlint.get().version
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
// https://github.com/diffplug/spotless/issues/111
|
||||
target("src/**/*.kt")
|
||||
ktlint()
|
||||
ktlint(ktlintVersion)
|
||||
}
|
||||
kotlinGradle {
|
||||
ktlint()
|
||||
ktlint(ktlintVersion)
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -11,11 +11,20 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/347963245
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
|
||||
# https://issuetracker.google.com/222232895
|
||||
-dontwarn androidx.window.extensions.**
|
||||
-dontwarn androidx.window.sidecar.Sidecar*
|
||||
|
||||
-keepattributes LineNumberTable
|
||||
|
||||
-allowaccessmodification
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 23,
|
||||
"identityHash": "7d1cfc4e9a9d506a73eb0520ae414d25",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "GALLERIES",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_TAGS` TEXT, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "token",
|
||||
"columnName": "TOKEN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "TITLE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleJpn",
|
||||
"columnName": "TITLE_JPN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbKey",
|
||||
"columnName": "THUMB",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "CATEGORY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "posted",
|
||||
"columnName": "POSTED",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "UPLOADER",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "rating",
|
||||
"columnName": "RATING",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleTags",
|
||||
"columnName": "SIMPLE_TAGS",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pages",
|
||||
"columnName": "PAGES",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleLanguage",
|
||||
"columnName": "SIMPLE_LANGUAGE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "favoriteSlot",
|
||||
"columnName": "FAVORITE_SLOT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_LABELS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "LABEL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "POSITION",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_DOWNLOAD_LABELS_LABEL",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"LABEL"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOADS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "state",
|
||||
"columnName": "STATE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "legacy",
|
||||
"columnName": "LEGACY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "LABEL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_DOWNLOADS_LABEL",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"LABEL"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "DOWNLOAD_LABELS",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"LABEL"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"LABEL"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_DIRNAME",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dirname",
|
||||
"columnName": "DIRNAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_ARTISTS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `ARTIST` TEXT NOT NULL, PRIMARY KEY(`GID`, `ARTIST`), FOREIGN KEY(`GID`) REFERENCES `DOWNLOADS`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "ARTIST",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID",
|
||||
"ARTIST"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "DOWNLOADS",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "FILTER",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "MODE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "TEXT",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enable",
|
||||
"columnName": "ENABLE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "HISTORY",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "LOCAL_FAVORITES",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "PROGRESS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "page",
|
||||
"columnName": "PAGE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "QUICK_SEARCH",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "MODE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "CATEGORY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyword",
|
||||
"columnName": "KEYWORD",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "advanceSearch",
|
||||
"columnName": "ADVANCE_SEARCH",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minRating",
|
||||
"columnName": "MIN_RATING",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pageFrom",
|
||||
"columnName": "PAGE_FROM",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pageTo",
|
||||
"columnName": "PAGE_TO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "POSITION",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "GALLERIES",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END"
|
||||
],
|
||||
"tableName": "GALLERIES_FTS",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, `SIMPLE_TAGS` TEXT, content=`GALLERIES`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "TITLE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleJpn",
|
||||
"columnName": "TITLE_JPN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleTags",
|
||||
"columnName": "SIMPLE_TAGS",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d1cfc4e9a9d506a73eb0520ae414d25')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="@bool/large_heap"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
@@ -101,23 +100,24 @@
|
||||
<data android:scheme="content" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:mimeType="application/7z" />
|
||||
<data android:mimeType="application/rar" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/x-7z-compressed" />
|
||||
<data android:mimeType="application/x-rar-compressed" />
|
||||
<data android:mimeType="application/x-zip-compressed" />
|
||||
<data android:mimeType="application/x-tar" />
|
||||
<data android:mimeType="application/x-xz" />
|
||||
<data android:mimeType="application/gzip" />
|
||||
<data android:mimeType="application/vnd.rar" />
|
||||
<data android:mimeType="application/vnd.comicbook+zip" />
|
||||
<data android:mimeType="application/vnd.comicbook-rar" />
|
||||
|
||||
<!-- unofficial -->
|
||||
<data android:mimeType="application/7z" />
|
||||
<data android:mimeType="application/rar" />
|
||||
<data android:mimeType="application/x-cbz" />
|
||||
<data android:mimeType="application/x-cbr" />
|
||||
<data android:mimeType="application/x-cbt" />
|
||||
<data android:mimeType="application/x-cb7" />
|
||||
|
||||
<!-- untested -->
|
||||
<data android:mimeType="application/vnd.comicbook+zip" />
|
||||
<data android:mimeType="application/vnd.comicbook-rar" />
|
||||
<data android:mimeType="application/x-zip-compressed" />
|
||||
<data android:mimeType="application/x-rar-compressed" />
|
||||
<data android:mimeType="application/x-compressed-tar" />
|
||||
<data android:mimeType="application/x-bzip-compressed-tar" />
|
||||
<data android:mimeType="application/x-lzma-compressed-tar" />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
From aee8f7147ddb262fd9e5feee8d5b17094cf3470f Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Tue, 26 Sep 2023 00:09:25 +0800
|
||||
Subject: [PATCH] Insert link libs
|
||||
|
||||
---
|
||||
CMakeLists.txt | 2 +-
|
||||
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index ec97e4c7..420f204c 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
# # endif
|
||||
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
|
||||
-SET(ADDITIONAL_LIBS "")
|
||||
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
|
||||
#
|
||||
# Find ZLIB
|
||||
#
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(ehviewer C)
|
||||
include(ExternalProject)
|
||||
include(FetchContent)
|
||||
|
||||
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
@@ -18,31 +19,47 @@ endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
|
||||
option(BUILD_TESTING OFF)
|
||||
|
||||
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
|
||||
FetchContent_Declare(
|
||||
liblzma
|
||||
GIT_REPOSITORY https://git.tukaani.org/xz.git
|
||||
GIT_TAG v5.6.1
|
||||
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
|
||||
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
|
||||
GIT_TAG v5.6.2
|
||||
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
|
||||
|
||||
# Build GNUTLS libnettle
|
||||
FetchContent_Declare(
|
||||
if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
|
||||
execute_process(
|
||||
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
|
||||
OUTPUT_VARIABLE CURRENT_DIR
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
else ()
|
||||
set(CURRENT_DIR ${CMAKE_CURRENT_LIST_DIR})
|
||||
endif ()
|
||||
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
|
||||
ExternalProject_Add(
|
||||
nettle
|
||||
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz
|
||||
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e
|
||||
SOURCE_DIR nettle-src/nettle
|
||||
PREFIX nettle
|
||||
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
|
||||
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
|
||||
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
|
||||
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
|
||||
INSTALL_COMMAND ""
|
||||
BUILD_IN_SOURCE 1
|
||||
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(nettle)
|
||||
set(NETTLE_DIR ${nettle_SOURCE_DIR})
|
||||
add_subdirectory(nettle)
|
||||
ExternalProject_Get_Property(nettle SOURCE_DIR)
|
||||
include_directories(${SOURCE_DIR}/..)
|
||||
add_library(libnettle STATIC IMPORTED)
|
||||
set_property(TARGET libnettle PROPERTY IMPORTED_LOCATION ${SOURCE_DIR}/libnettle.a)
|
||||
add_dependencies(libnettle nettle)
|
||||
|
||||
# Configure libnettle support for libarchive
|
||||
include_directories(${CMAKE_CURRENT_BINARY_DIR}/nettle-src)
|
||||
include_directories(.)
|
||||
set(HAVE_LIBNETTLE 1)
|
||||
set(HAVE_NETTLE_AES_H 1)
|
||||
set(HAVE_NETTLE_HMAC_H 1)
|
||||
@@ -58,20 +75,22 @@ SET(HAVE_LZMA_STREAM_ENCODER_MT 1)
|
||||
SET(HAVE_LZMADEC_H 1)
|
||||
SET(HAVE_LIBLZMADEC 1)
|
||||
|
||||
option(ENABLE_OPENSSL OFF)
|
||||
option(ENABLE_TAR OFF)
|
||||
option(ENABLE_CPIO OFF)
|
||||
option(ENABLE_CAT OFF)
|
||||
option(ENABLE_UNZIP OFF)
|
||||
option(ENABLE_TEST OFF)
|
||||
option(ENABLE_WERROR OFF)
|
||||
|
||||
# Configure libarchive link's static lib
|
||||
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
|
||||
SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
|
||||
|
||||
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
|
||||
GIT_TAG v3.7.3
|
||||
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
|
||||
GIT_TAG v3.7.4
|
||||
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(libarchive)
|
||||
@@ -80,12 +99,13 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
|
||||
FetchContent_Declare(
|
||||
Corrosion
|
||||
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
|
||||
GIT_TAG 0a3bdf452995803c334d79dc9a2affbfad51b720
|
||||
GIT_TAG v0.5
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
|
||||
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics)
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
|
||||
@@ -49,13 +49,14 @@ typedef struct {
|
||||
} entry;
|
||||
|
||||
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static archive_ctx **ctx_pool;
|
||||
static archive_ctx **ctx_pool = NULL;
|
||||
#define CTX_POOL_SIZE 20
|
||||
|
||||
static void *mempool = NULL;
|
||||
static size_t *mempoolofs = NULL;
|
||||
static int page_size = 0;
|
||||
|
||||
#define PAGE_ALIGN(x) ((x + ~PAGE_MASK) & PAGE_MASK)
|
||||
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
|
||||
|
||||
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
|
||||
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
|
||||
@@ -282,6 +283,7 @@ JNIEXPORT jint JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
page_size = getpagesize();
|
||||
archive_ctx *ctx = NULL;
|
||||
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (archiveAddr == MAP_FAILED) {
|
||||
@@ -401,6 +403,7 @@ JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
if (!ctx_pool) return;
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
archive_release_ctx(ctx_pool[i]);
|
||||
free(passwd);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
TARGET=$2
|
||||
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
AR=$TOOLCHAIN/llvm-ar \
|
||||
CC="$TOOLCHAIN/clang -target $TARGET" \
|
||||
RANLIB=$TOOLCHAIN/llvm-ranlib
|
||||
@@ -0,0 +1,39 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <jni.h>
|
||||
#include <nettle/sha1.h>
|
||||
|
||||
#include "ehviewer.h"
|
||||
|
||||
#define BUFFER_SIZE 8192
|
||||
|
||||
typedef uint8_t byte;
|
||||
|
||||
const char hex_digits[] = "0123456789abcdef";
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_hippo_ehviewer_jni_HashKt_sha1(JNIEnv *env, jclass clazz, jint fd) {
|
||||
EH_UNUSED(clazz);
|
||||
struct sha1_ctx ctx;
|
||||
sha1_init(&ctx);
|
||||
|
||||
size_t bytes_read;
|
||||
byte buffer[BUFFER_SIZE];
|
||||
while ((bytes_read = read(fd, buffer, BUFFER_SIZE)) > 0) {
|
||||
sha1_update(&ctx, bytes_read, buffer);
|
||||
}
|
||||
|
||||
byte digest[SHA1_DIGEST_SIZE];
|
||||
sha1_digest(&ctx, SHA1_DIGEST_SIZE, digest);
|
||||
|
||||
byte byte;
|
||||
char hex_digest[2 * SHA1_DIGEST_SIZE + 1];
|
||||
for (int i = 0; i < SHA1_DIGEST_SIZE; i++) {
|
||||
byte = digest[i];
|
||||
hex_digest[2 * i] = hex_digits[byte >> 4 & 0xF];
|
||||
hex_digest[2 * i + 1] = hex_digits[byte & 0xF];
|
||||
}
|
||||
hex_digest[2 * SHA1_DIGEST_SIZE] = '\0';
|
||||
|
||||
return (*env)->NewStringUTF(env, hex_digest);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.4.1)
|
||||
project(nettle C)
|
||||
|
||||
set(LIBNETTLE_DEFINITIONS
|
||||
-DHAVE_CONFIG_H)
|
||||
|
||||
set(LIBNETTLE_SOURCES
|
||||
${NETTLE_DIR}/aes-decrypt-internal.c
|
||||
${NETTLE_DIR}/aes-decrypt.c
|
||||
${NETTLE_DIR}/aes-decrypt-table.c
|
||||
${NETTLE_DIR}/aes128-decrypt.c
|
||||
${NETTLE_DIR}/aes192-decrypt.c
|
||||
${NETTLE_DIR}/aes256-decrypt.c
|
||||
${NETTLE_DIR}/aes-encrypt-internal.c
|
||||
${NETTLE_DIR}/aes-encrypt.c
|
||||
${NETTLE_DIR}/aes-encrypt-table.c
|
||||
${NETTLE_DIR}/aes128-encrypt.c
|
||||
${NETTLE_DIR}/aes192-encrypt.c
|
||||
${NETTLE_DIR}/aes256-encrypt.c
|
||||
${NETTLE_DIR}/aes-invert-internal.c
|
||||
${NETTLE_DIR}/aes-set-key-internal.c
|
||||
${NETTLE_DIR}/aes-set-encrypt-key.c
|
||||
${NETTLE_DIR}/aes-set-decrypt-key.c
|
||||
${NETTLE_DIR}/aes128-set-encrypt-key.c
|
||||
${NETTLE_DIR}/aes128-set-decrypt-key.c
|
||||
${NETTLE_DIR}/aes128-meta.c
|
||||
${NETTLE_DIR}/aes192-set-encrypt-key.c
|
||||
${NETTLE_DIR}/aes192-set-decrypt-key.c
|
||||
${NETTLE_DIR}/aes192-meta.c
|
||||
${NETTLE_DIR}/aes256-set-encrypt-key.c
|
||||
${NETTLE_DIR}/aes256-set-decrypt-key.c
|
||||
${NETTLE_DIR}/aes256-meta.c
|
||||
${NETTLE_DIR}/nist-keywrap.c
|
||||
${NETTLE_DIR}/arcfour.c
|
||||
${NETTLE_DIR}/arctwo.c
|
||||
${NETTLE_DIR}/arctwo-meta.c
|
||||
${NETTLE_DIR}/blowfish.c
|
||||
${NETTLE_DIR}/blowfish-bcrypt.c
|
||||
${NETTLE_DIR}/base16-encode.c
|
||||
${NETTLE_DIR}/base16-decode.c
|
||||
${NETTLE_DIR}/base16-meta.c
|
||||
${NETTLE_DIR}/base64-encode.c
|
||||
${NETTLE_DIR}/base64-decode.c
|
||||
${NETTLE_DIR}/base64-meta.c
|
||||
${NETTLE_DIR}/base64url-encode.c
|
||||
${NETTLE_DIR}/base64url-decode.c
|
||||
${NETTLE_DIR}/base64url-meta.c
|
||||
${NETTLE_DIR}/buffer.c
|
||||
${NETTLE_DIR}/buffer-init.c
|
||||
${NETTLE_DIR}/camellia-crypt-internal.c
|
||||
${NETTLE_DIR}/camellia-table.c
|
||||
${NETTLE_DIR}/camellia-absorb.c
|
||||
${NETTLE_DIR}/camellia-invert-key.c
|
||||
${NETTLE_DIR}/camellia128-set-encrypt-key.c
|
||||
${NETTLE_DIR}/camellia128-crypt.c
|
||||
${NETTLE_DIR}/camellia128-set-decrypt-key.c
|
||||
${NETTLE_DIR}/camellia128-meta.c
|
||||
${NETTLE_DIR}/camellia192-meta.c
|
||||
${NETTLE_DIR}/camellia256-set-encrypt-key.c
|
||||
${NETTLE_DIR}/camellia256-crypt.c
|
||||
${NETTLE_DIR}/camellia256-set-decrypt-key.c
|
||||
${NETTLE_DIR}/camellia256-meta.c
|
||||
${NETTLE_DIR}/cast128.c
|
||||
${NETTLE_DIR}/cast128-meta.c
|
||||
${NETTLE_DIR}/cbc.c
|
||||
${NETTLE_DIR}/cbc-aes128-encrypt.c
|
||||
${NETTLE_DIR}/cbc-aes192-encrypt.c
|
||||
${NETTLE_DIR}/cbc-aes256-encrypt.c
|
||||
${NETTLE_DIR}/ccm.c
|
||||
${NETTLE_DIR}/ccm-aes128.c
|
||||
${NETTLE_DIR}/ccm-aes192.c
|
||||
${NETTLE_DIR}/ccm-aes256.c
|
||||
${NETTLE_DIR}/cfb.c
|
||||
${NETTLE_DIR}/siv-cmac.c
|
||||
${NETTLE_DIR}/siv-cmac-aes128.c
|
||||
${NETTLE_DIR}/siv-cmac-aes256.c
|
||||
${NETTLE_DIR}/cnd-memcpy.c
|
||||
${NETTLE_DIR}/chacha-crypt.c
|
||||
${NETTLE_DIR}/chacha-core-internal.c
|
||||
${NETTLE_DIR}/chacha-poly1305.c
|
||||
${NETTLE_DIR}/chacha-poly1305-meta.c
|
||||
${NETTLE_DIR}/chacha-set-key.c
|
||||
${NETTLE_DIR}/chacha-set-nonce.c
|
||||
${NETTLE_DIR}/ctr.c
|
||||
${NETTLE_DIR}/ctr16.c
|
||||
${NETTLE_DIR}/des.c
|
||||
${NETTLE_DIR}/des3.c
|
||||
${NETTLE_DIR}/eax.c
|
||||
${NETTLE_DIR}/eax-aes128.c
|
||||
${NETTLE_DIR}/eax-aes128-meta.c
|
||||
${NETTLE_DIR}/ghash-set-key.c
|
||||
${NETTLE_DIR}/ghash-update.c
|
||||
${NETTLE_DIR}/gcm.c
|
||||
${NETTLE_DIR}/gcm-aes.c
|
||||
${NETTLE_DIR}/gcm-aes128.c
|
||||
${NETTLE_DIR}/gcm-aes128-meta.c
|
||||
${NETTLE_DIR}/gcm-aes192.c
|
||||
${NETTLE_DIR}/gcm-aes192-meta.c
|
||||
${NETTLE_DIR}/gcm-aes256.c
|
||||
${NETTLE_DIR}/gcm-aes256-meta.c
|
||||
${NETTLE_DIR}/gcm-camellia128.c
|
||||
${NETTLE_DIR}/gcm-camellia128-meta.c
|
||||
${NETTLE_DIR}/gcm-camellia256.c
|
||||
${NETTLE_DIR}/gcm-camellia256-meta.c
|
||||
${NETTLE_DIR}/cmac.c
|
||||
${NETTLE_DIR}/cmac64.c
|
||||
${NETTLE_DIR}/cmac-aes128.c
|
||||
${NETTLE_DIR}/cmac-aes256.c
|
||||
${NETTLE_DIR}/cmac-des3.c
|
||||
${NETTLE_DIR}/cmac-aes128-meta.c
|
||||
${NETTLE_DIR}/cmac-aes256-meta.c
|
||||
${NETTLE_DIR}/cmac-des3-meta.c
|
||||
${NETTLE_DIR}/gost28147.c
|
||||
${NETTLE_DIR}/gosthash94.c
|
||||
${NETTLE_DIR}/gosthash94-meta.c
|
||||
${NETTLE_DIR}/hmac.c
|
||||
${NETTLE_DIR}/hmac-gosthash94.c
|
||||
${NETTLE_DIR}/hmac-md5.c
|
||||
${NETTLE_DIR}/hmac-ripemd160.c
|
||||
${NETTLE_DIR}/hmac-sha1.c
|
||||
${NETTLE_DIR}/hmac-sha224.c
|
||||
${NETTLE_DIR}/hmac-sha256.c
|
||||
${NETTLE_DIR}/hmac-sha384.c
|
||||
${NETTLE_DIR}/hmac-sha512.c
|
||||
${NETTLE_DIR}/hmac-streebog.c
|
||||
${NETTLE_DIR}/hmac-sm3.c
|
||||
${NETTLE_DIR}/hmac-md5-meta.c
|
||||
${NETTLE_DIR}/hmac-ripemd160-meta.c
|
||||
${NETTLE_DIR}/hmac-sha1-meta.c
|
||||
${NETTLE_DIR}/hmac-sha224-meta.c
|
||||
${NETTLE_DIR}/hmac-sha256-meta.c
|
||||
${NETTLE_DIR}/hmac-sha384-meta.c
|
||||
${NETTLE_DIR}/hmac-sha512-meta.c
|
||||
${NETTLE_DIR}/hmac-streebog-meta.c
|
||||
${NETTLE_DIR}/hmac-sm3-meta.c
|
||||
${NETTLE_DIR}/knuth-lfib.c
|
||||
${NETTLE_DIR}/hkdf.c
|
||||
${NETTLE_DIR}/md2.c
|
||||
${NETTLE_DIR}/md2-meta.c
|
||||
${NETTLE_DIR}/md4.c
|
||||
${NETTLE_DIR}/md4-meta.c
|
||||
${NETTLE_DIR}/md5.c
|
||||
${NETTLE_DIR}/md5-compress.c
|
||||
${NETTLE_DIR}/md5-compat.c
|
||||
${NETTLE_DIR}/md5-meta.c
|
||||
${NETTLE_DIR}/memeql-sec.c
|
||||
${NETTLE_DIR}/memxor.c
|
||||
${NETTLE_DIR}/memxor3.c
|
||||
${NETTLE_DIR}/nettle-lookup-hash.c
|
||||
${NETTLE_DIR}/nettle-meta-aeads.c
|
||||
${NETTLE_DIR}/nettle-meta-armors.c
|
||||
${NETTLE_DIR}/nettle-meta-ciphers.c
|
||||
${NETTLE_DIR}/nettle-meta-hashes.c
|
||||
${NETTLE_DIR}/nettle-meta-macs.c
|
||||
${NETTLE_DIR}/pbkdf2.c
|
||||
${NETTLE_DIR}/pbkdf2-hmac-gosthash94.c
|
||||
${NETTLE_DIR}/pbkdf2-hmac-sha1.c
|
||||
${NETTLE_DIR}/pbkdf2-hmac-sha256.c
|
||||
${NETTLE_DIR}/pbkdf2-hmac-sha384.c
|
||||
${NETTLE_DIR}/pbkdf2-hmac-sha512.c
|
||||
${NETTLE_DIR}/poly1305-aes.c
|
||||
${NETTLE_DIR}/poly1305-internal.c
|
||||
${NETTLE_DIR}/realloc.c
|
||||
${NETTLE_DIR}/ripemd160.c
|
||||
${NETTLE_DIR}/ripemd160-compress.c
|
||||
${NETTLE_DIR}/ripemd160-meta.c
|
||||
${NETTLE_DIR}/salsa20-core-internal.c
|
||||
${NETTLE_DIR}/salsa20-crypt-internal.c
|
||||
${NETTLE_DIR}/salsa20-crypt.c
|
||||
${NETTLE_DIR}/salsa20r12-crypt.c
|
||||
${NETTLE_DIR}/salsa20-set-key.c
|
||||
${NETTLE_DIR}/salsa20-set-nonce.c
|
||||
${NETTLE_DIR}/salsa20-128-set-key.c
|
||||
${NETTLE_DIR}/salsa20-256-set-key.c
|
||||
${NETTLE_DIR}/sha1.c
|
||||
${NETTLE_DIR}/sha1-compress.c
|
||||
${NETTLE_DIR}/sha1-meta.c
|
||||
${NETTLE_DIR}/sha256.c
|
||||
${NETTLE_DIR}/sha256-compress-n.c
|
||||
${NETTLE_DIR}/sha224-meta.c
|
||||
${NETTLE_DIR}/sha256-meta.c
|
||||
${NETTLE_DIR}/sha512.c
|
||||
${NETTLE_DIR}/sha512-compress.c
|
||||
${NETTLE_DIR}/sha384-meta.c
|
||||
${NETTLE_DIR}/sha512-meta.c
|
||||
${NETTLE_DIR}/sha512-224-meta.c
|
||||
${NETTLE_DIR}/sha512-256-meta.c
|
||||
${NETTLE_DIR}/sha3.c
|
||||
${NETTLE_DIR}/sha3-permute.c
|
||||
${NETTLE_DIR}/sha3-224.c
|
||||
${NETTLE_DIR}/sha3-224-meta.c
|
||||
${NETTLE_DIR}/sha3-256.c
|
||||
${NETTLE_DIR}/sha3-256-meta.c
|
||||
${NETTLE_DIR}/sha3-384.c
|
||||
${NETTLE_DIR}/sha3-384-meta.c
|
||||
${NETTLE_DIR}/sha3-512.c
|
||||
${NETTLE_DIR}/sha3-512-meta.c
|
||||
${NETTLE_DIR}/shake256.c
|
||||
${NETTLE_DIR}/sm3.c
|
||||
${NETTLE_DIR}/sm3-meta.c
|
||||
${NETTLE_DIR}/serpent-set-key.c
|
||||
${NETTLE_DIR}/serpent-encrypt.c
|
||||
${NETTLE_DIR}/serpent-decrypt.c
|
||||
${NETTLE_DIR}/serpent-meta.c
|
||||
${NETTLE_DIR}/streebog.c
|
||||
${NETTLE_DIR}/streebog-meta.c
|
||||
${NETTLE_DIR}/twofish.c
|
||||
${NETTLE_DIR}/twofish-meta.c
|
||||
${NETTLE_DIR}/umac-nh.c
|
||||
${NETTLE_DIR}/umac-nh-n.c
|
||||
${NETTLE_DIR}/umac-l2.c
|
||||
${NETTLE_DIR}/umac-l3.c
|
||||
${NETTLE_DIR}/umac-poly64.c
|
||||
${NETTLE_DIR}/umac-poly128.c
|
||||
${NETTLE_DIR}/umac-set-key.c
|
||||
${NETTLE_DIR}/umac32.c
|
||||
${NETTLE_DIR}/umac64.c
|
||||
${NETTLE_DIR}/umac96.c
|
||||
${NETTLE_DIR}/umac128.c
|
||||
${NETTLE_DIR}/version.c
|
||||
${NETTLE_DIR}/write-be32.c
|
||||
${NETTLE_DIR}/write-le32.c
|
||||
${NETTLE_DIR}/write-le64.c
|
||||
${NETTLE_DIR}/yarrow256.c
|
||||
${NETTLE_DIR}/yarrow_key_event.c
|
||||
${NETTLE_DIR}/xts.c
|
||||
${NETTLE_DIR}/xts-aes128.c
|
||||
${NETTLE_DIR}/xts-aes256.c
|
||||
)
|
||||
|
||||
set(LIBNETTLE_INCLUDES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${NETTLE_DIR}
|
||||
)
|
||||
|
||||
add_library(nettle STATIC ${LIBNETTLE_SOURCES})
|
||||
target_include_directories(nettle PUBLIC ${LIBNETTLE_INCLUDES})
|
||||
target_compile_definitions(nettle PRIVATE ${LIBNETTLE_DEFINITIONS})
|
||||
@@ -1,280 +0,0 @@
|
||||
/* config.h. Generated from config.h.in by configure. */
|
||||
/* config.h.in. Generated from configure.ac by autoheader. */
|
||||
|
||||
/* Define if building universal (internal helper macro) */
|
||||
/* #undef AC_APPLE_UNIVERSAL_BUILD */
|
||||
|
||||
/* Define to one of `_getb67', `GETB67', `getb67' for Cray-2 and Cray-YMP
|
||||
systems. This function is required for `alloca.c' support on those systems.
|
||||
*/
|
||||
/* #undef CRAY_STACKSEG_END */
|
||||
|
||||
/* Define to 1 if using `alloca.c'. */
|
||||
/* #undef C_ALLOCA */
|
||||
|
||||
/* Define to 1 if you have `alloca', as a function or macro. */
|
||||
#define HAVE_ALLOCA 1
|
||||
|
||||
/* Define to 1 if you have <alloca.h> and it should be used (not on Ultrix).
|
||||
*/
|
||||
#define HAVE_ALLOCA_H 1
|
||||
|
||||
/* Define if __builtin_bswap64 is available */
|
||||
#define HAVE_BUILTIN_BSWAP64 1
|
||||
|
||||
/* Define if clock_gettime is available */
|
||||
#define HAVE_CLOCK_GETTIME 1
|
||||
|
||||
/* Define to 1 if you have the <dlfcn.h> header file. */
|
||||
#define HAVE_DLFCN_H 1
|
||||
|
||||
/* Define if fcntl file locking is available */
|
||||
#define HAVE_FCNTL_LOCKING 1
|
||||
|
||||
/* Define if the compiler understands __attribute__ */
|
||||
#define HAVE_GCC_ATTRIBUTE 1
|
||||
|
||||
/* Define to 1 if you have the `getline' function. */
|
||||
#define HAVE_GETLINE 1
|
||||
|
||||
/* Define to 1 if you have the <inttypes.h> header file. */
|
||||
#define HAVE_INTTYPES_H 1
|
||||
|
||||
/* Define to 1 if you have dlopen (with -ldl). */
|
||||
#define HAVE_LIBDL 1
|
||||
|
||||
/* Define to 1 if you have the `gmp' library (-lgmp). */
|
||||
/* #undef HAVE_LIBGMP */
|
||||
|
||||
/* Define if compiler and linker supports __attribute__ ifunc */
|
||||
#define HAVE_LINK_IFUNC 1
|
||||
|
||||
/* Define to 1 if you have the <malloc.h> header file. */
|
||||
#define HAVE_MALLOC_H 1
|
||||
|
||||
/* Define to 1 if you have the <memory.h> header file. */
|
||||
#define HAVE_MEMORY_H 1
|
||||
|
||||
/* Define to 1 each of the following for which a native (ie. CPU specific)
|
||||
implementation of the corresponding routine exists. */
|
||||
/* #undef HAVE_NATIVE_memxor3 */
|
||||
/* #undef HAVE_NATIVE_aes_decrypt */
|
||||
/* #undef HAVE_NATIVE_aes_encrypt */
|
||||
/* #undef HAVE_NATIVE_aes128_decrypt */
|
||||
/* #undef HAVE_NATIVE_aes128_encrypt */
|
||||
/* #undef HAVE_NATIVE_aes128_invert_key */
|
||||
/* #undef HAVE_NATIVE_aes128_set_decrypt_key */
|
||||
/* #undef HAVE_NATIVE_aes128_set_encrypt_key */
|
||||
/* #undef HAVE_NATIVE_aes192_decrypt */
|
||||
/* #undef HAVE_NATIVE_aes192_encrypt */
|
||||
/* #undef HAVE_NATIVE_aes192_invert_key */
|
||||
/* #undef HAVE_NATIVE_aes192_set_decrypt_key */
|
||||
/* #undef HAVE_NATIVE_aes192_set_encrypt_key */
|
||||
/* #undef HAVE_NATIVE_aes256_decrypt */
|
||||
/* #undef HAVE_NATIVE_aes256_encrypt */
|
||||
/* #undef HAVE_NATIVE_aes256_invert_key */
|
||||
/* #undef HAVE_NATIVE_aes256_set_decrypt_key */
|
||||
/* #undef HAVE_NATIVE_aes256_set_encrypt_key */
|
||||
/* #undef HAVE_NATIVE_cbc_aes128_encrypt */
|
||||
/* #undef HAVE_NATIVE_cbc_aes192_encrypt */
|
||||
/* #undef HAVE_NATIVE_cbc_aes256_encrypt */
|
||||
/* #undef HAVE_NATIVE_chacha_core */
|
||||
/* #undef HAVE_NATIVE_chacha_2core */
|
||||
#define HAVE_NATIVE_chacha_3core 1
|
||||
/* #undef HAVE_NATIVE_chacha_4core */
|
||||
/* #undef HAVE_NATIVE_fat_chacha_2core */
|
||||
/* #undef HAVE_NATIVE_fat_chacha_3core */
|
||||
/* #undef HAVE_NATIVE_fat_chacha_4core */
|
||||
/* #undef HAVE_NATIVE_ecc_curve25519_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_curve448_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp192r1_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp192r1_redc */
|
||||
/* #undef HAVE_NATIVE_ecc_secp224r1_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp224r1_redc */
|
||||
/* #undef HAVE_NATIVE_ecc_secp256r1_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp256r1_redc */
|
||||
/* #undef HAVE_NATIVE_ecc_secp384r1_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp384r1_redc */
|
||||
/* #undef HAVE_NATIVE_ecc_secp521r1_modp */
|
||||
/* #undef HAVE_NATIVE_ecc_secp521r1_redc */
|
||||
/* #undef HAVE_NATIVE_poly1305_set_key */
|
||||
/* #undef HAVE_NATIVE_poly1305_block */
|
||||
/* #undef HAVE_NATIVE_poly1305_digest */
|
||||
/* #undef HAVE_NATIVE_poly1305_blocks */
|
||||
/* #undef HAVE_NATIVE_fat_poly1305_blocks */
|
||||
/* #undef HAVE_NATIVE_ghash_set_key */
|
||||
/* #undef HAVE_NATIVE_ghash_update */
|
||||
/* #undef HAVE_NATIVE_salsa20_core */
|
||||
#define HAVE_NATIVE_salsa20_2core 1
|
||||
/* #undef HAVE_NATIVE_fat_salsa20_2core */
|
||||
/* #undef HAVE_NATIVE_sha1_compress */
|
||||
/* #undef HAVE_NATIVE_sha256_compress_n */
|
||||
/* #undef HAVE_NATIVE_sha512_compress */
|
||||
/* #undef HAVE_NATIVE_sha3_permute */
|
||||
/* #undef HAVE_NATIVE_umac_nh */
|
||||
/* #undef HAVE_NATIVE_umac_nh_n */
|
||||
|
||||
/* Define to 1 if you have the <openssl/ecdsa.h> header file. */
|
||||
/* #undef HAVE_OPENSSL_ECDSA_H */
|
||||
|
||||
/* Define to 1 if you have the <openssl/evp.h> header file. */
|
||||
/* #undef HAVE_OPENSSL_EVP_H */
|
||||
|
||||
/* Define to 1 if you have the `secure_getenv' function. */
|
||||
#define HAVE_SECURE_GETENV 1
|
||||
|
||||
/* Define to 1 if you have the <stdint.h> header file. */
|
||||
#define HAVE_STDINT_H 1
|
||||
|
||||
/* Define to 1 if you have the <stdlib.h> header file. */
|
||||
#define HAVE_STDLIB_H 1
|
||||
|
||||
/* Define to 1 if you have the `strerror' function. */
|
||||
#define HAVE_STRERROR 1
|
||||
|
||||
/* Define to 1 if you have the <strings.h> header file. */
|
||||
#define HAVE_STRINGS_H 1
|
||||
|
||||
/* Define to 1 if you have the <string.h> header file. */
|
||||
#define HAVE_STRING_H 1
|
||||
|
||||
/* Define to 1 if you have the <sys/stat.h> header file. */
|
||||
#define HAVE_SYS_STAT_H 1
|
||||
|
||||
/* Define to 1 if you have the <sys/types.h> header file. */
|
||||
#define HAVE_SYS_TYPES_H 1
|
||||
|
||||
/* Define to 1 if you have the <unistd.h> header file. */
|
||||
#define HAVE_UNISTD_H 1
|
||||
|
||||
/* Define to 1 if you have the <valgrind/memcheck.h> header file. */
|
||||
/* #undef HAVE_VALGRIND_MEMCHECK_H */
|
||||
|
||||
/* Define to the address where bug reports for this package should be sent. */
|
||||
#define PACKAGE_BUGREPORT "nettle-bugs@lists.lysator.liu.se"
|
||||
|
||||
/* Define to the full name of this package. */
|
||||
#define PACKAGE_NAME "nettle"
|
||||
|
||||
/* Define to the full name and version of this package. */
|
||||
#define PACKAGE_STRING "nettle 3.9.1"
|
||||
|
||||
/* Define to the one symbol short name of this package. */
|
||||
#define PACKAGE_TARNAME "nettle"
|
||||
|
||||
/* Define to the home page for this package. */
|
||||
#define PACKAGE_URL ""
|
||||
|
||||
/* Define to the version of this package. */
|
||||
#define PACKAGE_VERSION "3.9.1"
|
||||
|
||||
/* The size of `long', as computed by sizeof. */
|
||||
#define SIZEOF_LONG __SIZEOF_LONG__
|
||||
|
||||
/* The size of `size_t', as computed by sizeof. */
|
||||
#define SIZEOF_SIZE_T __SIZEOF_SIZE_T__
|
||||
|
||||
/* If using the C implementation of alloca, define if you know the
|
||||
direction of stack growth for your system; otherwise it will be
|
||||
automatically deduced at runtime.
|
||||
STACK_DIRECTION > 0 => grows toward higher addresses
|
||||
STACK_DIRECTION < 0 => grows toward lower addresses
|
||||
STACK_DIRECTION = 0 => direction of growth unknown */
|
||||
/* #undef STACK_DIRECTION */
|
||||
|
||||
/* Define to 1 if you have the ANSI C header files. */
|
||||
#define STDC_HEADERS 1
|
||||
|
||||
/* Define to 1 if you can safely include both <sys/time.h> and <time.h>. */
|
||||
#define TIME_WITH_SYS_TIME 1
|
||||
|
||||
/* Defined if public key features are enabled */
|
||||
/* #undef WITH_HOGWEED */
|
||||
|
||||
/* Define if you have openssl's libcrypto (used for benchmarking) */
|
||||
/* #undef WITH_OPENSSL */
|
||||
|
||||
/* Define WORDS_BIGENDIAN to 1 if your processor stores words with the most
|
||||
significant byte first (like Motorola and SPARC, unlike Intel). */
|
||||
#if defined AC_APPLE_UNIVERSAL_BUILD
|
||||
# if defined __BIG_ENDIAN__
|
||||
# define WORDS_BIGENDIAN 1
|
||||
# endif
|
||||
#else
|
||||
# ifndef WORDS_BIGENDIAN
|
||||
/* # undef WORDS_BIGENDIAN */
|
||||
# endif
|
||||
#endif
|
||||
|
||||
/* Define to empty if `const' does not conform to ANSI C. */
|
||||
/* #undef const */
|
||||
|
||||
/* Define to `int' if <sys/types.h> doesn't define. */
|
||||
/* #undef gid_t */
|
||||
|
||||
/* Define to `__inline__' or `__inline' if that's what the C compiler
|
||||
calls it, or to nothing if 'inline' is not supported under any name. */
|
||||
#ifndef __cplusplus
|
||||
/* #undef inline */
|
||||
#endif
|
||||
|
||||
/* Define to `unsigned int' if <sys/types.h> does not define. */
|
||||
/* #undef size_t */
|
||||
|
||||
/* Define to `int' if <sys/types.h> doesn't define. */
|
||||
/* #undef uid_t */
|
||||
|
||||
/* AIX requires this to be the first thing in the file. */
|
||||
#ifndef __GNUC__
|
||||
# if HAVE_ALLOCA_H
|
||||
# include <alloca.h>
|
||||
# else
|
||||
# ifdef _AIX
|
||||
#pragma alloca
|
||||
# else
|
||||
# ifndef alloca /* predefined by HP cc +Olibcalls */
|
||||
char *alloca ();
|
||||
# endif
|
||||
# endif
|
||||
/* Needed for alloca on windows */
|
||||
# if HAVE_MALLOC_H
|
||||
# include <malloc.h>
|
||||
# endif
|
||||
# endif
|
||||
#else /* defined __GNUC__ */
|
||||
# if HAVE_ALLOCA_H
|
||||
# include <alloca.h>
|
||||
# else
|
||||
/* Needed for alloca on windows, also with gcc */
|
||||
# if HAVE_MALLOC_H
|
||||
# include <malloc.h>
|
||||
# endif
|
||||
# endif
|
||||
#endif
|
||||
|
||||
|
||||
#if HAVE_STRERROR
|
||||
#define STRERROR strerror
|
||||
#else
|
||||
#define STRERROR(x) (sys_errlist[x])
|
||||
#endif
|
||||
|
||||
|
||||
#if __GNUC__ && HAVE_GCC_ATTRIBUTE
|
||||
# define NORETURN __attribute__ ((__noreturn__))
|
||||
# define PRINTF_STYLE(f, a) __attribute__ ((__format__ (__printf__, f, a)))
|
||||
# define UNUSED __attribute__ ((__unused__))
|
||||
#else
|
||||
# define NORETURN
|
||||
# define PRINTF_STYLE(f, a)
|
||||
# define UNUSED
|
||||
#endif
|
||||
|
||||
|
||||
#if defined(__x86_64__) || defined(__arch64__)
|
||||
# define HAVE_NATIVE_64_BIT 1
|
||||
#else
|
||||
/* Needs include of <limits.h> before use. */
|
||||
# define HAVE_NATIVE_64_BIT (SIZEOF_LONG * CHAR_BIT >= 64)
|
||||
#endif
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/* version.h
|
||||
|
||||
Information about library version.
|
||||
|
||||
Copyright (C) 2015 Red Hat, Inc.
|
||||
Copyright (C) 2015 Niels Möller
|
||||
|
||||
This file is part of GNU Nettle.
|
||||
|
||||
GNU Nettle is free software: you can redistribute it and/or
|
||||
modify it under the terms of either:
|
||||
|
||||
* the GNU Lesser General Public License as published by the Free
|
||||
Software Foundation; either version 3 of the License, or (at your
|
||||
option) any later version.
|
||||
|
||||
or
|
||||
|
||||
* the GNU General Public License as published by the Free
|
||||
Software Foundation; either version 2 of the License, or (at your
|
||||
option) any later version.
|
||||
|
||||
or both in parallel, as here.
|
||||
|
||||
GNU Nettle is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received copies of the GNU General Public License and
|
||||
the GNU Lesser General Public License along with this program. If
|
||||
not, see http://www.gnu.org/licenses/.
|
||||
*/
|
||||
|
||||
#ifndef NETTLE_VERSION_H_INCLUDED
|
||||
#define NETTLE_VERSION_H_INCLUDED
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Individual version numbers in decimal */
|
||||
#define NETTLE_VERSION_MAJOR 3
|
||||
#define NETTLE_VERSION_MINOR 9
|
||||
|
||||
#define NETTLE_USE_MINI_GMP 0
|
||||
|
||||
/* We need a preprocessor constant for GMP_NUMB_BITS, simply using
|
||||
sizeof(mp_limb_t) * CHAR_BIT is not good enough. */
|
||||
#if NETTLE_USE_MINI_GMP
|
||||
# define GMP_NUMB_BITS n/a
|
||||
#endif
|
||||
|
||||
int
|
||||
nettle_version_major (void);
|
||||
|
||||
int
|
||||
nettle_version_minor (void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* NETTLE_VERSION_H_INCLUDED */
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+460
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.FloatDecayAnimationSpec
|
||||
import androidx.compose.animation.core.generateDecayAnimationSpec
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
|
||||
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
|
||||
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.node.LayoutModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.ObserverModifierNode
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.node.observeReads
|
||||
import androidx.compose.ui.node.requireLayoutDirection
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun <T> Modifier.anchoredHorizontalDraggable(
|
||||
state: AnchoredDraggableState<T>,
|
||||
enableDragFromStartToEnd: Boolean = true,
|
||||
enableDragFromEndToStart: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
startDragImmediately: Boolean = state.isAnimationRunning,
|
||||
flingBehavior: FlingBehavior? = null,
|
||||
): Modifier = this then AnchoredHorizontalDraggableElement(
|
||||
state = state,
|
||||
canDrag = { (x, y) ->
|
||||
val enableDrag = if (x > 0) enableDragFromStartToEnd else enableDragFromEndToStart
|
||||
enableDrag && x.absoluteValue > y.absoluteValue * 2f
|
||||
},
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
flingBehavior = flingBehavior,
|
||||
)
|
||||
|
||||
private class AnchoredHorizontalDraggableElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val canDrag: (Offset) -> Boolean,
|
||||
private val enabled: Boolean,
|
||||
private val startDragImmediately: Boolean,
|
||||
private val flingBehavior: FlingBehavior?,
|
||||
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
|
||||
override fun create() = AnchoredHorizontalDraggableNode(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
flingBehavior,
|
||||
)
|
||||
|
||||
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
|
||||
node.update(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
flingBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + canDrag.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + startDragImmediately.hashCode()
|
||||
result = 31 * result + flingBehavior.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is AnchoredHorizontalDraggableElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (canDrag != other.canDrag) return false
|
||||
if (enabled != other.enabled) return false
|
||||
if (startDragImmediately != other.startDragImmediately) return false
|
||||
if (flingBehavior != other.flingBehavior) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "anchoredDraggable"
|
||||
properties["state"] = state
|
||||
properties["canDrag"] = canDrag
|
||||
properties["enabled"] = enabled
|
||||
properties["startDragImmediately"] = startDragImmediately
|
||||
properties["flingBehavior"] = flingBehavior
|
||||
}
|
||||
}
|
||||
|
||||
private class AnchoredHorizontalDraggableNode<T>(
|
||||
private var state: AnchoredDraggableState<T>,
|
||||
private var canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
private var startDragImmediately: Boolean,
|
||||
private var flingBehavior: FlingBehavior?,
|
||||
) : HorizontalDragGestureNode(enabled = enabled),
|
||||
ObserverModifierNode {
|
||||
|
||||
lateinit var resolvedFlingBehavior: FlingBehavior
|
||||
lateinit var density: Density
|
||||
|
||||
private val isReverseDirection: Boolean
|
||||
get() = requireLayoutDirection() == LayoutDirection.Rtl
|
||||
|
||||
override fun onAttach() {
|
||||
updateFlingBehavior(flingBehavior)
|
||||
}
|
||||
|
||||
override fun onObservedReadsChanged() {
|
||||
val newDensity = currentValueOf(LocalDensity)
|
||||
if (density != newDensity) {
|
||||
density = newDensity
|
||||
updateFlingBehavior(flingBehavior)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
|
||||
// Fall back to default fling behavior if the new fling behavior is null
|
||||
this.resolvedFlingBehavior =
|
||||
if (newFlingBehavior == null) {
|
||||
// Only register for LocalDensity snapshot updates if we are creating a decay
|
||||
observeReads { density = currentValueOf(LocalDensity) }
|
||||
anchoredDraggableFlingBehavior(
|
||||
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
|
||||
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
|
||||
density = density,
|
||||
state = state,
|
||||
)
|
||||
} else {
|
||||
newFlingBehavior
|
||||
}
|
||||
}
|
||||
|
||||
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
|
||||
|
||||
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
|
||||
state.anchoredDrag {
|
||||
forEachDelta { dragDelta ->
|
||||
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDragStarted(startedPosition: Offset) { }
|
||||
|
||||
override fun onDragStopped(velocity: Velocity) {
|
||||
if (!isAttached) return
|
||||
coroutineScope.launch {
|
||||
fling(velocity.reverseIfNeeded().x)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fling(velocity: Float): Float {
|
||||
var leftoverVelocity = velocity
|
||||
state.anchoredDrag {
|
||||
val scrollScope =
|
||||
object : ScrollScope {
|
||||
override fun scrollBy(pixels: Float): Float {
|
||||
val newOffset = state.newOffsetForDelta(pixels)
|
||||
val consumed = newOffset - state.offset
|
||||
dragTo(newOffset)
|
||||
return consumed
|
||||
}
|
||||
}
|
||||
with(resolvedFlingBehavior) {
|
||||
leftoverVelocity = scrollScope.performFling(velocity)
|
||||
}
|
||||
}
|
||||
return leftoverVelocity
|
||||
}
|
||||
|
||||
override fun startDragImmediately(): Boolean = startDragImmediately
|
||||
|
||||
fun update(
|
||||
state: AnchoredDraggableState<T>,
|
||||
canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
startDragImmediately: Boolean,
|
||||
flingBehavior: FlingBehavior?,
|
||||
) {
|
||||
this.flingBehavior = flingBehavior
|
||||
|
||||
var resetPointerInputHandling = false
|
||||
|
||||
if (this.state != state) {
|
||||
this.state = state
|
||||
updateFlingBehavior(flingBehavior)
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
this.canDrag = canDrag
|
||||
this.startDragImmediately = startDragImmediately
|
||||
|
||||
update(
|
||||
enabled = enabled,
|
||||
shouldResetPointerInputHandling = resetPointerInputHandling,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
}
|
||||
|
||||
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
|
||||
((if (offset.isNaN()) 0f else offset) + delta)
|
||||
.coerceIn(anchors.minPosition(), anchors.maxPosition())
|
||||
|
||||
/**
|
||||
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
|
||||
* [velocityThreshold].
|
||||
*
|
||||
* @return The suggested target anchor
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
private fun <T> DraggableAnchors<T>.computeTarget(
|
||||
currentOffset: Float,
|
||||
currentValue: T,
|
||||
velocity: Float,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
): T {
|
||||
val currentAnchors = this
|
||||
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
|
||||
val velocityThresholdPx = velocityThreshold()
|
||||
return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) {
|
||||
currentValue
|
||||
} else {
|
||||
if (abs(velocity) >= abs(velocityThresholdPx)) {
|
||||
currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!!
|
||||
} else {
|
||||
val neighborAnchor =
|
||||
currentAnchors.closestAnchor(
|
||||
currentOffset,
|
||||
currentOffset - currentAnchorPosition > 0,
|
||||
)!!
|
||||
val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor)
|
||||
val distance = abs(currentAnchorPosition - neighborAnchorPosition)
|
||||
val relativeThreshold = abs(positionalThreshold(distance))
|
||||
val relativePosition = abs(currentAnchorPosition - currentOffset)
|
||||
if (relativePosition <= relativeThreshold) currentValue else neighborAnchor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable].
|
||||
*
|
||||
* @param state The [AnchoredDraggableState] that will be used for the fling animation
|
||||
* @param positionalThreshold A positional threshold that needs to be crossed in order to reach the
|
||||
* next anchor when flinging, in pixels. This can be a derived from the distance that the lambda
|
||||
* is invoked with.
|
||||
* @param snapAnimationSpec The animation spec that will be used to snap to a new state.
|
||||
*/
|
||||
@ExperimentalFoundationApi
|
||||
internal fun <T> anchoredDraggableFlingBehavior(
|
||||
state: AnchoredDraggableState<T>,
|
||||
density: Density,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
snapAnimationSpec: AnimationSpec<Float>,
|
||||
): TargetedFlingBehavior =
|
||||
snapFlingBehavior(
|
||||
decayAnimationSpec = NoOpDecayAnimationSpec,
|
||||
snapAnimationSpec = snapAnimationSpec,
|
||||
snapLayoutInfoProvider =
|
||||
AnchoredDraggableLayoutInfoProvider(
|
||||
state = state,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = { with(density) { 125.dp.toPx() } },
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun <T> AnchoredDraggableLayoutInfoProvider(
|
||||
state: AnchoredDraggableState<T>,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
): SnapLayoutInfoProvider =
|
||||
object : SnapLayoutInfoProvider {
|
||||
|
||||
// We never decay in AnchoredDraggable's fling
|
||||
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
|
||||
|
||||
override fun calculateSnapOffset(velocity: Float): Float {
|
||||
val currentOffset = state.requireOffset()
|
||||
val target =
|
||||
state.anchors.computeTarget(
|
||||
currentOffset = currentOffset,
|
||||
currentValue = state.currentValue,
|
||||
velocity = velocity,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = velocityThreshold,
|
||||
)
|
||||
return state.anchors.positionOf(target) - currentOffset
|
||||
}
|
||||
}
|
||||
|
||||
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
|
||||
object : FloatDecayAnimationSpec {
|
||||
override val absVelocityThreshold = 0f
|
||||
|
||||
override fun getValueFromNanos(
|
||||
playTimeNanos: Long,
|
||||
initialValue: Float,
|
||||
initialVelocity: Float,
|
||||
) = 0f
|
||||
|
||||
override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L
|
||||
|
||||
override fun getVelocityFromNanos(
|
||||
playTimeNanos: Long,
|
||||
initialValue: Float,
|
||||
initialVelocity: Float,
|
||||
) = 0f
|
||||
|
||||
override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f
|
||||
}.generateDecayAnimationSpec()
|
||||
|
||||
/**
|
||||
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
|
||||
* node's size and offsetting it.
|
||||
* It considers lookahead and reports the appropriate size and measurement for the appropriate
|
||||
* phase.
|
||||
*
|
||||
* @param state The state the anchors should be attached to
|
||||
* @param orientation The orientation the component should be offset in
|
||||
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
|
||||
* constraints. These can be useful to avoid subcomposition.
|
||||
*/
|
||||
internal fun <T> Modifier.draggableAnchors(
|
||||
state: AnchoredDraggableState<T>,
|
||||
orientation: Orientation,
|
||||
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
) = this then DraggableAnchorsElement(state, anchors, orientation)
|
||||
|
||||
private class DraggableAnchorsElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
private val orientation: Orientation,
|
||||
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
|
||||
|
||||
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
|
||||
|
||||
override fun update(node: DraggableAnchorsNode<T>) {
|
||||
node.state = state
|
||||
node.anchors = anchors
|
||||
node.orientation = orientation
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is DraggableAnchorsElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (anchors !== other.anchors) return false
|
||||
if (orientation != other.orientation) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + anchors.hashCode()
|
||||
result = 31 * result + orientation.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
debugInspectorInfo {
|
||||
properties["state"] = state
|
||||
properties["anchors"] = anchors
|
||||
properties["orientation"] = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DraggableAnchorsNode<T>(
|
||||
var state: AnchoredDraggableState<T>,
|
||||
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
var orientation: Orientation,
|
||||
) : Modifier.Node(),
|
||||
LayoutModifierNode {
|
||||
private var didLookahead: Boolean = false
|
||||
|
||||
override fun onDetach() {
|
||||
didLookahead = false
|
||||
}
|
||||
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||
// update the anchors in the main pass.
|
||||
if (!isLookingAhead || !didLookahead) {
|
||||
val size = IntSize(placeable.width, placeable.height)
|
||||
val newAnchorResult = anchors(size, constraints)
|
||||
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
|
||||
}
|
||||
didLookahead = isLookingAhead || didLookahead
|
||||
return layout(placeable.width, placeable.height) {
|
||||
// In a lookahead pass, we use the position of the current target as this is where any
|
||||
// ongoing animations would move. If the component is in a settled state, lookahead
|
||||
// and post-lookahead will converge.
|
||||
val offset = if (isLookingAhead) {
|
||||
state.anchors.positionOf(state.targetValue)
|
||||
} else {
|
||||
state.requireOffset()
|
||||
}
|
||||
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
|
||||
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
|
||||
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* A Gesture detector that waits for pointer down and touch slop in the horizontal direction and
|
||||
* then calls [onDrag] for each drag event.
|
||||
* It follows the touch slop detection of [awaitHorizontalTouchSlopOrCancellation] but will consume
|
||||
* the position change automatically once the touch slop has been crossed, the amount of drag over
|
||||
* the touch slop is reported as the first drag event [onDrag] after the slop is crossed.
|
||||
* If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored
|
||||
* and the drag gesture will be recognized immediately.The first [onDrag] in this case will report
|
||||
* an [Offset.Zero].
|
||||
*
|
||||
* [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
|
||||
* the last known pointer position relative to the containing element as well as the initial
|
||||
* down event that triggered this gesture detection cycle. The [Offset] can be outside
|
||||
* the actual bounds of the element itself meaning the numbers can be negative or larger than the
|
||||
* element bounds if the touch target is smaller than the
|
||||
* [ViewConfiguration.minimumTouchTargetSize].
|
||||
*
|
||||
* [onDragEnd] is called after all pointers are up with the event change of the up event
|
||||
* and [onDragCancel] is called if another gesture has consumed pointer input,
|
||||
* canceling this gesture.
|
||||
*
|
||||
* @param onDragStart A lambda to be called when the drag gesture starts, it contains information
|
||||
* about the last known [PointerInputChange] relative to the containing element and the post slop
|
||||
* delta.
|
||||
* @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
|
||||
* up [PointerInputChange] that finished the gesture.
|
||||
* @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
|
||||
* when it was consumed.
|
||||
* @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
|
||||
* @param onDrag A lambda to be called for each delta event in the gesture. It contains information
|
||||
* about the [PointerInputChange] and the movement offset.
|
||||
*/
|
||||
internal suspend fun PointerInputScope.detectHorizontalDragGestures(
|
||||
canDrag: (PointerInputChange) -> Boolean,
|
||||
onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
shouldAwaitTouchSlop: () -> Boolean,
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val initialDown =
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val awaitTouchSlop = shouldAwaitTouchSlop()
|
||||
|
||||
if (!awaitTouchSlop) {
|
||||
initialDown.consume()
|
||||
}
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var drag: PointerInputChange?
|
||||
var overSlop = Offset.Zero
|
||||
|
||||
if (awaitTouchSlop) {
|
||||
do {
|
||||
drag = awaitHorizontalTouchSlopOrCancellation(
|
||||
down.id,
|
||||
) { change, over ->
|
||||
if (canDrag.invoke(change)) {
|
||||
change.consume()
|
||||
overSlop = Offset(over, 0f)
|
||||
} else {
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
} while (drag != null && !drag.isConsumed)
|
||||
} else {
|
||||
drag = initialDown
|
||||
}
|
||||
|
||||
if (drag != null) {
|
||||
onDragStart.invoke(drag, overSlop)
|
||||
onDrag(drag, overSlop)
|
||||
val dragEnd = horizontalDrag(
|
||||
pointerId = drag.id,
|
||||
onDrag = {
|
||||
onDrag(it, it.positionChange())
|
||||
it.consume()
|
||||
},
|
||||
)
|
||||
if (dragEnd) {
|
||||
onDragEnd()
|
||||
} else {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragCancelled
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStarted
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStopped
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.PointerInputModifierNode
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A node that performs drag gesture recognition and event propagation.
|
||||
*/
|
||||
internal abstract class HorizontalDragGestureNode(
|
||||
enabled: Boolean,
|
||||
) : DelegatingNode(),
|
||||
PointerInputModifierNode,
|
||||
CompositionLocalConsumerModifierNode {
|
||||
|
||||
protected var enabled = enabled
|
||||
private set
|
||||
|
||||
private var channel: Channel<HorizontalDragEvent>? = null
|
||||
private var isListeningForEvents = false
|
||||
|
||||
abstract fun canDrag(change: PointerInputChange): Boolean
|
||||
|
||||
/**
|
||||
* Responsible for the dragging behavior between the start and the end of the drag. It
|
||||
* continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta`
|
||||
* calls `dragBy` method to process each individual delta.
|
||||
*/
|
||||
abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag starts. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStarted(startedPosition: Offset)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag stops. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStopped(velocity: Velocity)
|
||||
|
||||
/**
|
||||
* If touch slop recognition should be skipped. If this is true, this node will start
|
||||
* recognizing drag events immediately without waiting for touch slop.
|
||||
*/
|
||||
abstract fun startDragImmediately(): Boolean
|
||||
|
||||
private fun startListeningForEvents() {
|
||||
isListeningForEvents = true
|
||||
|
||||
/**
|
||||
* To preserve the original behavior we had (before the Modifier.Node migration) we need to
|
||||
* scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using
|
||||
* the one provided by the pointer input modifier, this is to ensure that even when the pointer
|
||||
* input scope is reset we will continue any coroutine scope scope that we started from these
|
||||
* methods while the pointer input scope was active.
|
||||
*/
|
||||
coroutineScope.launch {
|
||||
while (isActive) {
|
||||
var event = channel?.receive()
|
||||
if (event !is DragStarted) continue
|
||||
onDragStarted(event.startPoint)
|
||||
try {
|
||||
drag { processDelta ->
|
||||
while (event !is DragStopped && event !is DragCancelled) {
|
||||
(event as? DragDelta)?.let(processDelta)
|
||||
event = channel?.receive()
|
||||
}
|
||||
}
|
||||
if (event is DragStopped) {
|
||||
onDragStopped((event as DragStopped).velocity)
|
||||
} else if (event is DragCancelled) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pointerInputNode: SuspendingPointerInputModifierNode? = null
|
||||
|
||||
override fun onDetach() {
|
||||
isListeningForEvents = false
|
||||
}
|
||||
|
||||
override fun onPointerEvent(
|
||||
pointerEvent: PointerEvent,
|
||||
pass: PointerEventPass,
|
||||
bounds: IntSize,
|
||||
) {
|
||||
if (enabled && pointerInputNode == null) {
|
||||
pointerInputNode = delegate(initializePointerInputNode())
|
||||
}
|
||||
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
|
||||
}
|
||||
|
||||
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode = SuspendingPointerInputModifierNode {
|
||||
// re-create tracker when pointer input block restarts. This lazily creates the tracker
|
||||
// only when it is need.
|
||||
val velocityTracker = VelocityTracker()
|
||||
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
|
||||
{ startEvent, initialDelta ->
|
||||
if (!isListeningForEvents) {
|
||||
if (channel == null) {
|
||||
channel = Channel(capacity = Channel.UNLIMITED)
|
||||
}
|
||||
startListeningForEvents()
|
||||
}
|
||||
val xSign = sign(startEvent.position.x)
|
||||
val ySign = sign(startEvent.position.y)
|
||||
val adjustedStart = startEvent.position -
|
||||
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
|
||||
|
||||
channel?.trySend(DragStarted(adjustedStart))
|
||||
}
|
||||
|
||||
val onDragEnd: () -> Unit = {
|
||||
val maximumVelocity = currentValueOf(LocalViewConfiguration)
|
||||
.maximumFlingVelocity
|
||||
val velocity = velocityTracker.calculateVelocity(
|
||||
Velocity(maximumVelocity, maximumVelocity),
|
||||
)
|
||||
velocityTracker.resetTracking()
|
||||
channel?.trySend(DragStopped(velocity))
|
||||
}
|
||||
|
||||
val onDragCancel: () -> Unit = {
|
||||
channel?.trySend(DragCancelled)
|
||||
}
|
||||
|
||||
val shouldAwaitTouchSlop: () -> Boolean = {
|
||||
!startDragImmediately()
|
||||
}
|
||||
|
||||
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
|
||||
{ change, delta ->
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
channel?.trySend(DragDelta(delta))
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
detectHorizontalDragGestures(
|
||||
canDrag = ::canDrag,
|
||||
onDragStart = onDragStart,
|
||||
onDragEnd = onDragEnd,
|
||||
onDragCancel = onDragCancel,
|
||||
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
|
||||
onDrag = onDrag,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
channel?.trySend(DragCancelled)
|
||||
if (!isActive) throw cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelPointerInput() {
|
||||
pointerInputNode?.onCancelPointerInput()
|
||||
}
|
||||
|
||||
fun update(
|
||||
enabled: Boolean = this.enabled,
|
||||
shouldResetPointerInputHandling: Boolean = false,
|
||||
) {
|
||||
var resetPointerInputHandling = shouldResetPointerInputHandling
|
||||
|
||||
if (this.enabled != enabled) {
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
pointerInputNode?.let { undelegate(it) }
|
||||
pointerInputNode = null
|
||||
}
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
if (resetPointerInputHandling) {
|
||||
pointerInputNode?.resetPointerInputHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HorizontalDragEvent {
|
||||
class DragStarted(val startPoint: Offset) : HorizontalDragEvent()
|
||||
class DragStopped(val velocity: Velocity) : HorizontalDragEvent()
|
||||
object DragCancelled : HorizontalDragEvent()
|
||||
class DragDelta(val delta: Offset) : HorizontalDragEvent()
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package androidx.compose.foundation.scrollbar
|
||||
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
internal class LazyStaggeredGridScrollbarAdapter(
|
||||
private val scrollState: LazyStaggeredGridState,
|
||||
) : LazyLineContentAdapter() {
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = with(scrollState.layoutInfo) {
|
||||
if (isVertical) viewportSize.height else viewportSize.width
|
||||
}.toDouble()
|
||||
|
||||
private val isVertical by lazy {
|
||||
scrollState.layoutInfo.orientation == Orientation.Vertical
|
||||
}
|
||||
|
||||
private fun LazyStaggeredGridItemInfo.mainAxisSize() = with(size) {
|
||||
if (isVertical) height else width
|
||||
}
|
||||
|
||||
private fun LazyStaggeredGridItemInfo.mainAxisOffset() = with(offset) {
|
||||
if (isVertical) y else x
|
||||
}
|
||||
|
||||
override fun firstVisibleLine(): VisibleLine? {
|
||||
val first = scrollState.layoutInfo.visibleItemsInfo.firstOrNull()
|
||||
return first?.run { VisibleLine(index, mainAxisOffset()) }
|
||||
}
|
||||
|
||||
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
|
||||
|
||||
override fun contentPadding() = with(scrollState.layoutInfo) {
|
||||
beforeContentPadding + afterContentPadding
|
||||
}
|
||||
|
||||
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
|
||||
scrollState.scrollToItem(lineIndex, scrollOffset)
|
||||
}
|
||||
|
||||
override suspend fun scrollBy(value: Float) {
|
||||
scrollState.scrollBy(value)
|
||||
}
|
||||
|
||||
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
|
||||
val first = firstOrNull() ?: return@with 0.0
|
||||
val last = last()
|
||||
(last.mainAxisOffset() + last.mainAxisSize() - first.mainAxisOffset()).toDouble() / size
|
||||
}
|
||||
|
||||
override val lineSpacing get() = 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(
|
||||
scrollState: LazyStaggeredGridState,
|
||||
): ScrollbarAdapter = remember(scrollState) {
|
||||
LazyStaggeredGridScrollbarAdapter(scrollState)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.scrollbar
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocal
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.constrainHeight
|
||||
import androidx.compose.ui.unit.constrainWidth
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* [CompositionLocal] used to pass [ScrollbarStyle] down the tree.
|
||||
* This value is typically set in some "Theme" composable function
|
||||
* (DesktopTheme, MaterialTheme)
|
||||
*/
|
||||
val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
|
||||
|
||||
/**
|
||||
* Defines visual style of scrollbars (thickness, shapes, colors, etc).
|
||||
* Can be passed as a parameter of scrollbar through [LocalScrollbarStyle]
|
||||
*/
|
||||
@Immutable
|
||||
data class ScrollbarStyle(
|
||||
val minimalHeight: Dp,
|
||||
val thickness: Dp,
|
||||
val shape: Shape,
|
||||
val hoverDurationMillis: Int,
|
||||
val unhoverColor: Color,
|
||||
val hoverColor: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* Simple default [ScrollbarStyle] without applying MaterialTheme.
|
||||
*/
|
||||
fun defaultScrollbarStyle() = ScrollbarStyle(
|
||||
minimalHeight = 16.dp,
|
||||
thickness = 8.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
hoverDurationMillis = 300,
|
||||
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||
)
|
||||
|
||||
/**
|
||||
* Vertical scrollbar that can be attached to some scrollable
|
||||
* component (ScrollableColumn, LazyColumn) and share common state with it.
|
||||
*
|
||||
* Can be placed independently.
|
||||
*
|
||||
* Example:
|
||||
* val state = rememberScrollState(0)
|
||||
*
|
||||
* Box(Modifier.fillMaxSize()) {
|
||||
* Box(modifier = Modifier.verticalScroll(state)) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* VerticalScrollbar(
|
||||
* adapter = rememberScrollbarAdapter(state)
|
||||
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* @param adapter [ScrollbarAdapter] that will be used to
|
||||
* communicate with scrollable component
|
||||
* @param modifier the modifier to apply to this layout
|
||||
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
|
||||
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
|
||||
* will be at the bottom of the container.
|
||||
* It is usually used in pair with `LazyColumn(reverseLayout = true)`
|
||||
* @param style [ScrollbarStyle] to define visual style of scrollbar
|
||||
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
|
||||
* [DragInteraction.Start] when this Scrollbar is being dragged.
|
||||
*/
|
||||
@Composable
|
||||
fun VerticalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(
|
||||
adapter,
|
||||
modifier,
|
||||
reverseLayout,
|
||||
style,
|
||||
interactionSource,
|
||||
isVertical = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Horizontal scrollbar that can be attached to some scrollable
|
||||
* component (Modifier.verticalScroll(), LazyRow) and share common state with it.
|
||||
*
|
||||
* Can be placed independently.
|
||||
*
|
||||
* Example:
|
||||
* val state = rememberScrollState(0)
|
||||
*
|
||||
* Box(Modifier.fillMaxSize()) {
|
||||
* Box(modifier = Modifier.verticalScroll(state)) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* HorizontalScrollbar(
|
||||
* adapter = rememberScrollbarAdapter(state)
|
||||
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxWidth(),
|
||||
* )
|
||||
* }
|
||||
*
|
||||
* @param adapter [ScrollbarAdapter] that will be used to
|
||||
* communicate with scrollable component
|
||||
* @param modifier the modifier to apply to this layout
|
||||
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
|
||||
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
|
||||
* will be at the end of the container.
|
||||
* It is usually used in pair with `LazyRow(reverseLayout = true)`
|
||||
* @param style [ScrollbarStyle] to define visual style of scrollbar
|
||||
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
|
||||
* [DragInteraction.Start] when this Scrollbar is being dragged.
|
||||
*/
|
||||
@Composable
|
||||
fun HorizontalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(
|
||||
adapter,
|
||||
modifier,
|
||||
if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout,
|
||||
style,
|
||||
interactionSource,
|
||||
isVertical = false,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Scrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean,
|
||||
style: ScrollbarStyle,
|
||||
interactionSource: MutableInteractionSource,
|
||||
isVertical: Boolean,
|
||||
) = with(LocalDensity.current) {
|
||||
val dragInteraction = remember<MutableState<DragInteraction.Start?>> { mutableStateOf(null) }
|
||||
DisposableEffect(key1 = interactionSource) {
|
||||
onDispose {
|
||||
dragInteraction.value?.let { interaction ->
|
||||
interactionSource.tryEmit(DragInteraction.Cancel(interaction))
|
||||
dragInteraction.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var containerSize by remember { mutableIntStateOf(0) }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
|
||||
val isHighlighted by remember {
|
||||
derivedStateOf {
|
||||
isHovered || dragInteraction.value is DragInteraction.Start
|
||||
}
|
||||
}
|
||||
|
||||
val minimalHeight = style.minimalHeight.toPx()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sliderAdapter = remember(
|
||||
adapter,
|
||||
containerSize,
|
||||
minimalHeight,
|
||||
reverseLayout,
|
||||
isVertical,
|
||||
coroutineScope,
|
||||
) {
|
||||
SliderAdapter(
|
||||
adapter,
|
||||
containerSize,
|
||||
minimalHeight,
|
||||
reverseLayout = reverseLayout,
|
||||
isVertical = isVertical,
|
||||
coroutineScope = coroutineScope,
|
||||
)
|
||||
}
|
||||
|
||||
val scrollThickness = style.thickness.roundToPx()
|
||||
val measurePolicy = if (isVertical) {
|
||||
remember(sliderAdapter, scrollThickness) {
|
||||
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
|
||||
}
|
||||
} else {
|
||||
remember(sliderAdapter, scrollThickness) {
|
||||
horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
|
||||
}
|
||||
}
|
||||
|
||||
val color by animateColorAsState(
|
||||
if (isHighlighted) style.hoverColor else style.unhoverColor,
|
||||
animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis),
|
||||
label = "Scrollbar color",
|
||||
)
|
||||
|
||||
val isVisible = sliderAdapter.thumbSize < containerSize
|
||||
|
||||
Layout(
|
||||
{
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (isVisible) color else Color.Transparent, style.shape)
|
||||
.scrollbarDrag(
|
||||
interactionSource = interactionSource,
|
||||
draggedInteraction = dragInteraction,
|
||||
sliderAdapter = sliderAdapter,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier.hoverable(interactionSource = interactionSource),
|
||||
measurePolicy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] [ScrollbarAdapter] for
|
||||
* scrollable container with the given instance [ScrollState].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(
|
||||
scrollState: ScrollState,
|
||||
): ScrollbarAdapter = remember(scrollState) {
|
||||
ScrollbarAdapter(scrollState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] [ScrollbarAdapter] for
|
||||
* lazy scrollable container with the given instance [LazyListState].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(
|
||||
scrollState: LazyListState,
|
||||
): ScrollbarAdapter = remember(scrollState) {
|
||||
ScrollbarAdapter(scrollState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] [ScrollbarAdapter] for lazy grid with
|
||||
* the given instance of [LazyGridState].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(
|
||||
scrollState: LazyGridState,
|
||||
): ScrollbarAdapter = remember(scrollState) {
|
||||
ScrollbarAdapter(scrollState)
|
||||
}
|
||||
|
||||
/**
|
||||
* ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll
|
||||
*
|
||||
* [scrollState] is instance of [ScrollState] which is used by scrollable component
|
||||
*
|
||||
* Example:
|
||||
* val state = rememberScrollState(0)
|
||||
*
|
||||
* Box(Modifier.fillMaxSize()) {
|
||||
* Box(modifier = Modifier.verticalScroll(state)) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* VerticalScrollbar(
|
||||
* adapter = rememberScrollbarAdapter(state)
|
||||
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
fun ScrollbarAdapter(
|
||||
scrollState: ScrollState,
|
||||
): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState)
|
||||
|
||||
/**
|
||||
* ScrollbarAdapter for lazy lists.
|
||||
*
|
||||
* [scrollState] is instance of [LazyListState] which is used by scrollable component
|
||||
*
|
||||
* Example:
|
||||
* Box(Modifier.fillMaxSize()) {
|
||||
* val state = rememberLazyListState()
|
||||
*
|
||||
* LazyColumn(state = state) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* VerticalScrollbar(
|
||||
* adapter = rememberScrollbarAdapter(state)
|
||||
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
fun ScrollbarAdapter(
|
||||
scrollState: LazyListState,
|
||||
): ScrollbarAdapter = LazyListScrollbarAdapter(scrollState)
|
||||
|
||||
/**
|
||||
* ScrollbarAdapter for lazy grids.
|
||||
*
|
||||
* [scrollState] is instance of [LazyGridState] which is used by scrollable component
|
||||
*
|
||||
* Example:
|
||||
* Box(Modifier.fillMaxSize()) {
|
||||
* val state = rememberLazyGridState()
|
||||
*
|
||||
* LazyVerticalGrid(columns = ..., state = state) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* VerticalScrollbar(
|
||||
* adapter = rememberScrollbarAdapter(state)
|
||||
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
* )
|
||||
* }
|
||||
*/
|
||||
fun ScrollbarAdapter(
|
||||
scrollState: LazyGridState,
|
||||
): ScrollbarAdapter = LazyGridScrollbarAdapter(scrollState)
|
||||
|
||||
private val SliderAdapter.thumbPixelRange: IntRange
|
||||
get() {
|
||||
val start = position.roundToInt()
|
||||
val endExclusive = start + thumbSize.roundToInt()
|
||||
|
||||
return (start until endExclusive)
|
||||
}
|
||||
|
||||
private val IntRange.size get() = last + 1 - first
|
||||
|
||||
private fun verticalMeasurePolicy(
|
||||
sliderAdapter: SliderAdapter,
|
||||
setContainerSize: (Int) -> Unit,
|
||||
scrollThickness: Int,
|
||||
) = MeasurePolicy { measurables, constraints ->
|
||||
setContainerSize(constraints.maxHeight)
|
||||
val pixelRange = sliderAdapter.thumbPixelRange
|
||||
val placeable = measurables.first().measure(
|
||||
Constraints.fixed(
|
||||
constraints.constrainWidth(scrollThickness),
|
||||
pixelRange.size,
|
||||
),
|
||||
)
|
||||
layout(placeable.width, constraints.maxHeight) {
|
||||
placeable.place(0, pixelRange.first)
|
||||
}
|
||||
}
|
||||
|
||||
private fun horizontalMeasurePolicy(
|
||||
sliderAdapter: SliderAdapter,
|
||||
setContainerSize: (Int) -> Unit,
|
||||
scrollThickness: Int,
|
||||
) = MeasurePolicy { measurables, constraints ->
|
||||
setContainerSize(constraints.maxWidth)
|
||||
val pixelRange = sliderAdapter.thumbPixelRange
|
||||
val placeable = measurables.first().measure(
|
||||
Constraints.fixed(
|
||||
pixelRange.size,
|
||||
constraints.constrainHeight(scrollThickness),
|
||||
),
|
||||
)
|
||||
layout(constraints.maxWidth, placeable.height) {
|
||||
placeable.place(pixelRange.first, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.scrollbarDrag(
|
||||
interactionSource: MutableInteractionSource,
|
||||
draggedInteraction: MutableState<DragInteraction.Start?>,
|
||||
sliderAdapter: SliderAdapter,
|
||||
): Modifier = composed {
|
||||
val currentInteractionSource by rememberUpdatedState(interactionSource)
|
||||
val currentDraggedInteraction by rememberUpdatedState(draggedInteraction)
|
||||
val currentSliderAdapter by rememberUpdatedState(sliderAdapter)
|
||||
|
||||
pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val interaction = DragInteraction.Start()
|
||||
currentInteractionSource.tryEmit(interaction)
|
||||
currentDraggedInteraction.value = interaction
|
||||
currentSliderAdapter.onDragStarted()
|
||||
val isSuccess = drag(down.id) { change ->
|
||||
currentSliderAdapter.onDragDelta(change.positionChange())
|
||||
change.consume()
|
||||
}
|
||||
val finishInteraction = if (isSuccess) {
|
||||
DragInteraction.Stop(interaction)
|
||||
} else {
|
||||
DragInteraction.Cancel(interaction)
|
||||
}
|
||||
currentInteractionSource.tryEmit(finishInteraction)
|
||||
currentDraggedInteraction.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.scrollbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Defines how to scroll the scrollable component and how to display a scrollbar for it.
|
||||
*
|
||||
* The values of this interface are typically in pixels, but do not have to be.
|
||||
* It's possible to create an adapter with any scroll range of `Double` values.
|
||||
*/
|
||||
interface ScrollbarAdapter {
|
||||
|
||||
// We use `Double` values here in order to allow scrolling both very large (think LazyList with
|
||||
// millions of items) and very small (think something whose natural coordinates are less than 1)
|
||||
// content.
|
||||
|
||||
/**
|
||||
* Scroll offset of the content inside the scrollable component.
|
||||
*
|
||||
* For example, a value of `100` could mean the content is scrolled by 100 pixels from the
|
||||
* start.
|
||||
*/
|
||||
val scrollOffset: Double
|
||||
|
||||
/**
|
||||
* The size of the scrollable content, on the scrollable axis.
|
||||
*/
|
||||
val contentSize: Double
|
||||
|
||||
/**
|
||||
* The size of the viewport, on the scrollable axis.
|
||||
*/
|
||||
val viewportSize: Double
|
||||
|
||||
/**
|
||||
* Instantly jump to [scrollOffset].
|
||||
*
|
||||
* @param scrollOffset target offset to jump to, value will be coerced to the valid
|
||||
* scroll range.
|
||||
*/
|
||||
suspend fun scrollTo(scrollOffset: Double)
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum scroll offset of the scrollable content.
|
||||
*/
|
||||
val ScrollbarAdapter.maxScrollOffset: Double
|
||||
get() = (contentSize - viewportSize).coerceAtLeast(0.0)
|
||||
|
||||
internal class ScrollableScrollbarAdapter(
|
||||
private val scrollState: ScrollState,
|
||||
) : ScrollbarAdapter {
|
||||
|
||||
override val scrollOffset: Double get() = scrollState.value.toDouble()
|
||||
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
scrollState.scrollTo(scrollOffset.roundToInt())
|
||||
}
|
||||
|
||||
override val contentSize: Double
|
||||
// This isn't strictly correct, as the actual content can be smaller
|
||||
// than the viewport when scrollState.maxValue is 0, but the scrollbar
|
||||
// doesn't really care as long as contentSize <= viewportSize; it's
|
||||
// just not showing itself
|
||||
get() = scrollState.maxValue + viewportSize
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = scrollState.viewportSize.toDouble()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for [LazyListScrollbarAdapter] and [LazyGridScrollbarAdapter],
|
||||
* and in the future maybe other lazy widgets that lay out their content in lines.
|
||||
*/
|
||||
internal abstract class LazyLineContentAdapter : ScrollbarAdapter {
|
||||
|
||||
// Implement the adapter in terms of "lines", which means either rows,
|
||||
// (for a vertically scrollable widget) or columns (for a horizontally
|
||||
// scrollable one).
|
||||
// For LazyList this translates directly to items; for LazyGrid, it
|
||||
// translates to rows/columns of items.
|
||||
|
||||
class VisibleLine(
|
||||
val index: Int,
|
||||
val offset: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Return the first visible line, if any.
|
||||
*/
|
||||
protected abstract fun firstVisibleLine(): VisibleLine?
|
||||
|
||||
/**
|
||||
* Return the total number of lines.
|
||||
*/
|
||||
protected abstract fun totalLineCount(): Int
|
||||
|
||||
/**
|
||||
* The sum of content padding (before+after) on the scrollable axis.
|
||||
*/
|
||||
protected abstract fun contentPadding(): Int
|
||||
|
||||
/**
|
||||
* Scroll immediately to the given line, and offset it by [scrollOffset] pixels.
|
||||
*/
|
||||
protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int)
|
||||
|
||||
/**
|
||||
* Scroll from the current position by the given amount of pixels.
|
||||
*/
|
||||
protected abstract suspend fun scrollBy(value: Float)
|
||||
|
||||
/**
|
||||
* Return the average size (on the scrollable axis) of the visible lines.
|
||||
*/
|
||||
protected abstract fun averageVisibleLineSize(): Double
|
||||
|
||||
/**
|
||||
* The spacing between lines.
|
||||
*/
|
||||
protected abstract val lineSpacing: Int
|
||||
|
||||
private val averageVisibleLineSize by derivedStateOf {
|
||||
if (totalLineCount() == 0) {
|
||||
0.0
|
||||
} else {
|
||||
averageVisibleLineSize()
|
||||
}
|
||||
}
|
||||
|
||||
private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing
|
||||
|
||||
override val scrollOffset: Double
|
||||
get() {
|
||||
val firstVisibleLine = firstVisibleLine()
|
||||
return if (firstVisibleLine == null) {
|
||||
0.0
|
||||
} else {
|
||||
firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset
|
||||
}
|
||||
}
|
||||
|
||||
override val contentSize: Double
|
||||
get() {
|
||||
val totalLineCount = totalLineCount()
|
||||
return averageVisibleLineSize * totalLineCount +
|
||||
lineSpacing * (totalLineCount - 1).coerceAtLeast(0) +
|
||||
contentPadding()
|
||||
}
|
||||
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset
|
||||
|
||||
// if we scroll less than viewport we need to use scrollBy function to avoid
|
||||
// undesirable scroll jumps (when an item size is different)
|
||||
//
|
||||
// if we scroll more than viewport we should immediately jump to this position
|
||||
// without recreating all items between the current and the new position
|
||||
if (abs(distance) <= viewportSize) {
|
||||
scrollBy(distance.toFloat())
|
||||
} else {
|
||||
snapTo(scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun snapTo(scrollOffset: Double) {
|
||||
val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset)
|
||||
|
||||
val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing)
|
||||
.toInt()
|
||||
.coerceAtLeast(0)
|
||||
.coerceAtMost(totalLineCount() - 1)
|
||||
|
||||
val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing)
|
||||
.toInt()
|
||||
.coerceAtLeast(0)
|
||||
|
||||
snapToLine(lineIndex = index, scrollOffset = offset)
|
||||
}
|
||||
}
|
||||
|
||||
internal class LazyListScrollbarAdapter(
|
||||
private val scrollState: LazyListState,
|
||||
) : LazyLineContentAdapter() {
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = with(scrollState.layoutInfo) {
|
||||
if (orientation == Orientation.Vertical) {
|
||||
viewportSize.height
|
||||
} else {
|
||||
viewportSize.width
|
||||
}
|
||||
}.toDouble()
|
||||
|
||||
/**
|
||||
* A heuristic that tries to ignore the "currently stickied" header because it breaks the other
|
||||
* computations in this adapter:
|
||||
* - The currently stickied header always appears in the list of visible items, with its
|
||||
* regular index. This makes [firstVisibleLine] always return its index, even if the list has
|
||||
* been scrolled far beyond it.
|
||||
* - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't
|
||||
* overlap, and the stickied item breaks this assumption.
|
||||
*
|
||||
* Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it
|
||||
* could be sticky, but not stickied to the top of the list right now) item, if there is one.
|
||||
*
|
||||
* Note that this heuristic breaks down if the sticky header covers the entire list, so that
|
||||
* it's the only visible item for some portion of the scroll range. But there's currently no
|
||||
* known better way to solve it, and it's a relatively unusual case.
|
||||
*/
|
||||
private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo) {
|
||||
when (size) {
|
||||
0 -> null
|
||||
1 -> 0
|
||||
else -> {
|
||||
val first = this[0]
|
||||
val second = this[1]
|
||||
// If either the indices or the offsets aren't continuous, then the first item is
|
||||
// sticky, so we return 1
|
||||
if ((first.index < second.index - 1) ||
|
||||
(first.offset + first.size + lineSpacing > second.offset)
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun firstVisibleLine(): VisibleLine? {
|
||||
val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null
|
||||
val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex]
|
||||
return VisibleLine(
|
||||
index = firstFloatingItem.index,
|
||||
offset = firstFloatingItem.offset,
|
||||
)
|
||||
}
|
||||
|
||||
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
|
||||
|
||||
override fun contentPadding() = with(scrollState.layoutInfo) {
|
||||
beforeContentPadding + afterContentPadding
|
||||
}
|
||||
|
||||
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
|
||||
scrollState.scrollToItem(lineIndex, scrollOffset)
|
||||
}
|
||||
|
||||
override suspend fun scrollBy(value: Float) {
|
||||
scrollState.scrollBy(value)
|
||||
}
|
||||
|
||||
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
|
||||
val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0
|
||||
val first = this[firstFloatingIndex]
|
||||
val last = last()
|
||||
val count = size - firstFloatingIndex
|
||||
(last.offset + last.size - first.offset - (count - 1) * lineSpacing).toDouble() / count
|
||||
}
|
||||
|
||||
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
|
||||
}
|
||||
|
||||
internal class LazyGridScrollbarAdapter(
|
||||
private val scrollState: LazyGridState,
|
||||
) : LazyLineContentAdapter() {
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = with(scrollState.layoutInfo) {
|
||||
if (isVertical) {
|
||||
viewportSize.height
|
||||
} else {
|
||||
viewportSize.width
|
||||
}
|
||||
}.toDouble()
|
||||
|
||||
private val isVertical by lazy {
|
||||
scrollState.layoutInfo.orientation == Orientation.Vertical
|
||||
}
|
||||
|
||||
private val unknownLine = with(LazyGridItemInfo) {
|
||||
if (isVertical) UnknownRow else UnknownColumn
|
||||
}
|
||||
|
||||
private fun LazyGridItemInfo.line() = if (isVertical) row else column
|
||||
|
||||
private fun LazyGridItemInfo.mainAxisSize() = with(size) {
|
||||
if (isVertical) height else width
|
||||
}
|
||||
|
||||
private fun LazyGridItemInfo.mainAxisOffset() = with(offset) {
|
||||
if (isVertical) y else x
|
||||
}
|
||||
|
||||
private fun slotsPerLine(): Int = scrollState.layoutInfo.visibleItemsInfo
|
||||
.groupingBy { it.line() }.eachCount().maxOfOrNull { it.value } ?: 1
|
||||
|
||||
private fun lineOfIndex(index: Int) = index / slotsPerLine()
|
||||
|
||||
private fun indexOfFirstInLine(line: Int) = line * slotsPerLine()
|
||||
|
||||
override fun firstVisibleLine(): VisibleLine? = scrollState.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.line() != unknownLine } // Skip exiting items
|
||||
?.let { firstVisibleItem ->
|
||||
VisibleLine(
|
||||
index = firstVisibleItem.line(),
|
||||
offset = firstVisibleItem.mainAxisOffset(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun totalLineCount(): Int {
|
||||
val itemCount = scrollState.layoutInfo.totalItemsCount
|
||||
return if (itemCount == 0) {
|
||||
0
|
||||
} else {
|
||||
lineOfIndex(itemCount - 1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun contentPadding() = with(scrollState.layoutInfo) {
|
||||
beforeContentPadding + afterContentPadding
|
||||
}
|
||||
|
||||
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
|
||||
scrollState.scrollToItem(
|
||||
index = indexOfFirstInLine(lineIndex),
|
||||
scrollOffset = scrollOffset,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun scrollBy(value: Float) {
|
||||
scrollState.scrollBy(value)
|
||||
}
|
||||
|
||||
override fun averageVisibleLineSize(): Double {
|
||||
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
|
||||
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
|
||||
if (indexOfFirstKnownLineItem == -1) {
|
||||
return 0.0
|
||||
}
|
||||
val reallyVisibleItemsInfo = // Non-exiting visible items
|
||||
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
|
||||
|
||||
// Compute the size of the last line
|
||||
val lastLine = reallyVisibleItemsInfo.last().line()
|
||||
val lastLineSize = reallyVisibleItemsInfo
|
||||
.asReversed()
|
||||
.asSequence()
|
||||
.takeWhile { it.line() == lastLine }
|
||||
.maxOf { it.mainAxisSize() }
|
||||
|
||||
val first = reallyVisibleItemsInfo.first()
|
||||
val last = reallyVisibleItemsInfo.last()
|
||||
val lineCount = last.line() - first.line() + 1
|
||||
val lineSpacingSum = (lineCount - 1) * lineSpacing
|
||||
return (
|
||||
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
|
||||
).toDouble() / lineCount
|
||||
}
|
||||
|
||||
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
|
||||
}
|
||||
|
||||
internal class SliderAdapter(
|
||||
private val adapter: ScrollbarAdapter,
|
||||
private val trackSize: Int,
|
||||
private val minHeight: Float,
|
||||
private val reverseLayout: Boolean,
|
||||
private val isVertical: Boolean,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
|
||||
private val contentSize get() = adapter.contentSize
|
||||
private val visiblePart: Double
|
||||
get() {
|
||||
val contentSize = contentSize
|
||||
return if (contentSize == 0.0) {
|
||||
1.0
|
||||
} else {
|
||||
(adapter.viewportSize / contentSize).coerceAtMost(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
val thumbSize
|
||||
get() = (trackSize * visiblePart).coerceAtLeast(minHeight.toDouble())
|
||||
|
||||
private val scrollScale: Double
|
||||
get() {
|
||||
val extraScrollbarSpace = trackSize - thumbSize
|
||||
val extraContentSpace = adapter.maxScrollOffset // == contentSize - viewportSize
|
||||
return if (extraContentSpace == 0.0) 1.0 else extraScrollbarSpace / extraContentSpace
|
||||
}
|
||||
|
||||
private val rawPosition: Double
|
||||
get() = scrollScale * adapter.scrollOffset
|
||||
|
||||
val position: Double
|
||||
get() = if (reverseLayout) trackSize - thumbSize - rawPosition else rawPosition
|
||||
|
||||
// How much of the current drag was ignored because we've reached the end of the scrollbar area
|
||||
private var unscrolledDragDistance = 0.0
|
||||
|
||||
/** Called when the thumb dragging starts */
|
||||
fun onDragStarted() {
|
||||
unscrolledDragDistance = 0.0
|
||||
}
|
||||
|
||||
private suspend fun setPosition(value: Double) {
|
||||
val rawPosition = if (reverseLayout) {
|
||||
trackSize - thumbSize - value
|
||||
} else {
|
||||
value
|
||||
}
|
||||
adapter.scrollTo(rawPosition / scrollScale)
|
||||
}
|
||||
|
||||
private val dragMutex = Mutex()
|
||||
|
||||
/** Called on every movement while dragging the thumb */
|
||||
fun onDragDelta(offset: Offset) {
|
||||
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
// Mutex is used to ensure that all earlier drag deltas were applied
|
||||
// before calculating a new raw position
|
||||
dragMutex.withLock {
|
||||
val dragDelta = if (isVertical) offset.y else offset.x
|
||||
val maxScrollPosition = adapter.maxScrollOffset * scrollScale
|
||||
val currentPosition = position
|
||||
val targetPosition =
|
||||
(currentPosition + dragDelta + unscrolledDragDistance).coerceIn(
|
||||
0.0,
|
||||
maxScrollPosition,
|
||||
)
|
||||
val sliderDelta = targetPosition - currentPosition
|
||||
|
||||
// Have to add to position for smooth content scroll if the items are of different size
|
||||
val newPos = position + sliderDelta
|
||||
setPosition(newPos)
|
||||
unscrolledDragDistance += dragDelta - sliderDelta
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,13 +76,11 @@ fun SearchBarInputField(
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading -> { Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } } },
|
||||
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.exponentialDecay
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -42,17 +39,12 @@ import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
@@ -61,9 +53,6 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
|
||||
private const val DrawerPositionalThreshold = 0.5f
|
||||
private val DrawerVelocityThreshold = 400.dp
|
||||
private val ContainerWidth = 360.0.dp
|
||||
|
||||
@Suppress("NotCloseable")
|
||||
@Stable
|
||||
@@ -74,11 +63,7 @@ class DrawerState2(
|
||||
|
||||
val anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
snapAnimationSpec = AnimationSpec,
|
||||
decayAnimationSpec = exponentialDecay(7.5f),
|
||||
confirmValueChange = confirmStateChange,
|
||||
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
|
||||
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -239,10 +224,8 @@ class DrawerState2(
|
||||
fun rememberDrawerState2(
|
||||
initialValue: DrawerValue,
|
||||
confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
): DrawerState2 {
|
||||
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
|
||||
DrawerState2(initialValue, confirmStateChange)
|
||||
}
|
||||
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
|
||||
DrawerState2(initialValue, confirmStateChange)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -256,60 +239,34 @@ fun ModalSideDrawer(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
var maxValue by remember { mutableFloatStateOf(with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() }) }
|
||||
val minValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
DrawerValue.Closed at maxValue
|
||||
DrawerValue.Open at minValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drag.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
Box(
|
||||
modifier.fillMaxSize().anchoredHorizontalDraggable(
|
||||
state = drawerState.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = drawerState.isOpen,
|
||||
enableDragFromEndToStart = drawerState.isClosed,
|
||||
enabled = gesturesEnabled,
|
||||
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
|
||||
state = drawerState.anchoredDraggableState,
|
||||
animationSpec = AnimationSpec,
|
||||
),
|
||||
),
|
||||
) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
@@ -331,7 +288,7 @@ fun ModalSideDrawer(
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
calculateFraction(maxValue, minValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
@@ -349,7 +306,7 @@ fun ModalSideDrawer(
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
|
||||
reMeasured.placeRelative(placeable.width - scaledWidth, 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
@@ -361,14 +318,20 @@ fun ModalSideDrawer(
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
IntOffset(lerp(0f, maxValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = it.size.width.toFloat()
|
||||
Modifier.onSizeChanged {
|
||||
val width = it.width
|
||||
if (width != 0) {
|
||||
maxValue = width.toFloat()
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at maxValue
|
||||
DrawerValue.Open at minValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.fork
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.draggableAnchors
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
private val SnapAnimationSpec = SpringSpec<Float>()
|
||||
|
||||
/**
|
||||
* State of the [SwipeToDismissBox] composable.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
class SwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue,
|
||||
internal val density: Density,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
) {
|
||||
internal val anchoredDraggableState =
|
||||
AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
confirmValueChange = confirmValueChange,
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
/**
|
||||
* Require the current offset.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* The current state value of the [SwipeToDismissBoxState].
|
||||
*/
|
||||
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target state. This is the closest state to the current offset (taking into account
|
||||
* positional thresholds). If no interactions like animations or drags are in progress, this
|
||||
* will be the current state.
|
||||
*/
|
||||
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
|
||||
*/
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
|
||||
|
||||
/**
|
||||
* The direction (if any) in which the composable has been or is being dismissed.
|
||||
*
|
||||
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
|
||||
* on each side.
|
||||
*/
|
||||
val dismissDirection: SwipeToDismissBoxValue
|
||||
get() = when {
|
||||
offset == 0f || offset.isNaN() -> SwipeToDismissBoxValue.Settled
|
||||
offset > 0f -> SwipeToDismissBoxValue.StartToEnd
|
||||
else -> SwipeToDismissBoxValue.EndToStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position with animation and suspend until it if fully
|
||||
* reset or animation has been cancelled. This method will throw [CancellationException] if
|
||||
* the animation is interrupted
|
||||
*
|
||||
* @return the reason the reset animation ended
|
||||
*/
|
||||
suspend fun reset() = anchoredDraggableState.animateTo(
|
||||
targetValue = SwipeToDismissBoxValue.Settled,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dismiss the component in the given [direction], with an animation and suspend. This method
|
||||
* will throw [CancellationException] if the animation is interrupted
|
||||
*
|
||||
* @param direction The dismiss direction.
|
||||
*/
|
||||
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.animateTo(targetValue = direction)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SwipeToDismissBoxState].
|
||||
*/
|
||||
fun Saver(
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
|
||||
density: Density,
|
||||
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
|
||||
save = { it.currentValue },
|
||||
restore = {
|
||||
SwipeToDismissBoxState(
|
||||
it,
|
||||
density,
|
||||
confirmValueChange,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SwipeToDismissBoxState].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
): SwipeToDismissBoxState {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
saver = SwipeToDismissBoxState.Saver(
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
),
|
||||
) {
|
||||
SwipeToDismissBoxState(initialValue, density, confirmValueChange)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that can be dismissed by swiping left or right.
|
||||
*
|
||||
* @param state The state of this component.
|
||||
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
|
||||
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||
* @param modifier Optional [Modifier] for this component.
|
||||
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
|
||||
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
|
||||
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
|
||||
* @param content The content that can be dismissed.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeToDismissBox(
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enableDismissFromStartToEnd: Boolean = true,
|
||||
enableDismissFromEndToStart: Boolean = true,
|
||||
gesturesEnabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.anchoredHorizontalDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = enableDismissFromStartToEnd,
|
||||
enableDragFromEndToStart = enableDismissFromEndToStart,
|
||||
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
|
||||
state = state.anchoredDraggableState,
|
||||
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
|
||||
animationSpec = SnapAnimationSpec,
|
||||
),
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
Row(
|
||||
content = backgroundContent,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
Row(
|
||||
content = content,
|
||||
modifier = Modifier.draggableAnchors(
|
||||
state.anchoredDraggableState,
|
||||
Orientation.Horizontal,
|
||||
) { size, _ ->
|
||||
val width = size.width.toFloat()
|
||||
return@draggableAnchors DraggableAnchors {
|
||||
SwipeToDismissBoxValue.Settled at 0f
|
||||
if (enableDismissFromStartToEnd) {
|
||||
SwipeToDismissBoxValue.StartToEnd at width
|
||||
}
|
||||
if (enableDismissFromEndToStart) {
|
||||
SwipeToDismissBoxValue.EndToStart at -width
|
||||
}
|
||||
} to state.targetValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
-98
@@ -56,7 +56,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
// Current quickscale state
|
||||
private val quickScaleThreshold: Float
|
||||
private val quickScaleThreshold =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
|
||||
|
||||
// Long click handler
|
||||
private var longClickJob: Job? = null
|
||||
@@ -85,9 +86,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
@ScaleType
|
||||
private var minimumScaleType = SCALE_TYPE_CENTER_INSIDE
|
||||
|
||||
// Whether to crop borders.
|
||||
private var cropBorders = false
|
||||
|
||||
// Gesture detection settings
|
||||
private var panEnabled = true
|
||||
|
||||
@@ -186,15 +184,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
var isReady = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Call to find whether the main image (base layer tiles where relevant) have been loaded. Before
|
||||
* this event the view is blank unless a preview was provided.
|
||||
*
|
||||
* @return true if the main image (not the preview) has been loaded and is ready to display.
|
||||
*/
|
||||
var isImageLoaded = false
|
||||
private set
|
||||
|
||||
// Event listener
|
||||
private var onImageEventListener: OnImageEventListener? = null
|
||||
|
||||
@@ -218,11 +207,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
private val mtrx = Matrix()
|
||||
|
||||
init {
|
||||
setMinimumDpi(160)
|
||||
setDoubleTapZoomDpi(160)
|
||||
setGestureDetector(context)
|
||||
quickScaleThreshold =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,7 +255,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
sHeight = 0
|
||||
srcRect.setEmpty()
|
||||
isReady = false
|
||||
isImageLoaded = false
|
||||
bitmap = null
|
||||
bitmapIsCached = false
|
||||
}
|
||||
@@ -768,19 +752,13 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the base layer of tiles or full size bitmap is ready.
|
||||
*/
|
||||
private val isBaseLayerReady: Boolean
|
||||
get() = bitmap != null
|
||||
|
||||
/**
|
||||
* Check whether view and image dimensions are known and either a preview, full size image or
|
||||
* base layer tiles are loaded. First time, send ready event to listener. The next draw will
|
||||
* display an image.
|
||||
*/
|
||||
private fun checkReady(): Boolean {
|
||||
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && (bitmap != null || isBaseLayerReady)
|
||||
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && bitmap != null
|
||||
if (!isReady && ready) {
|
||||
preDraw()
|
||||
isReady = true
|
||||
@@ -789,19 +767,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
return ready
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether either the full size bitmap or base layer tiles are loaded. First time, send image
|
||||
* loaded event to listener.
|
||||
*/
|
||||
private fun checkImageLoaded(): Boolean {
|
||||
val imageLoaded = isBaseLayerReady
|
||||
if (!isImageLoaded && imageLoaded) {
|
||||
preDraw()
|
||||
isImageLoaded = true
|
||||
}
|
||||
return imageLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets scale and translate ready for the next draw.
|
||||
*/
|
||||
@@ -832,13 +797,14 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* is set so one dimension fills the view and the image is centered on the other dimension. Used to calculate what the target of an
|
||||
* animation should be.
|
||||
*
|
||||
* @param _center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
|
||||
* @param maybeCenter Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
|
||||
* @param sat The scale we want and the translation we're aiming for. The values are adjusted to be valid.
|
||||
*/
|
||||
private fun fitToBounds(_center: Boolean, sat: ScaleAndTranslate) {
|
||||
var center = _center
|
||||
if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
|
||||
center = false
|
||||
private fun fitToBounds(maybeCenter: Boolean, sat: ScaleAndTranslate) {
|
||||
val center = if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
|
||||
false
|
||||
} else {
|
||||
maybeCenter
|
||||
}
|
||||
val vTranslate = sat.vTranslate
|
||||
val scale = limitedScale(sat.scale)
|
||||
@@ -920,21 +886,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
this.bitmap = bitmap
|
||||
sWidth = srcRect.width()
|
||||
sHeight = srcRect.height()
|
||||
val ready = checkReady()
|
||||
val imageLoaded = checkImageLoaded()
|
||||
if (ready || imageLoaded) {
|
||||
invalidate()
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set border crop of non-filled (white or black) content.
|
||||
*
|
||||
* @param cropBorders Whether to crop image borders.
|
||||
*/
|
||||
fun setCropBorders(cropBorders: Boolean) {
|
||||
this.cropBorders = cropBorders
|
||||
checkReady()
|
||||
invalidate()
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -958,16 +912,12 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
/**
|
||||
* Convert screen to source x coordinate.
|
||||
*/
|
||||
private fun viewToSourceX(vx: Float): Float {
|
||||
return if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
|
||||
}
|
||||
private fun viewToSourceX(vx: Float): Float = if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
|
||||
|
||||
/**
|
||||
* Convert screen to source y coordinate.
|
||||
*/
|
||||
private fun viewToSourceY(vy: Float): Float {
|
||||
return if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
|
||||
}
|
||||
private fun viewToSourceY(vy: Float): Float = if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
|
||||
|
||||
/**
|
||||
* Convert screen coordinate to source coordinate.
|
||||
@@ -975,9 +925,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param vxy view X/Y coordinate.
|
||||
* @return a coordinate representing the corresponding source coordinate.
|
||||
*/
|
||||
fun viewToSourceCoord(vxy: PointF): PointF? {
|
||||
return viewToSourceCoord(vxy.x, vxy.y, PointF())
|
||||
}
|
||||
fun viewToSourceCoord(vxy: PointF): PointF? = viewToSourceCoord(vxy.x, vxy.y, PointF())
|
||||
|
||||
/**
|
||||
* Convert screen coordinate to source coordinate.
|
||||
@@ -987,7 +935,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param sTarget target object for result. The same instance is also returned.
|
||||
* @return a coordinate representing the corresponding source coordinate. This is the same instance passed to the sTarget param.
|
||||
*/
|
||||
fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
|
||||
private fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
|
||||
if (vTranslate == null) {
|
||||
return null
|
||||
}
|
||||
@@ -998,16 +946,12 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
/**
|
||||
* Convert source to view x coordinate.
|
||||
*/
|
||||
private fun sourceToViewX(sx: Float): Float {
|
||||
return if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
|
||||
}
|
||||
private fun sourceToViewX(sx: Float): Float = if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
|
||||
|
||||
/**
|
||||
* Convert source to view y coordinate.
|
||||
*/
|
||||
private fun sourceToViewY(sy: Float): Float {
|
||||
return if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
|
||||
}
|
||||
private fun sourceToViewY(sy: Float): Float = if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
|
||||
|
||||
/**
|
||||
* Convert source coordinate to view coordinate.
|
||||
@@ -1015,9 +959,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param sxy source coordinates to convert.
|
||||
* @return view coordinates.
|
||||
*/
|
||||
fun sourceToViewCoord(sxy: PointF?): PointF? {
|
||||
return sourceToViewCoord(sxy!!.x, sxy.y, PointF())
|
||||
}
|
||||
fun sourceToViewCoord(sxy: PointF?): PointF? = sourceToViewCoord(sxy!!.x, sxy.y, PointF())
|
||||
|
||||
/**
|
||||
* Convert source coordinate to view coordinate.
|
||||
@@ -1027,7 +969,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param vTarget target object for result. The same instance is also returned.
|
||||
* @return view coordinates. This is the same instance passed to the vTarget param.
|
||||
*/
|
||||
fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
|
||||
private fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
|
||||
if (vTranslate == null) {
|
||||
return null
|
||||
}
|
||||
@@ -1099,9 +1041,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
/**
|
||||
* Adjust a requested scale to be within the allowed limits.
|
||||
*/
|
||||
private fun limitedScale(targetScale: Float): Float {
|
||||
return targetScale.coerceIn(minScale(), maxScale)
|
||||
}
|
||||
private fun limitedScale(targetScale: Float): Float = targetScale.coerceIn(minScale(), maxScale)
|
||||
|
||||
/**
|
||||
* Apply a selected type of easing.
|
||||
@@ -1113,12 +1053,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param duration Anm duration
|
||||
* @return Current value
|
||||
*/
|
||||
private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float {
|
||||
return when (type) {
|
||||
EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration)
|
||||
EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration)
|
||||
else -> throw IllegalStateException("Unexpected easing type: $type")
|
||||
}
|
||||
private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float = when (type) {
|
||||
EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration)
|
||||
EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration)
|
||||
else -> throw IllegalStateException("Unexpected easing type: $type")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1245,9 +1183,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
*
|
||||
* @return the minimum scale as a source/view pixels ratio.
|
||||
*/
|
||||
fun getMinScale(): Float {
|
||||
return minScale()
|
||||
}
|
||||
fun getMinScale(): Float = minScale()
|
||||
|
||||
/**
|
||||
* Set the minimum scale allowed. A value of 1 means 1:1 pixels at minimum scale. You may wish to set this according
|
||||
@@ -1371,9 +1307,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param sCenter Target center point
|
||||
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
|
||||
*/
|
||||
fun animateCenter(sCenter: PointF): AnimationBuilder? {
|
||||
return if (isReady) AnimationBuilder(sCenter) else null
|
||||
}
|
||||
fun animateCenter(sCenter: PointF): AnimationBuilder? = if (isReady) AnimationBuilder(sCenter) else null
|
||||
|
||||
/**
|
||||
* Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image
|
||||
@@ -1383,9 +1317,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param sCenter Target source center.
|
||||
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
|
||||
*/
|
||||
fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? {
|
||||
return if (isReady) AnimationBuilder(scale, sCenter) else null
|
||||
}
|
||||
fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? = if (isReady) AnimationBuilder(scale, sCenter) else null
|
||||
|
||||
/**
|
||||
* An event listener, allowing subclasses and activities to be notified of significant events.
|
||||
@@ -1704,7 +1636,5 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* State change originated from a double tap zoom anim.
|
||||
*/
|
||||
const val ORIGIN_DOUBLE_TAP_ZOOM = 4
|
||||
|
||||
private val TAG = SubsamplingScaleImageView::class.java.simpleName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.accompanist.systemuicontroller
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* A class which provides easy-to-use utilities for updating the System UI bar
|
||||
* colors within Jetpack Compose.
|
||||
*/
|
||||
@Stable
|
||||
interface SystemUiController {
|
||||
|
||||
/**
|
||||
* Control for the behavior of the system bars. This value should be one of the
|
||||
* [WindowInsetsControllerCompat] behavior constants:
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
|
||||
*/
|
||||
var systemBarsBehavior: Int
|
||||
|
||||
/**
|
||||
* Property which holds the status bar visibility. If set to true, show the status bar,
|
||||
* otherwise hide the status bar.
|
||||
*/
|
||||
var isStatusBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the navigation bar visibility. If set to true, show the navigation bar,
|
||||
* otherwise hide the navigation bar.
|
||||
*/
|
||||
var isNavigationBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the status & navigation bar visibility. If set to true, show both bars,
|
||||
* otherwise hide both bars.
|
||||
*/
|
||||
var isSystemBarsVisible: Boolean
|
||||
get() = isNavigationBarVisible && isStatusBarVisible
|
||||
set(value) {
|
||||
isStatusBarVisible = value
|
||||
isNavigationBarVisible = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers a [SystemUiController] for the given [window].
|
||||
*
|
||||
* If no [window] is provided, an attempt to find the correct [Window] is made.
|
||||
*
|
||||
* First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
|
||||
* be used.
|
||||
*
|
||||
* Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
|
||||
*
|
||||
* If none of these are found (such as may happen in a preview), then the functionality of the
|
||||
* returned [SystemUiController] will be degraded, but won't throw an exception.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSystemUiController(
|
||||
window: Window? = findWindow(),
|
||||
): SystemUiController {
|
||||
val view = LocalView.current
|
||||
return remember(view, window) { AndroidSystemUiController(view, window) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun findWindow(): Window? =
|
||||
(LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
?: LocalView.current.context.findWindow()
|
||||
|
||||
private tailrec fun Context.findWindow(): Window? =
|
||||
when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.findWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper class for setting the navigation and status bar colors for a [View], gracefully
|
||||
* degrading behavior based upon API level.
|
||||
*
|
||||
* Typically you would use [rememberSystemUiController] to remember an instance of this.
|
||||
*/
|
||||
internal class AndroidSystemUiController(
|
||||
private val view: View,
|
||||
window: Window?,
|
||||
) : SystemUiController {
|
||||
private val windowInsetsController = window?.let {
|
||||
WindowCompat.getInsetsController(it, view)
|
||||
}
|
||||
|
||||
override var systemBarsBehavior: Int
|
||||
get() = windowInsetsController?.systemBarsBehavior ?: 0
|
||||
set(value) {
|
||||
windowInsetsController?.systemBarsBehavior = value
|
||||
}
|
||||
|
||||
override var isStatusBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var isNavigationBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.asImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ktor.KtorNetworkFetcherFactory
|
||||
@@ -43,19 +43,23 @@ import com.hippo.ehviewer.coil.CropBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
|
||||
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.CronetEngine
|
||||
import com.hippo.ehviewer.ktor.Cronet
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.ui.tools.dataStateFlow
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -75,7 +79,9 @@ import splitties.init.appCtx
|
||||
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
class EhApplication :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
@@ -103,13 +109,13 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
super.onCreate()
|
||||
System.loadLibrary("ehviewer")
|
||||
lifecycleScope.launchIO {
|
||||
launch { EhTagDatabase }
|
||||
launch { EhDB }
|
||||
dataStateFlow.value
|
||||
launch {
|
||||
EhTagDatabase
|
||||
}
|
||||
launch {
|
||||
EhDB
|
||||
}
|
||||
launch {
|
||||
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
|
||||
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
|
||||
}
|
||||
DownloadManager.readMetadataFromLocal()
|
||||
}
|
||||
launch {
|
||||
@@ -166,8 +172,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
add(KtorNetworkFetcherFactory { ktorClient })
|
||||
add(MergeInterceptor)
|
||||
add(DownloadThumbInterceptor)
|
||||
if (isAtLeastO) {
|
||||
add(HardwareBitmapInterceptor)
|
||||
}
|
||||
add(CropBorderInterceptor)
|
||||
add(HardwareBitmapInterceptor)
|
||||
if (isAtLeastP) {
|
||||
add(AnimatedImageDecoder.Factory(false))
|
||||
} else {
|
||||
@@ -177,7 +185,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
diskCache { imageCache }
|
||||
crossfade(300)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asCoilImage(true))
|
||||
if (drawable != null) error(drawable.asImage(true))
|
||||
if (BuildConfig.DEBUG) {
|
||||
logger(DebugLogger())
|
||||
} else {
|
||||
@@ -193,7 +201,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
companion object {
|
||||
val ktorClient by lazy {
|
||||
HttpClient(CronetEngine) {
|
||||
HttpClient(Cronet) {
|
||||
engine {
|
||||
client = cronetHttpClient
|
||||
}
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
@@ -201,7 +212,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
|
||||
val noRedirectKtorClient by lazy {
|
||||
HttpClient(CronetEngine) {
|
||||
HttpClient(ktorClient.engine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.net.Uri
|
||||
import arrow.fx.coroutines.release
|
||||
import arrow.fx.coroutines.resource
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadDirname
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
@@ -32,9 +33,9 @@ import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.dao.Schema17to18
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
|
||||
object EhDB {
|
||||
@@ -113,8 +114,11 @@ object EhDB {
|
||||
dao.insertOrIgnore(downloadDirnameList)
|
||||
}
|
||||
|
||||
val downloadsCount
|
||||
get() = db.downloadsDao().count()
|
||||
val downloadsCountByLabel
|
||||
get() = db.downloadsDao().countByLabel()
|
||||
|
||||
val downloadsCountByArtist
|
||||
get() = db.downloadsDao().countByArtist()
|
||||
|
||||
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
|
||||
|
||||
@@ -142,6 +146,14 @@ object EhDB {
|
||||
dao.fill(raw.position)
|
||||
}
|
||||
|
||||
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
|
||||
if (artists.isNotEmpty()) {
|
||||
val dao = db.downloadArtistDao()
|
||||
dao.deleteByGid(gid)
|
||||
dao.insert(artists)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
|
||||
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
@@ -261,10 +273,10 @@ object EhDB {
|
||||
db.filterDao().update(filter)
|
||||
}
|
||||
|
||||
fun exportDB(context: Context, file: UniFile) {
|
||||
fun exportDB(context: Context, file: Path) {
|
||||
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
|
||||
val dbFile = context.getDatabasePath(DB_NAME)
|
||||
dbFile.asUniFile() sendTo file
|
||||
dbFile.toOkioPath() sendTo file
|
||||
}
|
||||
|
||||
suspend fun importDB(context: Context, uri: Uri) {
|
||||
@@ -279,49 +291,39 @@ object EhDB {
|
||||
it.close()
|
||||
context.deleteDatabase(tempDBName)
|
||||
} use { oldDB ->
|
||||
runCatching {
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
runCatching {
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q -> q.position = index + offset }
|
||||
importQuickSearch(importList)
|
||||
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
runCatching {
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
}
|
||||
runCatching {
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q -> q.position = index + offset }
|
||||
importQuickSearch(importList)
|
||||
}
|
||||
runCatching {
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val filterList = oldDB.filterDao().list()
|
||||
val currentFilterList = db.filterDao().list()
|
||||
filterList.forEach {
|
||||
if (it !in currentFilterList) addFilter(it)
|
||||
}
|
||||
|
||||
val filterList = oldDB.filterDao().list()
|
||||
val currentFilterList = db.filterDao().list()
|
||||
filterList.forEach {
|
||||
if (it !in currentFilterList) addFilter(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.download.SortMode
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
@@ -98,6 +100,7 @@ fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
|
||||
object Settings : DataStorePreferences(null) {
|
||||
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
|
||||
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private val _favFlow = MutableSharedFlow<Unit>()
|
||||
val favChangesFlow = _favFlow.debounce(1000)
|
||||
var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow)
|
||||
@@ -110,6 +113,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
|
||||
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
|
||||
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
val gridView = boolPref("grid_view", false)
|
||||
@@ -117,7 +122,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val qSSaveProgress = boolPref("qs_save_progress", true)
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
val displayName = stringOrNullPref("display_name", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
@@ -135,6 +141,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var readCacheSize by intPref("read_cache_size_2", 640)
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
|
||||
var forceEhThumb by boolPref("force_eh_thumb", false)
|
||||
var showComments by boolPref("show_gallery_comments", true)
|
||||
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
@@ -144,7 +151,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var backupBeforeUpdate by boolPref("backup_before_update", true)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
|
||||
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
|
||||
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
|
||||
var saveParseErrorBody by boolPref("save_parse_error_body", true)
|
||||
@@ -160,21 +167,13 @@ object Settings : DataStorePreferences(null) {
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var lastDawnDays by intPref("last_dawn_days", 0)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
|
||||
var displayName by stringOrNullPref("display_name", null)
|
||||
var avatar by stringOrNullPref("avatar", null)
|
||||
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
|
||||
var lastUpdateTime by longPref("last_update_time", 0)
|
||||
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
|
||||
|
||||
// TODO: Remove this after swipe gestures are correctly handled in compose
|
||||
var touchSlopFactor by intPref("touch_slop", 3)
|
||||
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
|
||||
|
||||
// Tachiyomi Reader
|
||||
var newReader by boolPref("new_compose_reader", false)
|
||||
@@ -203,7 +202,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val invertedColors = boolPref("pref_inverted_colors", false)
|
||||
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
|
||||
val readerPagerNav = intPref("reader_navigation_mode_pager", 0)
|
||||
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon", 0)
|
||||
val readerPagerNavInverted = intPref("reader_tapping_inverted_2", 0)
|
||||
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon_2", 0)
|
||||
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
|
||||
val navigateToPan = boolPref("navigate_pan", true)
|
||||
val imageScaleType = intPref("pref_image_scale_type_key", 1)
|
||||
@@ -227,7 +227,7 @@ object Settings : DataStorePreferences(null) {
|
||||
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
|
||||
@@ -239,7 +239,7 @@ object Settings : DataStorePreferences(null) {
|
||||
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
@Suppress("ktlint:standard:backing-property-naming")
|
||||
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
|
||||
@@ -27,15 +25,6 @@ fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { col
|
||||
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
|
||||
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
|
||||
|
||||
fun updateWhenLocaleChanges() {
|
||||
val newValue = Settings.language
|
||||
if ("system" == newValue) {
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
|
||||
} else {
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenKeepMediaStatusChanges() {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
@@ -64,7 +53,7 @@ fun updateWhenRequestNewsChanges() {
|
||||
}
|
||||
|
||||
fun updateWhenGallerySiteChanges() {
|
||||
if (!Settings.needSignIn && EhCookieStore.hasSignedIn()) {
|
||||
if (Settings.hasSignedIn.value) {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
EhEngine.getUConfig()
|
||||
|
||||
@@ -29,13 +29,9 @@ private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
|
||||
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
|
||||
|
||||
fun getImageKey(gid: Long, index: Int): String {
|
||||
return "image:$gid:$index"
|
||||
}
|
||||
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
|
||||
|
||||
fun getThumbKey(url: String): String {
|
||||
return url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
|
||||
}
|
||||
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
|
||||
|
||||
val GalleryPreview.url
|
||||
get() = if (exThumb) {
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastJoinToString
|
||||
import io.ktor.client.plugins.cookies.CookiesStorage
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.parseClientCookiesHeader
|
||||
import io.ktor.http.renderCookieHeader
|
||||
import io.ktor.http.renderSetCookieHeader
|
||||
|
||||
object EhCookieStore : CookiesStorage {
|
||||
private val manager = CookieManager.getInstance()
|
||||
fun signOut() = manager.removeAllCookies(null)
|
||||
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
|
||||
fun removeAllCookies() = manager.removeAllCookies(null)
|
||||
|
||||
fun hasSignedIn(): Boolean {
|
||||
val url = EhUrl.HOST_E
|
||||
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
|
||||
}
|
||||
fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
|
||||
containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
|
||||
} ?: false
|
||||
|
||||
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
|
||||
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
|
||||
@@ -53,11 +48,7 @@ object EhCookieStore : CookiesStorage {
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
fun getCookieHeader(url: String): String {
|
||||
return load(Url(url)).fastJoinToString("; ") {
|
||||
renderCookieHeader(it)
|
||||
}
|
||||
}
|
||||
fun getCookieHeader(url: String): String? = manager.getCookie(url)
|
||||
|
||||
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
|
||||
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
|
||||
|
||||
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.partially2
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
@@ -44,8 +45,10 @@ import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.client.parser.ProfileParser
|
||||
import com.hippo.ehviewer.client.parser.RateGalleryResult
|
||||
import com.hippo.ehviewer.client.parser.SignInParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentResult
|
||||
import com.hippo.ehviewer.client.parser.UserConfigParser
|
||||
import com.hippo.ehviewer.client.parser.VoteCommentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
@@ -62,6 +65,7 @@ import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerializationException
|
||||
@@ -80,11 +84,9 @@ private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
private const val REQUEST_INTERVAL = 5000L
|
||||
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
|
||||
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
|
||||
file.sink().buffer().use { sink ->
|
||||
with(sink) {
|
||||
writeUtf8(e.message + "\n")
|
||||
@@ -177,16 +179,20 @@ object EhEngine {
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
|
||||
val funds = if (EhUtils.isExHentai) getFunds() else null
|
||||
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
|
||||
}
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
|
||||
{ getFunds() },
|
||||
{ limits, funds -> HomeParser.Result(limits, funds) },
|
||||
)
|
||||
|
||||
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
@@ -197,12 +203,12 @@ object EhEngine {
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
runSuspendCatching {
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
|
||||
}.onFailure { throwable ->
|
||||
// It may get redirected when accessing ex for the first time
|
||||
if (url == EhUrl.URL_UCONFIG_EX) {
|
||||
logcat(throwable)
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
|
||||
} else {
|
||||
throw throwable
|
||||
}
|
||||
@@ -234,6 +240,22 @@ object EhEngine {
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
val url = EhUrl.API_SIGN_IN
|
||||
val origin = "https://forums.e-hentai.org"
|
||||
return ehRequest(url, referer, origin) {
|
||||
formBody {
|
||||
append("referer", referer)
|
||||
append("b", "")
|
||||
append("bt", "")
|
||||
append("UserName", username)
|
||||
append("PassWord", password)
|
||||
append("CookieDate", "1")
|
||||
}
|
||||
}.fetchUsingAsText(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == -1L) {
|
||||
@@ -275,6 +297,10 @@ object EhEngine {
|
||||
}.executeSafely { }
|
||||
}
|
||||
|
||||
suspend fun getFavoriteNote(gid: Long, token: String) =
|
||||
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(FavoritesParser::parseNote)
|
||||
|
||||
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
|
||||
val url = EhUrl.getDownloadArchive(gid, token, or)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
@@ -431,13 +457,13 @@ object EhEngine {
|
||||
}
|
||||
|
||||
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap { (gid, token) ->
|
||||
galleryList.forEach { (gid, token) ->
|
||||
// https://github.com/FooIbar/EhViewer/issues/1190
|
||||
// Workaround for duplicate items when sorting by favorited time
|
||||
val timeTaken = measureTimeMillis {
|
||||
modifyFavorites(gid, token, dstCat)
|
||||
}
|
||||
delay(1000 - timeTaken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -50,7 +51,7 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.sha1
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
@@ -45,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.BufferedSource
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import splitties.init.appCtx
|
||||
@@ -63,9 +63,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
var initialized by mutableStateOf(false)
|
||||
|
||||
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
|
||||
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun internalSuggestFlow(
|
||||
tags: Map<String, String>,
|
||||
@@ -131,24 +129,16 @@ object EhTagDatabase : CoroutineScope {
|
||||
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
|
||||
removeSpace().contains(other.removeSpace(), ignoreCase)
|
||||
|
||||
private fun getMetadata(context: Context): Array<String>? {
|
||||
return context.resources.getStringArray(R.array.tag_translation_metadata)
|
||||
.takeIf { it.size == 4 }
|
||||
}
|
||||
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
|
||||
.takeIf { it.size == 4 }
|
||||
|
||||
fun isTranslatable(context: Context): Boolean {
|
||||
return context.resources.getBoolean(R.bool.tag_translatable)
|
||||
}
|
||||
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
|
||||
|
||||
private fun getFileContent(file: File): String? {
|
||||
return runCatching {
|
||||
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
|
||||
}.getOrNull()
|
||||
}
|
||||
private fun getFileContent(file: File): String? = runCatching {
|
||||
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
|
||||
}.getOrNull()
|
||||
|
||||
private fun checkData(sha1: String?, data: File): Boolean {
|
||||
return sha1 != null && sha1 == data.asUniFile().sha1()
|
||||
}
|
||||
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
|
||||
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
|
||||
@@ -36,6 +36,7 @@ object EhUrl {
|
||||
const val API_E = "https://api.e-hentai.org/api.php"
|
||||
const val URL_UCONFIG_E = HOST_E + "uconfig.php"
|
||||
const val URL_MY_TAGS_E = HOST_E + "mytags"
|
||||
const val API_SIGN_IN = "https://forums.e-hentai.org/index.php?act=Login&CODE=01"
|
||||
const val URL_POPULAR_E = "https://e-hentai.org/popular"
|
||||
const val URL_POPULAR_EX = "https://exhentai.org/popular"
|
||||
const val URL_IMAGE_SEARCH_E = "https://upload.e-hentai.org/image_lookup.php"
|
||||
|
||||
@@ -88,21 +88,18 @@ object EhUtils {
|
||||
|
||||
fun getCategory(type: String?): Int {
|
||||
for (entry in CATEGORY_STRINGS) {
|
||||
for (str in entry.first)
|
||||
for (str in entry.first) {
|
||||
if (str.equals(type, ignoreCase = true)) {
|
||||
return entry.second
|
||||
}
|
||||
}
|
||||
}
|
||||
return UNKNOWN
|
||||
}
|
||||
|
||||
fun getCategory(type: Int): String {
|
||||
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
|
||||
}
|
||||
fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
|
||||
|
||||
private fun differenceDegrees(a: Float, b: Float): Float {
|
||||
return 180.0f - abs(abs(a - b) - 180.0f)
|
||||
}
|
||||
private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
|
||||
|
||||
private fun sanitizeDegreesDouble(degrees: Float): Float {
|
||||
val deg = degrees % 360.0f
|
||||
@@ -152,20 +149,18 @@ object EhUtils {
|
||||
val categoryTextColor = Color(0xffe6e0e9)
|
||||
|
||||
fun signOut() {
|
||||
EhCookieStore.signOut()
|
||||
Settings.avatar = null
|
||||
Settings.displayName = null
|
||||
EhCookieStore.removeAllCookies()
|
||||
Settings.displayName.value = null
|
||||
Settings.hasSignedIn.value = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
Settings.needSignIn = true
|
||||
}
|
||||
|
||||
fun getSuitableTitle(gi: GalleryInfo): String {
|
||||
return if (Settings.showJpnTitle) {
|
||||
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
|
||||
} else {
|
||||
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
|
||||
}.orEmpty()
|
||||
}
|
||||
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
|
||||
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
|
||||
} else {
|
||||
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
|
||||
}.orEmpty()
|
||||
|
||||
fun extractTitle(fullTitle: String?): String? {
|
||||
var title: String = fullTitle ?: return null
|
||||
|
||||
@@ -88,7 +88,8 @@ data class BaseGalleryInfo(
|
||||
|
||||
@Ignore
|
||||
override var favoriteNote: String? = null,
|
||||
) : GalleryInfo, Parcelable {
|
||||
) : GalleryInfo,
|
||||
Parcelable {
|
||||
constructor() : this(0, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ data class GalleryDetail(
|
||||
var comments: GalleryCommentList,
|
||||
val previewPages: Int,
|
||||
val previewList: List<GalleryPreview>,
|
||||
) : GalleryInfo by galleryInfo, Parcelable {
|
||||
) : GalleryInfo by galleryInfo,
|
||||
Parcelable {
|
||||
fun fillInfo() {
|
||||
val index = LANGUAGES.indexOf(language)
|
||||
if (index != -1) simpleLanguage = S_LANGS[index]
|
||||
|
||||
@@ -134,14 +134,10 @@ interface GalleryInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
|
||||
return when (this) {
|
||||
is BaseGalleryInfo -> this
|
||||
is GalleryDetail -> galleryInfo
|
||||
else -> unreachable()
|
||||
}
|
||||
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
|
||||
is BaseGalleryInfo -> this
|
||||
is GalleryDetail -> galleryInfo
|
||||
else -> unreachable()
|
||||
}
|
||||
|
||||
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
|
||||
return this as? GalleryDetail
|
||||
}
|
||||
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
|
||||
@@ -22,4 +22,5 @@ import kotlinx.parcelize.Parcelize
|
||||
data class GalleryTagGroup(
|
||||
val groupName: String,
|
||||
private val tagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable, MutableList<String> by tagList
|
||||
) : Parcelable,
|
||||
MutableList<String> by tagList
|
||||
@@ -42,6 +42,7 @@ data class ListUrlBuilder(
|
||||
var category: Int = EhUtils.NONE,
|
||||
private var mKeyword: String? = null,
|
||||
var hash: String? = null,
|
||||
var language: Int = -1,
|
||||
var advanceSearch: Int = -1,
|
||||
var minRating: Int = -1,
|
||||
var pageFrom: Int = -1,
|
||||
@@ -83,18 +84,16 @@ data class ListUrlBuilder(
|
||||
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
|
||||
}
|
||||
|
||||
fun toQuickSearch(name: String): QuickSearch {
|
||||
return QuickSearch(
|
||||
name = name,
|
||||
mode = mode,
|
||||
category = category,
|
||||
keyword = mKeyword,
|
||||
advanceSearch = advanceSearch,
|
||||
minRating = minRating,
|
||||
pageFrom = pageFrom,
|
||||
pageTo = pageTo,
|
||||
)
|
||||
}
|
||||
fun toQuickSearch(name: String): QuickSearch = QuickSearch(
|
||||
name = name,
|
||||
mode = mode,
|
||||
category = category,
|
||||
keyword = mKeyword,
|
||||
advanceSearch = advanceSearch,
|
||||
minRating = minRating,
|
||||
pageFrom = pageFrom,
|
||||
pageTo = pageTo,
|
||||
)
|
||||
|
||||
fun equalsQuickSearch(q: QuickSearch?): Boolean {
|
||||
if (null == q) {
|
||||
@@ -259,84 +258,89 @@ data class ListUrlBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
fun build(): String {
|
||||
return when (mode) {
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
fun build(): String = when (mode) {
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
|
||||
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
}
|
||||
val query = mKeyword?.let { keyword ->
|
||||
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
|
||||
keyword
|
||||
} else {
|
||||
val tag = GalleryInfo.S_LANG_TAGS[language]
|
||||
if (keyword.isNotEmpty()) {
|
||||
"$tag $keyword"
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
}
|
||||
val query = mKeyword?.let {
|
||||
val index = Settings.languageFilter.value
|
||||
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
|
||||
"$it $lang"
|
||||
} ?: it
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
|
||||
// Advance search
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
if (advanceSearch and AdvanceTable.SH != 0) {
|
||||
addQueryParameter("f_sh", "on")
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
if (advanceSearch and AdvanceTable.STO != 0) {
|
||||
addQueryParameter("f_sto", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFL != 0) {
|
||||
addQueryParameter("f_sfl", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFU != 0) {
|
||||
addQueryParameter("f_sfu", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFT != 0) {
|
||||
addQueryParameter("f_sft", "on")
|
||||
}
|
||||
// Set min star
|
||||
if (minRating > 0) {
|
||||
addQueryParameter("f_sr", "on")
|
||||
addQueryParameter("f_srdd", "$minRating")
|
||||
}
|
||||
// Pages
|
||||
if (pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("f_sp", "on")
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spf",
|
||||
pageFrom.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spt",
|
||||
pageTo.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.buildString()
|
||||
|
||||
MODE_UPLOADER, MODE_TAG -> {
|
||||
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
|
||||
ehUrl(listOf(path, requireNotNull(mKeyword))) {
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
|
||||
// Advance search
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
if (advanceSearch and AdvanceTable.SH != 0) {
|
||||
addQueryParameter("f_sh", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.STO != 0) {
|
||||
addQueryParameter("f_sto", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFL != 0) {
|
||||
addQueryParameter("f_sfl", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFU != 0) {
|
||||
addQueryParameter("f_sfu", "on")
|
||||
}
|
||||
if (advanceSearch and AdvanceTable.SFT != 0) {
|
||||
addQueryParameter("f_sft", "on")
|
||||
}
|
||||
// Set min star
|
||||
if (minRating > 0) {
|
||||
addQueryParameter("f_sr", "on")
|
||||
addQueryParameter("f_srdd", "$minRating")
|
||||
}
|
||||
// Pages
|
||||
if (pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("f_sp", "on")
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spf",
|
||||
pageFrom.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spt",
|
||||
pageTo.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.buildString()
|
||||
|
||||
MODE_UPLOADER, MODE_TAG -> {
|
||||
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
|
||||
ehUrl(listOf(path, requireNotNull(mKeyword))) {
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
|
||||
}.buildString()
|
||||
}
|
||||
|
||||
MODE_WHATS_HOT -> EhUrl.popularUrl
|
||||
MODE_IMAGE_SEARCH -> ehUrl {
|
||||
addQueryParameter("f_shash", requireNotNull(hash))
|
||||
}.buildString()
|
||||
MODE_TOPLIST -> {
|
||||
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
|
||||
addQueryParameter("tl", requireNotNull(mKeyword))
|
||||
addQueryParameterIfNotBlank("p", mJumpTo)
|
||||
}.buildString()
|
||||
}
|
||||
|
||||
else -> throw IllegalStateException("Unexpected value: $mode")
|
||||
}
|
||||
|
||||
MODE_WHATS_HOT -> EhUrl.popularUrl
|
||||
MODE_IMAGE_SEARCH -> ehUrl {
|
||||
addQueryParameter("f_shash", requireNotNull(hash))
|
||||
}.buildString()
|
||||
MODE_TOPLIST -> {
|
||||
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
|
||||
addQueryParameter("tl", requireNotNull(mKeyword))
|
||||
addQueryParameterIfNotBlank("p", mJumpTo)
|
||||
}.buildString()
|
||||
}
|
||||
|
||||
else -> throw IllegalStateException("Unexpected value: $mode")
|
||||
}
|
||||
|
||||
@IntDef(
|
||||
|
||||
@@ -34,7 +34,7 @@ object ArchiveParser {
|
||||
private const val ERROR_INSUFFICIENT_FUNDS =
|
||||
"You do not have enough funds to download this archive."
|
||||
|
||||
fun parse(body: String): Result {
|
||||
fun parse(body: String, maybeFunds: Funds?): Result {
|
||||
val m = PATTERN_HATH_FORM.find(body)!!
|
||||
val paramOr = m.groupValues[1]
|
||||
val archiveList = ArrayList<Archive>()
|
||||
@@ -59,14 +59,12 @@ object ArchiveParser {
|
||||
val item = Archive(res, name, size, cost, true)
|
||||
archiveList.add(item)
|
||||
}
|
||||
val result = Result(paramOr, archiveList, null)
|
||||
PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run {
|
||||
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
|
||||
val fundsGP = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsC = ParserUtils.parseInt(get(2), 0)
|
||||
val funds = HomeParser.Funds(fundsGP, fundsC)
|
||||
result.funds = funds
|
||||
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
|
||||
}
|
||||
return result
|
||||
return Result(paramOr, archiveList, funds)
|
||||
}
|
||||
|
||||
fun parseArchiveUrl(body: String): String? {
|
||||
@@ -80,13 +78,13 @@ object ArchiveParser {
|
||||
// TODO: Check more errors
|
||||
}
|
||||
|
||||
class Archive(
|
||||
val res: String,
|
||||
val name: String,
|
||||
val size: String,
|
||||
val cost: String,
|
||||
val isHAtH: Boolean,
|
||||
)
|
||||
|
||||
class Result(val paramOr: String, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
|
||||
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
|
||||
}
|
||||
|
||||
class Archive(
|
||||
val res: String,
|
||||
val name: String,
|
||||
val size: String,
|
||||
val cost: String,
|
||||
val isHAtH: Boolean,
|
||||
)
|
||||
@@ -20,7 +20,5 @@ package com.hippo.ehviewer.client.parser
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object EventPaneParser {
|
||||
fun parse(body: String): String? {
|
||||
return Jsoup.parse(body).getElementById("eventpane")?.html()
|
||||
}
|
||||
fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
@@ -17,13 +18,16 @@ data class FavParserResult(
|
||||
}
|
||||
|
||||
object FavoritesParser {
|
||||
fun parse(body: ByteBuffer): FavParserResult {
|
||||
return runSuspendCatching {
|
||||
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
|
||||
}.getOrElse {
|
||||
throw ParseException("Parse favorites error", it)
|
||||
}
|
||||
private val NoteRegex = Regex("<textarea[^>]*>([^<]*)</textarea>")
|
||||
|
||||
fun parse(body: ByteBuffer): FavParserResult = runSuspendCatching {
|
||||
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
|
||||
}.getOrElse {
|
||||
throw ParseException("Parse favorites error", it)
|
||||
}
|
||||
|
||||
fun parseNote(body: String) = NoteRegex.find(body)?.run { groupValues[1].unescapeXml() }
|
||||
?: throw ParseException("Can't parse favorite note")
|
||||
}
|
||||
|
||||
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
|
||||
@@ -21,15 +21,13 @@ import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object ForumsParser {
|
||||
fun parse(body: String): String {
|
||||
return runCatching {
|
||||
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
|
||||
val userlinks = d.getElementById("userlinks")
|
||||
val child = userlinks!!.child(0).child(0).child(0)
|
||||
child.attr("href")
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
fun parse(body: String): String = runCatching {
|
||||
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
|
||||
val userlinks = d.getElementById("userlinks")
|
||||
val child = userlinks!!.child(0).child(0).child(0)
|
||||
child.attr("href")
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
}
|
||||
@@ -283,56 +283,50 @@ object GalleryDetailParser {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTagGroup(element: Element): GalleryTagGroup? {
|
||||
return try {
|
||||
var nameSpace = element.child(0).text()
|
||||
// Remove last ':'
|
||||
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
|
||||
val group = GalleryTagGroup(nameSpace)
|
||||
val tags = element.child(1).children()
|
||||
tags.forEach {
|
||||
var tag = it.text()
|
||||
// Sometimes parody tag is followed with '|' and english translate, just remove them
|
||||
val index = tag.indexOf('|')
|
||||
if (index >= 0) {
|
||||
tag = tag.substring(0, index).trim()
|
||||
}
|
||||
if (it.className() == "gtw") {
|
||||
tag = "_$tag" // weak tag
|
||||
}
|
||||
group.add(tag)
|
||||
private fun parseTagGroup(element: Element): GalleryTagGroup? = try {
|
||||
var nameSpace = element.child(0).text()
|
||||
// Remove last ':'
|
||||
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
|
||||
val group = GalleryTagGroup(nameSpace)
|
||||
val tags = element.child(1).children()
|
||||
tags.forEach {
|
||||
var tag = it.text()
|
||||
// Sometimes parody tag is followed with '|' and english translate, just remove them
|
||||
val index = tag.indexOf('|')
|
||||
if (index >= 0) {
|
||||
tag = tag.substring(0, index).trim()
|
||||
}
|
||||
if (group.size > 0) group else null
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
null
|
||||
if (it.className() == "gtw") {
|
||||
tag = "_$tag" // weak tag
|
||||
}
|
||||
group.add(tag)
|
||||
}
|
||||
if (group.size > 0) group else null
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tag groups with html parser
|
||||
*/
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
|
||||
return try {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
val tagGroups = taglist.child(0).child(0).children()
|
||||
parseTagGroups(tagGroups)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
emptyList()
|
||||
}
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
val tagGroups = taglist.child(0).child(0).children()
|
||||
parseTagGroups(tagGroups)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
|
||||
return try {
|
||||
trs.mapNotNull { parseTagGroup(it) }
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
emptyList()
|
||||
}
|
||||
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> = try {
|
||||
trs.mapNotNull { parseTagGroup(it) }
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private suspend fun parseComment(element: Element): GalleryComment? {
|
||||
@@ -449,22 +443,16 @@ object GalleryDetailParser {
|
||||
/**
|
||||
* Parse preview pages with regular expressions
|
||||
*/
|
||||
fun parsePreviewPages(body: String): Int {
|
||||
return PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse preview page count error")
|
||||
}
|
||||
fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse preview page count error")
|
||||
|
||||
/**
|
||||
* Parse pages with regular expressions
|
||||
*/
|
||||
fun parsePages(body: String): Int {
|
||||
return PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse pages error")
|
||||
}
|
||||
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
|
||||
?: throw ParseException("Parse pages error")
|
||||
|
||||
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> {
|
||||
return runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
|
||||
}
|
||||
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
|
||||
|
||||
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
|
||||
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
|
||||
|
||||
@@ -22,14 +22,12 @@ import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object GalleryNotAvailableParser {
|
||||
fun parse(body: String): String? {
|
||||
return runCatching {
|
||||
val e = Jsoup.parse(body).getElementsByClass("d").first()
|
||||
e!!.child(0).html().replace("<br>", "\n")
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
fun parse(body: String): String? = runCatching {
|
||||
val e = Jsoup.parse(body).getElementsByClass("d").first()
|
||||
e!!.child(0).html().replace("<br>", "\n")
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,11 @@ object GalleryTokenApiParser {
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
fun parse(body: String): String {
|
||||
return runCatching {
|
||||
body.parseAs<Result>().tokenList[0].token
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw EhException(body.parseAs<Error>().error)
|
||||
}
|
||||
fun parse(body: String): String = runCatching {
|
||||
body.parseAs<Result>().tokenList[0].token
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw EhException(body.parseAs<Error>().error)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -25,18 +25,18 @@ object HomeParser {
|
||||
PATTERN_FUNDS.find(body)?.groupValues?.run {
|
||||
val fundsC = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
|
||||
return Funds(fundsGP, fundsC)
|
||||
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
|
||||
}
|
||||
throw ParseException("Parse funds error")
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val fundsGP: Int, val fundsC: Int) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Result(val limits: Limits, val funds: Funds) : Parcelable
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val gp: String, val credit: String) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import com.hippo.ehviewer.util.toLocalDateTime
|
||||
import com.hippo.ehviewer.util.toLongOrDefault
|
||||
@@ -36,20 +37,13 @@ object ParserUtils {
|
||||
minute()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun formatDate(time: Long): String {
|
||||
return formatter.format(time.toLocalDateTime())
|
||||
}
|
||||
fun formatDate(time: Long): String = formatter.format(time.toLocalDateTime())
|
||||
|
||||
fun trim(str: String?): String {
|
||||
return str?.unescapeXml()?.trim() ?: ""
|
||||
}
|
||||
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
|
||||
|
||||
fun parseInt(str: String?, defValue: Int): Int {
|
||||
return trim(str).replace(",", "").toIntOrDefault(defValue)
|
||||
}
|
||||
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
|
||||
|
||||
fun parseLong(str: String?, defValue: Long): Long {
|
||||
return trim(str).replace(",", "").toLongOrDefault(defValue)
|
||||
}
|
||||
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
|
||||
|
||||
fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
|
||||
}
|
||||
@@ -22,31 +22,29 @@ import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object ProfileParser {
|
||||
fun parse(body: String): Result {
|
||||
return runCatching {
|
||||
val d = Jsoup.parse(body)
|
||||
val profilename = d.getElementById("profilename")
|
||||
val displayName = profilename!!.child(0).text()
|
||||
val avatar = runCatching {
|
||||
val avatar =
|
||||
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
|
||||
if (avatar.isEmpty()) {
|
||||
null
|
||||
} else if (!avatar.startsWith("http")) {
|
||||
EhUrl.URL_FORUMS + avatar
|
||||
} else {
|
||||
avatar
|
||||
}
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
logcat { "No avatar" }
|
||||
fun parse(body: String): Result = runCatching {
|
||||
val d = Jsoup.parse(body)
|
||||
val profilename = d.getElementById("profilename")
|
||||
val displayName = profilename!!.child(0).text()
|
||||
val avatar = runCatching {
|
||||
val avatar =
|
||||
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
|
||||
if (avatar.isEmpty()) {
|
||||
null
|
||||
} else if (!avatar.startsWith("http")) {
|
||||
EhUrl.URL_FORUMS + avatar
|
||||
} else {
|
||||
avatar
|
||||
}
|
||||
Result(displayName, avatar)
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw ParseException("Parse forums error")
|
||||
logcat { "No avatar" }
|
||||
null
|
||||
}
|
||||
Result(displayName, avatar)
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw ParseException("Parse forums error")
|
||||
}
|
||||
|
||||
class Result(val displayName: String?, val avatar: String?)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
|
||||
object SignInParser {
|
||||
private val NAME_PATTERN = Regex("<p>You are now logged in as: (.+?)<")
|
||||
private val ERROR_PATTERN = Regex(
|
||||
"<h4>The error returned was:</h4>\\s*<p>(.+?)</p>" +
|
||||
"|<span class=\"postcolor\">(.+?)</span>",
|
||||
)
|
||||
|
||||
fun parse(body: String): String = NAME_PATTERN.find(body)?.let {
|
||||
it.groupValues[1]
|
||||
} ?: ERROR_PATTERN.find(body)?.let {
|
||||
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
|
||||
} ?: throw ParseException("Can't parse sign in")
|
||||
}
|
||||
@@ -14,16 +14,18 @@ object TorrentParser {
|
||||
|
||||
@Serializable
|
||||
data class Torrent(
|
||||
val outdated: Boolean,
|
||||
val posted: String,
|
||||
val size: String,
|
||||
val seeds: Int,
|
||||
val peers: Int,
|
||||
val downloads: Int,
|
||||
val uploader: String,
|
||||
val url: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
|
||||
fun Torrent.format() = "↑$seeds ↓$peers ✓$downloads"
|
||||
|
||||
typealias TorrentResult = List<Torrent>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object UserConfigParser {
|
||||
private external fun parseFavCat(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
|
||||
fun parse(body: ByteBuffer) = runCatching {
|
||||
val favCat = unmarshalParsingAs<Array<String>>(body, ::parseFavCat)
|
||||
Settings.favCat = favCat
|
||||
}.onFailure {
|
||||
throw ParseException("Can't parse user config", it)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,5 @@ import com.hippo.ehviewer.client.parseAs
|
||||
|
||||
object VoteTagParser {
|
||||
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
|
||||
fun parse(body: String): String? {
|
||||
return runCatching { body.parseAs<Error>().error }.getOrNull()
|
||||
}
|
||||
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Extras
|
||||
import coil3.Image
|
||||
import coil3.asCoilImage
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor.Chain
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import com.hippo.ehviewer.image.detectBorder
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
@@ -30,59 +26,33 @@ val ImageRequest.maybeCropBorder: Boolean
|
||||
|
||||
class BitmapImageWithRect(
|
||||
val rect: IntRect,
|
||||
val image: Image,
|
||||
val image: BitmapImage,
|
||||
) : Image by image
|
||||
|
||||
object CropBorderInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
val req = chain.request
|
||||
if (req.maybeCropBorder) {
|
||||
val hw = req.allowHardware && isAtLeastO
|
||||
val result = if (hw) {
|
||||
val nonHw = req.newBuilder().allowHardware(false).build()
|
||||
chain.withRequest(nonHw).proceed()
|
||||
} else {
|
||||
chain.proceed()
|
||||
}
|
||||
if (result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
val bitmap = image.bitmap
|
||||
val ratio = if (image.height > image.width) {
|
||||
image.height / image.width
|
||||
} else {
|
||||
image.width / image.height
|
||||
val result = chain.proceed()
|
||||
if (chain.request.maybeCropBorder && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
val bitmap = image.bitmap
|
||||
val ratio = if (image.height > image.width) {
|
||||
image.height / image.width
|
||||
} else {
|
||||
image.width / image.height
|
||||
}
|
||||
if (ratio < RATIO_THRESHOLD) {
|
||||
val array = detectBorder(bitmap)
|
||||
val minWidth = image.width * CROP_THRESHOLD
|
||||
val minHeight = image.height * CROP_THRESHOLD
|
||||
val rect = IntRect(array[0], array[1], array[2], array[3])
|
||||
if (rect.width > minWidth && rect.height > minHeight) {
|
||||
val new = BitmapImageWithRect(rect, image)
|
||||
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
|
||||
}
|
||||
val rect = if (ratio < RATIO_THRESHOLD) {
|
||||
val array = detectBorder(bitmap)
|
||||
val minWidth = image.width * CROP_THRESHOLD
|
||||
val minHeight = image.height * CROP_THRESHOLD
|
||||
IntRect(array[0], array[1], array[2], array[3]).takeIf {
|
||||
it.width > minWidth && it.height > minHeight
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val maybeHwImage = if (hw) {
|
||||
// Upload to Graphical Buffer to accelerate render
|
||||
// May fail if the image is too long
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false)?.run {
|
||||
bitmap.recycle()
|
||||
asCoilImage()
|
||||
} ?: image
|
||||
} else {
|
||||
image
|
||||
}
|
||||
val new = if (rect != null) {
|
||||
BitmapImageWithRect(rect, maybeHwImage)
|
||||
} else {
|
||||
maybeHwImage
|
||||
}
|
||||
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return chain.proceed()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -7,28 +7,20 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.files.isFile
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
|
||||
object DownloadThumbInterceptor : Interceptor {
|
||||
// TODO: Remove this after a few releases
|
||||
private const val LEGACY_THUMB_FILE = ".thumb"
|
||||
private const val THUMB_FILE = "thumb.jpg"
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val magicOrUrl = chain.request.data as? String
|
||||
if (magicOrUrl != null) {
|
||||
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
|
||||
if (location != null) {
|
||||
val thumb = withIOContext {
|
||||
val legacyThumb = downloadLocation / location / LEGACY_THUMB_FILE
|
||||
if (legacyThumb.isFile) {
|
||||
legacyThumb.apply { renameTo(THUMB_FILE) }
|
||||
} else {
|
||||
downloadLocation / location / THUMB_FILE
|
||||
}
|
||||
}
|
||||
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
|
||||
if (withIOContext { thumb.isFile }) {
|
||||
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
|
||||
val new = chain.request.newBuilder().data(thumb.toUri()).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) return result
|
||||
}
|
||||
@@ -38,11 +30,9 @@ object DownloadThumbInterceptor : Interceptor {
|
||||
withIOContext {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
if (thumb.ensureFile()) {
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data.asUniFile() sendTo thumb
|
||||
}
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import coil3.asCoilImage
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.network.NetworkHeaders
|
||||
@@ -42,7 +42,7 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
httpHeaders(header)
|
||||
}
|
||||
|
||||
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
|
||||
private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
|
||||
private val stubResult = DecodeResult(stubImage, false)
|
||||
private val stubFactory = Decoder { stubResult }
|
||||
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Extras
|
||||
import coil3.asImage
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor.Chain
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
|
||||
/**
|
||||
* Allocating hardware bitmaps may fail on extremely long images.
|
||||
*
|
||||
* Retry with [allowHardware] disabled.
|
||||
*/
|
||||
private val hardwareThresholdKey = Extras.Key(default = 16384)
|
||||
|
||||
fun ImageRequest.Builder.hardwareThreshold(size: Int) = apply {
|
||||
extras[hardwareThresholdKey] = size
|
||||
}
|
||||
|
||||
val ImageRequest.hardwareThreshold: Int
|
||||
get() = getExtra(hardwareThresholdKey)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
object HardwareBitmapInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
return if (chain.request.allowHardware && result is ErrorResult && result.throwable is OutOfMemoryError) {
|
||||
val req = chain.request.newBuilder().allowHardware(false).build()
|
||||
chain.withRequest(req).proceed()
|
||||
} else {
|
||||
result
|
||||
val request = result.request
|
||||
if (!request.allowHardware && result is SuccessResult) {
|
||||
var rect: IntRect? = null
|
||||
val image = result.image
|
||||
val bitmap = when (image) {
|
||||
is BitmapImageWithRect -> image.image.also { rect = image.rect }
|
||||
is BitmapImage -> image
|
||||
else -> return result
|
||||
}.bitmap
|
||||
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
|
||||
// This is not ideal but I haven't figured out how to probe the threshold.
|
||||
// All we know is that it's less than the maximum texture size.
|
||||
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
|
||||
bitmap.recycle()
|
||||
val newImage = hwBitmap.asImage()
|
||||
return result.copy(
|
||||
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
|
||||
request = request,
|
||||
dataSource = result.dataSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
|
||||
}
|
||||
|
||||
try {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
|
||||
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
|
||||
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
|
||||
val pool = DirectByteBufferPool(32)
|
||||
|
||||
// Limit thread to 1 since we are async & non-blocking
|
||||
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
import org.chromium.net.CronetEngine
|
||||
import splitties.init.appCtx
|
||||
|
||||
@@ -10,8 +10,8 @@ val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
|
||||
setUserAgent(Settings.userAgent)
|
||||
|
||||
// Cache Quic hint only since the real cache mechanism should on Ktor layer
|
||||
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
|
||||
setStoragePath(cache.absolutePath)
|
||||
val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() }
|
||||
setStoragePath(cache.path)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
|
||||
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
@@ -38,8 +37,7 @@ import splitties.init.appCtx
|
||||
|
||||
private const val CHANNEL_ID = "DailyCheckNotification"
|
||||
|
||||
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
class DailyCheckWork(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
|
||||
override suspend fun doWork(): Result = withIOContext {
|
||||
checkDawn().onFailure {
|
||||
return@withIOContext Result.retry()
|
||||
@@ -83,7 +81,7 @@ val today
|
||||
get() = (Clock.System.now().epochSeconds / 86400).toInt()
|
||||
|
||||
suspend fun checkDawn() = runCatching {
|
||||
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
|
||||
if (Settings.hasSignedIn.value && Settings.lastDawnDays != today) {
|
||||
EhEngine.getNews(true)?.let {
|
||||
Settings.lastDawnDays = today
|
||||
showEventNotification(it)
|
||||
|
||||
@@ -8,11 +8,11 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
|
||||
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class, DownloadArtist::class,
|
||||
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
|
||||
GalleryEntityFts::class,
|
||||
],
|
||||
version = 22,
|
||||
version = 23,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
|
||||
@@ -32,11 +32,13 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
AutoMigration(from = 19, to = 20),
|
||||
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
|
||||
AutoMigration(from = 21, to = 22, spec = Schema21to22::class),
|
||||
AutoMigration(from = 22, to = 23),
|
||||
],
|
||||
)
|
||||
@TypeConverters(FilterModeConverter::class)
|
||||
abstract class EhDatabase : RoomDatabase() {
|
||||
abstract fun galleryDao(): GalleryDao
|
||||
abstract fun downloadArtistDao(): DownloadArtistDao
|
||||
abstract fun downloadDirnameDao(): DownloadDirnameDao
|
||||
abstract fun downloadLabelDao(): DownloadLabelDao
|
||||
abstract fun downloadsDao(): DownloadsDao
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
tableName = "DOWNLOAD_ARTISTS",
|
||||
primaryKeys = ["GID", "ARTIST"],
|
||||
foreignKeys = [
|
||||
ForeignKey(DownloadEntity::class, ["GID"], ["GID"], onDelete = ForeignKey.CASCADE),
|
||||
],
|
||||
)
|
||||
data class DownloadArtist(
|
||||
@ColumnInfo(name = "GID")
|
||||
val gid: Long,
|
||||
|
||||
@ColumnInfo(name = "ARTIST")
|
||||
val artist: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(gid: Long, artists: Iterable<String>) = artists.map { DownloadArtist(gid, it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface DownloadArtistDao {
|
||||
@Insert
|
||||
suspend fun insert(downloadArtists: List<DownloadArtist>)
|
||||
|
||||
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
|
||||
suspend fun deleteByGid(gid: Long)
|
||||
}
|
||||
@@ -67,9 +67,14 @@ data class DownloadInfo(
|
||||
@ColumnInfo(name = "DIRNAME")
|
||||
val dirname: String?,
|
||||
|
||||
@Relation(parentColumn = "GID", entityColumn = "GID")
|
||||
var artistInfoList: List<DownloadArtist> = emptyList(),
|
||||
|
||||
@Embedded
|
||||
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
|
||||
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
|
||||
) : GalleryInfo by galleryInfo,
|
||||
AbstractDownloadInfo by downloadInfo {
|
||||
|
||||
companion object {
|
||||
const val STATE_INVALID = -1
|
||||
const val STATE_NONE = 0
|
||||
@@ -79,3 +84,6 @@ data class DownloadInfo(
|
||||
const val STATE_FAILED = 4
|
||||
}
|
||||
}
|
||||
|
||||
val DownloadInfo.artists: List<String>
|
||||
get() = artistInfoList.map { it.artist }
|
||||
@@ -14,7 +14,11 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface DownloadsDao {
|
||||
@Suppress("ktlint:standard:annotation")
|
||||
@Query("SELECT LABEL, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_LABELS USING(LABEL) GROUP BY LABEL")
|
||||
fun count(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
|
||||
fun countByLabel(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
|
||||
|
||||
@Suppress("ktlint:standard:annotation")
|
||||
@Query("SELECT ARTIST, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_ARTISTS USING(GID) GROUP BY ARTIST ORDER BY COUNT DESC, ARTIST")
|
||||
fun countByArtist(): Flow<Map<@MapColumn("ARTIST") String?, @MapColumn("COUNT") Int>>
|
||||
|
||||
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
|
||||
suspend fun list(): List<DownloadEntity>
|
||||
|
||||
@@ -12,11 +12,11 @@ interface SearchDao {
|
||||
@Query("DELETE FROM suggestions WHERE `query` = :query")
|
||||
suspend fun deleteQuery(query: String)
|
||||
|
||||
@Query("SELECT * FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun rawSuggestions(prefix: String, limit: Int): Array<Search>
|
||||
@Query("SELECT DISTINCT `query` FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun rawSuggestions(prefix: String, limit: Int): List<String>
|
||||
|
||||
@Query("SELECT * FROM suggestions ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun list(limit: Int): Array<Search>
|
||||
@Query("SELECT DISTINCT `query` FROM suggestions ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun list(limit: Int): List<String>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(search: Search)
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
import com.hippo.ehviewer.image.Image
|
||||
@@ -37,17 +38,18 @@ import com.hippo.ehviewer.spider.COMIC_INFO_FILE
|
||||
import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
|
||||
import com.hippo.ehviewer.spider.putToDownloadDir
|
||||
import com.hippo.ehviewer.spider.downloadDirname
|
||||
import com.hippo.ehviewer.spider.readComicInfo
|
||||
import com.hippo.ehviewer.spider.readCompatFromUniFile
|
||||
import com.hippo.ehviewer.spider.readCompatFromPath
|
||||
import com.hippo.ehviewer.spider.toSimpleTags
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.insertWith
|
||||
import com.hippo.ehviewer.util.mapNotNull
|
||||
import com.hippo.ehviewer.util.runAssertingNotMainThread
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.asUniFileOrNull
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.find
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -60,6 +62,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import logcat.LogPriority
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Path.Companion.toPath
|
||||
import splitties.preferences.edit
|
||||
|
||||
object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
@@ -171,10 +176,9 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
} else {
|
||||
// It is new download info
|
||||
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
|
||||
info = DownloadInfo(galleryInfo, galleryInfo.downloadDirname())
|
||||
info.label = label
|
||||
info.state = DownloadInfo.STATE_WAIT
|
||||
|
||||
// Add to all download list and map
|
||||
allInfoList.insertWith(info, comparator())
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
@@ -266,6 +270,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
|
||||
// Save to
|
||||
EhDB.putDownloadInfo(info)
|
||||
EhDB.putDownloadArtist(info.gid, info.artistInfoList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,26 +535,63 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
|
||||
suspend fun readMetadataFromLocal() {
|
||||
val list = allInfoList.filter { it.pages == 0 || it.simpleTags == null }.parMapNotNull(concurrency = 5) { info ->
|
||||
val list = allInfoList.mapNotNull {
|
||||
val updateGallery = it.pages == 0 || it.simpleTags == null
|
||||
val updateArtist = it.artistInfoList.isEmpty()
|
||||
if (updateGallery || updateArtist) {
|
||||
Triple(it, updateGallery, updateArtist)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
|
||||
info.downloadDir?.run {
|
||||
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
|
||||
val comicInfo = find(COMIC_INFO_FILE)?.let { readComicInfo(it) }
|
||||
if (comicInfo != null) {
|
||||
info.pages = comicInfo.pageCount
|
||||
info.simpleTags = comicInfo.toSimpleTags()
|
||||
info.galleryInfo
|
||||
val galleryInfo = if (updateGallery) {
|
||||
info.pages = comicInfo.pageCount
|
||||
info.simpleTags = comicInfo.toSimpleTags()
|
||||
info.galleryInfo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val artistList = if (updateArtist && comicInfo.penciller != null) {
|
||||
info.artistInfoList = DownloadArtist.from(info.gid, comicInfo.penciller)
|
||||
info.gid to info.artistInfoList
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (galleryInfo != null || artistList != null) {
|
||||
galleryInfo to artistList
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (info.pages == 0) {
|
||||
findFile(SPIDER_INFO_FILENAME)?.let {
|
||||
readCompatFromUniFile(it)?.run {
|
||||
val galleryInfo = find(SPIDER_INFO_FILENAME)?.let {
|
||||
readCompatFromPath(it)?.run {
|
||||
info.pages = pages
|
||||
info.galleryInfo
|
||||
}
|
||||
}
|
||||
|
||||
galleryInfo?.let { it to null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
EhDB.updateGalleryInfo(list)
|
||||
|
||||
val galleryInfoList = mutableListOf<BaseGalleryInfo>()
|
||||
list.forEach { (info, artists) ->
|
||||
info?.let {
|
||||
galleryInfoList.add(it)
|
||||
}
|
||||
artists?.let { (gid, updateList) ->
|
||||
EhDB.putDownloadArtist(gid, updateList)
|
||||
}
|
||||
}
|
||||
if (galleryInfoList.isNotEmpty()) EhDB.updateGalleryInfo(galleryInfoList)
|
||||
}
|
||||
|
||||
val isIdle: Boolean
|
||||
@@ -567,6 +609,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
override fun onGet509(index: Int) {
|
||||
launch {
|
||||
mDownloadListener?.onGet509()
|
||||
stopAllDownload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,7 +631,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
info.finished = finished
|
||||
info.downloaded = downloaded
|
||||
info.total = total
|
||||
mDownloadListener?.onGetPage(info)
|
||||
mDownloadListener?.onDownload(info)
|
||||
mutableNotifyFlow.emit(info)
|
||||
} ?: logcat(TAG, LogPriority.ERROR) { "Current task is null, but it should not be" }
|
||||
}
|
||||
@@ -664,11 +707,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
*/
|
||||
fun onDownload(info: DownloadInfo)
|
||||
|
||||
/**
|
||||
* Update page downloaded
|
||||
*/
|
||||
fun onGetPage(info: DownloadInfo)
|
||||
|
||||
/**
|
||||
* Download done
|
||||
*/
|
||||
@@ -775,18 +813,21 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
|
||||
}
|
||||
|
||||
var downloadLocation: UniFile
|
||||
var downloadLocation: Path
|
||||
get() = with(Settings) {
|
||||
val uri = Uri.Builder().apply {
|
||||
scheme(downloadScheme)
|
||||
encodedAuthority(downloadAuthority)
|
||||
encodedPath(downloadPath)
|
||||
encodedQuery(downloadQuery)
|
||||
encodedFragment(downloadFragment)
|
||||
}.build()
|
||||
uri.asUniFileOrNull() ?: AppConfig.defaultDownloadDir?.asUniFile() ?: UniFile.Stub
|
||||
if (downloadScheme != null) {
|
||||
Uri.Builder().apply {
|
||||
scheme(downloadScheme)
|
||||
encodedAuthority(downloadAuthority)
|
||||
encodedPath(downloadPath)
|
||||
encodedQuery(downloadQuery)
|
||||
encodedFragment(downloadFragment)
|
||||
}.build().toOkioPath()
|
||||
} else {
|
||||
AppConfig.defaultDownloadDir?.toOkioPath() ?: "".toPath()
|
||||
}
|
||||
}
|
||||
set(value) = with(value.uri) {
|
||||
set(value) = with(value.toUri()) {
|
||||
Settings.edit {
|
||||
downloadScheme = scheme
|
||||
downloadAuthority = encodedAuthority
|
||||
@@ -797,5 +838,5 @@ var downloadLocation: UniFile
|
||||
}
|
||||
|
||||
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
|
||||
val DownloadInfo.archiveFile get() = downloadDir?.findFile("$gid.cbz")
|
||||
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")
|
||||
val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
|
||||
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
|
||||
@@ -21,8 +21,6 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
@@ -34,58 +32,59 @@ import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.screen.DownloadsFragment
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineScope {
|
||||
class DownloadService :
|
||||
Service(),
|
||||
DownloadManager.DownloadListener,
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + SupervisorJob()
|
||||
private val deferredMgr = async { DownloadManager.apply { setDownloadListener(this@DownloadService) } }
|
||||
private var mNotifyManager: NotificationManagerCompat? = null
|
||||
private var mDownloadingBuilder: NotificationCompat.Builder? = null
|
||||
private var mDownloadedBuilder: NotificationCompat.Builder? = null
|
||||
private var m509dBuilder: NotificationCompat.Builder? = null
|
||||
private var mDownloadingDelay: NotificationDelay? = null
|
||||
private var mDownloadedDelay: NotificationDelay? = null
|
||||
private var m509Delay: NotificationDelay? = null
|
||||
private var mChannelID: String? = null
|
||||
private val deferredMgr = async { DownloadManager }
|
||||
private val notifyManager by unsafeLazy { NotificationManagerCompat.from(this) }
|
||||
private val downloadingNotification by unsafeLazy { initDownloadingNotification() }
|
||||
private val downloadedNotification by lazy { initDownloadedNotification() }
|
||||
private val error509Notification by lazy { init509Notification() }
|
||||
private val channelId by unsafeLazy { "$packageName.download" }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
mChannelID = "$packageName.download"
|
||||
mNotifyManager = NotificationManagerCompat.from(this)
|
||||
mNotifyManager!!.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(mChannelID!!, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
notifyManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.download_service)).build(),
|
||||
)
|
||||
ensureDownloadingBuilder()
|
||||
mDownloadingBuilder!!.setContentTitle(getString(R.string.download_service))
|
||||
.setContentText(null)
|
||||
.setSubText(null)
|
||||
.setProgress(0, 0, true)
|
||||
startForeground(ID_DOWNLOADING, mDownloadingBuilder!!.build())
|
||||
downloadingNotification.builder.run {
|
||||
setContentTitle(getString(R.string.download_service))
|
||||
.setContentText(null)
|
||||
.setSubText(null)
|
||||
.setProgress(0, 0, true)
|
||||
startForeground(ID_DOWNLOADING, build())
|
||||
}
|
||||
launch {
|
||||
deferredMgr.await().setDownloadListener(this@DownloadService)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
val scope = this
|
||||
launch {
|
||||
deferredMgr.await().setDownloadListener(null)
|
||||
// Wait for the last notification to be posted
|
||||
delay(DELAY)
|
||||
scope.cancel()
|
||||
}
|
||||
mNotifyManager = null
|
||||
mDownloadingBuilder = null
|
||||
mDownloadedBuilder = null
|
||||
m509dBuilder = null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -153,17 +152,14 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent) = error("No bindService")
|
||||
override fun onBind(intent: Intent) = null
|
||||
|
||||
private fun ensureDownloadingBuilder() {
|
||||
if (mDownloadingBuilder != null) {
|
||||
return
|
||||
}
|
||||
private fun initDownloadingNotification(): NotificationHandler {
|
||||
val stopAllIntent = Intent(this, DownloadService::class.java)
|
||||
stopAllIntent.action = ACTION_STOP_ALL
|
||||
val piStopAll =
|
||||
PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
mDownloadingBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
|
||||
val builder = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
@@ -174,47 +170,40 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
piStopAll,
|
||||
)
|
||||
.setShowWhen(false)
|
||||
.setChannelId(mChannelID!!)
|
||||
mDownloadingDelay =
|
||||
NotificationDelay(this, mNotifyManager!!, mDownloadingBuilder!!, ID_DOWNLOADING)
|
||||
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADING)
|
||||
.apply { launch { run() } }
|
||||
}
|
||||
|
||||
private fun ensureDownloadedBuilder() {
|
||||
if (mDownloadedBuilder != null) {
|
||||
return
|
||||
}
|
||||
private fun initDownloadedNotification(): NotificationHandler {
|
||||
val clearIntent = Intent(this, DownloadService::class.java)
|
||||
clearIntent.action = ACTION_CLEAR
|
||||
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val bundle = Bundle()
|
||||
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
bundle.putString(KEY_ACTION, ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
val piActivity = PendingIntent.getActivity(
|
||||
this@DownloadService,
|
||||
this,
|
||||
0,
|
||||
activityIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
mDownloadedBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
|
||||
val builder = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(getString(R.string.stat_download_done_title))
|
||||
.setDeleteIntent(piClear)
|
||||
.setOngoing(false)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(piActivity)
|
||||
mDownloadedDelay =
|
||||
NotificationDelay(this, mNotifyManager!!, mDownloadedBuilder!!, ID_DOWNLOADED)
|
||||
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADED)
|
||||
.apply { launch { run() } }
|
||||
}
|
||||
|
||||
private fun ensure509Builder() {
|
||||
if (m509dBuilder != null) {
|
||||
return
|
||||
}
|
||||
m509dBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
|
||||
private fun init509Notification(): NotificationHandler {
|
||||
val builder = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.ic_baseline_warning_24)
|
||||
.setContentText(getString(R.string.stat_509_alert_title))
|
||||
.setContentTitle(getString(R.string.stat_509_alert_title))
|
||||
.setContentText(getString(R.string.stat_509_alert_text))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)),
|
||||
@@ -222,89 +211,61 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
m509Delay = NotificationDelay(this, mNotifyManager!!, m509dBuilder!!, ID_509)
|
||||
return NotificationHandler(this, notifyManager, builder, ID_509)
|
||||
.apply { launch { run() } }
|
||||
}
|
||||
|
||||
override fun onGet509() {
|
||||
launch {
|
||||
deferredMgr.await().stopAllDownload()
|
||||
if (mNotifyManager == null) {
|
||||
return@launch
|
||||
}
|
||||
ensure509Builder()
|
||||
m509dBuilder!!.setWhen(System.currentTimeMillis())
|
||||
m509Delay!!.show()
|
||||
error509Notification.run {
|
||||
builder.setWhen(System.currentTimeMillis())
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(info: DownloadInfo) {
|
||||
if (mNotifyManager == null) {
|
||||
return
|
||||
}
|
||||
ensureDownloadingBuilder()
|
||||
val bundle = Bundle()
|
||||
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
|
||||
bundle.putLong(KEY_GID, info.gid)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
val piActivity = PendingIntent.getActivity(
|
||||
this@DownloadService,
|
||||
this,
|
||||
0,
|
||||
activityIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
|
||||
.setContentText(null)
|
||||
.setSubText(null)
|
||||
.setProgress(0, 0, true)
|
||||
.setContentIntent(piActivity)
|
||||
mDownloadingDelay!!.startForeground()
|
||||
}
|
||||
|
||||
private fun onUpdate(info: DownloadInfo) {
|
||||
if (mNotifyManager == null) {
|
||||
return
|
||||
downloadingNotification.run {
|
||||
builder.setContentTitle(EhUtils.getSuitableTitle(info))
|
||||
.setContentText(null)
|
||||
.setSubText(null)
|
||||
.setProgress(0, 0, true)
|
||||
.setContentIntent(piActivity)
|
||||
startForeground()
|
||||
}
|
||||
ensureDownloadingBuilder()
|
||||
var speed = info.speed
|
||||
if (speed < 0) {
|
||||
speed = 0
|
||||
}
|
||||
var text = FileUtils.humanReadableByteCount(speed, false) + "/s"
|
||||
val remaining = info.remaining
|
||||
text = if (remaining >= 0) {
|
||||
getString(
|
||||
R.string.download_speed_text_2,
|
||||
text,
|
||||
ReadableTime.getShortTimeInterval(remaining),
|
||||
)
|
||||
} else {
|
||||
getString(R.string.download_speed_text, text)
|
||||
}
|
||||
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
|
||||
.setProgress(info.total, info.finished, false)
|
||||
mDownloadingDelay!!.startForeground()
|
||||
}
|
||||
|
||||
override fun onDownload(info: DownloadInfo) {
|
||||
onUpdate(info)
|
||||
}
|
||||
|
||||
override fun onGetPage(info: DownloadInfo) {
|
||||
onUpdate(info)
|
||||
val speed = info.speed.coerceAtLeast(0)
|
||||
val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
|
||||
val remaining = info.remaining
|
||||
val text = if (remaining >= 0) {
|
||||
val interval = ReadableTime.getShortTimeInterval(remaining)
|
||||
getString(R.string.download_speed_text_2, speedText, interval)
|
||||
} else {
|
||||
getString(R.string.download_speed_text, speedText)
|
||||
}
|
||||
downloadingNotification.run {
|
||||
builder.setContentTitle(EhUtils.getSuitableTitle(info))
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
|
||||
.setProgress(info.total, info.finished, false)
|
||||
startForeground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinish(info: DownloadInfo) {
|
||||
if (mNotifyManager == null) {
|
||||
return
|
||||
}
|
||||
if (null != mDownloadingDelay) {
|
||||
mDownloadingDelay!!.cancel()
|
||||
}
|
||||
ensureDownloadedBuilder()
|
||||
downloadingNotification.cancel()
|
||||
val finish = info.state == DownloadInfo.STATE_FINISH
|
||||
val gid = info.gid
|
||||
val index = sItemStateArray.indexOfKey(gid)
|
||||
@@ -393,21 +354,18 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
} else {
|
||||
style = null
|
||||
}
|
||||
mDownloadedBuilder!!.setContentText(text)
|
||||
.setStyle(style)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setNumber(sDownloadedCount)
|
||||
mDownloadedDelay!!.show()
|
||||
downloadedNotification.run {
|
||||
builder.setContentText(text)
|
||||
.setStyle(style)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.setNumber(sDownloadedCount)
|
||||
show()
|
||||
}
|
||||
checkStopSelf()
|
||||
}
|
||||
|
||||
override fun onCancel(info: DownloadInfo) {
|
||||
if (mNotifyManager == null) {
|
||||
return
|
||||
}
|
||||
if (null != mDownloadingDelay) {
|
||||
mDownloadingDelay!!.cancel()
|
||||
}
|
||||
downloadingNotification.cancel()
|
||||
checkStopSelf()
|
||||
}
|
||||
|
||||
@@ -420,105 +378,49 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
}
|
||||
|
||||
private class NotificationDelay(
|
||||
private var mService: Service,
|
||||
private val mNotifyManager: NotificationManagerCompat,
|
||||
private val mBuilder: NotificationCompat.Builder,
|
||||
private val mId: Int,
|
||||
) : Runnable {
|
||||
private var mLastTime: Long = 0
|
||||
private var mPosted = false
|
||||
|
||||
// false for show, true for cancel
|
||||
@Ops
|
||||
private var mOps = 0
|
||||
private class NotificationHandler(
|
||||
private val service: Service,
|
||||
private val notifyManager: NotificationManagerCompat,
|
||||
val builder: NotificationCompat.Builder,
|
||||
private val id: Int,
|
||||
) {
|
||||
private val channel = Channel<Ops>(Channel.CONFLATED)
|
||||
|
||||
fun show() {
|
||||
if (mPosted) {
|
||||
mOps = OPS_NOTIFY
|
||||
} else {
|
||||
val now = SystemClock.currentThreadTimeMillis()
|
||||
if (now - mLastTime > DELAY) {
|
||||
// Wait long enough, do it now
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
mService,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
mNotifyManager.notify(mId, mBuilder.build())
|
||||
} else {
|
||||
// Too quick, post delay
|
||||
mOps = OPS_NOTIFY
|
||||
mPosted = true
|
||||
SimpleHandler.postDelayed(this, DELAY)
|
||||
}
|
||||
mLastTime = now
|
||||
}
|
||||
channel.trySend(Ops.Notify)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (mPosted) {
|
||||
mOps = OPS_CANCEL
|
||||
} else {
|
||||
val now = SystemClock.currentThreadTimeMillis()
|
||||
if (now - mLastTime > DELAY) {
|
||||
// Wait long enough, do it now
|
||||
mNotifyManager.cancel(mId)
|
||||
} else {
|
||||
// Too quick, post delay
|
||||
mOps = OPS_CANCEL
|
||||
mPosted = true
|
||||
SimpleHandler.postDelayed(this, DELAY)
|
||||
}
|
||||
}
|
||||
channel.trySend(Ops.Cancel)
|
||||
}
|
||||
|
||||
fun startForeground() {
|
||||
if (mPosted) {
|
||||
mOps = OPS_START_FOREGROUND
|
||||
} else {
|
||||
val now = SystemClock.currentThreadTimeMillis()
|
||||
if (now - mLastTime > DELAY) {
|
||||
// Wait long enough, do it now
|
||||
mService.startForeground(mId, mBuilder.build())
|
||||
} else {
|
||||
// Too quick, post delay
|
||||
mOps = OPS_START_FOREGROUND
|
||||
mPosted = true
|
||||
SimpleHandler.postDelayed(this, DELAY)
|
||||
}
|
||||
}
|
||||
channel.trySend(Ops.StartForeground)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
mPosted = false
|
||||
when (mOps) {
|
||||
OPS_NOTIFY -> {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
mService,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
suspend fun run() {
|
||||
channel.receiveAsFlow().sample(DELAY).collect {
|
||||
when (it) {
|
||||
Ops.Notify -> {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
service,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notifyManager.notify(id, builder.build())
|
||||
}
|
||||
}
|
||||
mNotifyManager.notify(mId, mBuilder.build())
|
||||
}
|
||||
|
||||
OPS_CANCEL -> mNotifyManager.cancel(mId)
|
||||
OPS_START_FOREGROUND -> mService.startForeground(mId, mBuilder.build())
|
||||
Ops.Cancel -> notifyManager.cancel(id)
|
||||
Ops.StartForeground -> service.startForeground(id, builder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IntDef(OPS_NOTIFY, OPS_CANCEL, OPS_START_FOREGROUND)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
private annotation class Ops
|
||||
companion object {
|
||||
private const val OPS_NOTIFY = 0
|
||||
private const val OPS_CANCEL = 1
|
||||
private const val OPS_START_FOREGROUND = 2
|
||||
private const val DELAY: Long = 1000 // 1s
|
||||
private enum class Ops {
|
||||
Notify,
|
||||
Cancel,
|
||||
StartForeground,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,9 +442,12 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
const val KEY_LABEL = "label"
|
||||
const val KEY_GID = "gid"
|
||||
const val KEY_GID_LIST = "gid_list"
|
||||
const val KEY_ACTION = "action"
|
||||
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
|
||||
private const val ID_DOWNLOADING = 1
|
||||
private const val ID_DOWNLOADED = 2
|
||||
private const val ID_509 = 3
|
||||
private const val DELAY = 1000L // 1s
|
||||
private val sItemStateArray = LongSparseArray<Boolean>()
|
||||
private val sItemTitleArray = LongSparseArray<String>()
|
||||
private var sFailedCount = 0
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hippo.ehviewer.download
|
||||
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.artists
|
||||
|
||||
enum class DownloadsFilterMode(
|
||||
val flag: Int,
|
||||
val take: (info: DownloadInfo, label: String?) -> Boolean,
|
||||
) {
|
||||
CUSTOM(flag = 0, take = { info, label ->
|
||||
label == "" || info.label == label
|
||||
}),
|
||||
ARTIST(flag = 1, take = { info, label ->
|
||||
if (null == label) {
|
||||
info.artistInfoList.isEmpty()
|
||||
} else {
|
||||
label == "" || info.artists.contains(label)
|
||||
}
|
||||
}),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val Default = ARTIST
|
||||
fun from(flag: Int) = DownloadsFilterMode.entries[flag]
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ import com.hippo.ehviewer.jni.openArchive
|
||||
import com.hippo.ehviewer.jni.providePassword
|
||||
import com.hippo.ehviewer.jni.releaseByteBuffer
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.displayPath
|
||||
import com.hippo.ehviewer.util.displayPath
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
@@ -41,21 +42,22 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLock
|
||||
import okio.Path
|
||||
|
||||
typealias PasswdInvalidator = (String) -> Boolean
|
||||
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
|
||||
|
||||
class ArchivePageLoader(
|
||||
private val file: UniFile,
|
||||
private val file: Path,
|
||||
gid: Long = 0,
|
||||
startPage: Int = 0,
|
||||
passwdProvider: PasswdProvider? = null,
|
||||
) : PageLoader2(gid, startPage) {
|
||||
private lateinit var pfd: ParcelFileDescriptor
|
||||
private val hostJob = launch(start = CoroutineStart.LAZY) {
|
||||
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" }
|
||||
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
|
||||
pfd = file.openFileDescriptor("r")
|
||||
size = openArchive(pfd.fd, pfd.statSize, gid == 0L)
|
||||
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size == 0) {
|
||||
return@launch
|
||||
}
|
||||
@@ -81,7 +83,7 @@ class ArchivePageLoader(
|
||||
super.stop()
|
||||
closeArchive()
|
||||
pfd.close()
|
||||
logcat(DEBUG_TAG) { "Close archive ${file.uri.displayPath} successfully!" }
|
||||
logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
|
||||
}
|
||||
|
||||
private val mJobMap = hashMapOf<Int, Job>()
|
||||
@@ -149,19 +151,15 @@ class ArchivePageLoader(
|
||||
FileUtils.getNameFromFilename(file.name)!!
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int): String {
|
||||
return getExtension(index)
|
||||
}
|
||||
override fun getImageExtension(index: Int): String = getExtension(index)
|
||||
|
||||
override fun save(index: Int, file: UniFile): Boolean {
|
||||
return runCatching {
|
||||
file.openFileDescriptor("w").use {
|
||||
extractToFd(index, it.fd)
|
||||
}
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
false
|
||||
override fun save(index: Int, file: Path): Boolean = runCatching {
|
||||
file.openFileDescriptor("w").use {
|
||||
extractToFd(index, it.fd)
|
||||
}
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
false
|
||||
}
|
||||
|
||||
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
|
||||
|
||||
@@ -22,11 +22,11 @@ import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import okio.Path
|
||||
|
||||
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
PageLoader2(mGalleryInfo.gid, startPage), OnSpiderListener {
|
||||
PageLoader2(mGalleryInfo.gid, startPage),
|
||||
OnSpiderListener {
|
||||
private lateinit var mSpiderQueen: SpiderQueen
|
||||
override fun start() {
|
||||
super.start()
|
||||
@@ -43,16 +43,12 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
FileUtils.sanitizeFilename(EhUtils.getSuitableTitle(mGalleryInfo))
|
||||
EhUtils.getSuitableTitle(mGalleryInfo)
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int): String {
|
||||
return mSpiderQueen.getExtension(index)!!
|
||||
}
|
||||
override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
|
||||
|
||||
override fun save(index: Int, file: UniFile): Boolean {
|
||||
return mSpiderQueen.save(index, file)
|
||||
}
|
||||
override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
|
||||
|
||||
override val size: Int
|
||||
get() = mSpiderQueen.size
|
||||
@@ -65,9 +61,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
mSpiderQueen.forceRequest(index, orgImg)
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
return super.awaitReady() && mSpiderQueen.awaitReady()
|
||||
}
|
||||
override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
|
||||
|
||||
override val isReady: Boolean
|
||||
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
|
||||
|
||||
@@ -17,7 +17,7 @@ package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
@@ -25,8 +25,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Path
|
||||
|
||||
abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoader(), CoroutineScope {
|
||||
abstract class PageLoader2(private val gid: Long, var startPage: Int) :
|
||||
PageLoader(),
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private val progressJob = launch(start = CoroutineStart.LAZY) {
|
||||
if (startPage == -1) {
|
||||
@@ -59,16 +62,11 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
|
||||
|
||||
protected abstract val title: String
|
||||
|
||||
protected abstract fun getImageExtension(index: Int): String
|
||||
protected abstract fun getImageExtension(index: Int): String?
|
||||
|
||||
fun getImageFilename(index: Int): String {
|
||||
return "$title - ${index + 1}.${getImageExtension(index)}"
|
||||
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
|
||||
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
|
||||
}
|
||||
|
||||
abstract fun save(index: Int, file: UniFile): Boolean
|
||||
|
||||
fun saveToDir(index: Int, dir: UniFile): UniFile? {
|
||||
val filename = getImageFilename(index)
|
||||
return (dir / filename).takeIf { save(index, it) }
|
||||
}
|
||||
abstract fun save(index: Int, file: Path): Boolean
|
||||
}
|
||||
@@ -30,10 +30,12 @@ import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Precision
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.coil.BitmapImageWithRect
|
||||
import com.hippo.ehviewer.coil.hardwareThreshold
|
||||
import com.hippo.ehviewer.coil.maybeCropBorder
|
||||
import com.hippo.ehviewer.jni.isGif
|
||||
import com.hippo.ehviewer.jni.mmap
|
||||
@@ -42,9 +44,11 @@ import com.hippo.ehviewer.jni.rewriteGifSource
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.nio.ByteBuffer
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
|
||||
@@ -75,12 +79,14 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
companion object {
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
|
||||
|
||||
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
|
||||
val req = appCtx.imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.uri) }
|
||||
onRight { data(it.source.toUri()) }
|
||||
size(Dimension(targetWidth), Dimension.Undefined)
|
||||
precision(Precision.INEXACT)
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
}
|
||||
@@ -93,7 +99,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
suspend fun decode(src: ImageSource): Image? {
|
||||
return runCatching {
|
||||
val image = when (src) {
|
||||
is UniFileSource -> {
|
||||
is PathSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
src.source.openFileDescriptor("rw").use {
|
||||
val fd = it.fd
|
||||
@@ -133,8 +139,8 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
|
||||
|
||||
sealed interface ImageSource : AutoCloseable
|
||||
|
||||
interface UniFileSource : ImageSource {
|
||||
val source: UniFile
|
||||
interface PathSource : ImageSource {
|
||||
val source: Path
|
||||
val type: String
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
|
||||
object Cronet : HttpClientEngineFactory<CronetConfig> {
|
||||
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
|
||||
CronetEngine(CronetConfig().apply(block))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import android.content.Context
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import org.chromium.net.CronetEngine
|
||||
|
||||
class CronetConfig : HttpClientEngineConfig() {
|
||||
var client: CronetEngine? = null
|
||||
|
||||
fun config(context: Context, block: CronetEngine.Builder.() -> Unit) {
|
||||
client = CronetEngine.Builder(context).apply(block).build()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.cronet.cronetDispatcher
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
|
||||
import com.hippo.ehviewer.cronet.pool
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
@@ -18,12 +13,15 @@ import io.ktor.util.date.GMTDate
|
||||
import io.ktor.util.flattenForEach
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.job
|
||||
@@ -33,9 +31,12 @@ import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
import org.chromium.net.apihelpers.UploadDataProviders
|
||||
|
||||
object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
override val config = HttpClientEngineConfig()
|
||||
override val dispatcher = cronetDispatcher
|
||||
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
|
||||
// Limit thread to 1 since we are async & non-blocking
|
||||
override val dispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
private val executor = dispatcher.asExecutor()
|
||||
private val pool = DirectByteBufferPool(32)
|
||||
private val client = config.client ?: error("Cronet client is not configured")
|
||||
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
|
||||
@@ -94,12 +95,12 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
}
|
||||
}
|
||||
|
||||
cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
|
||||
client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply {
|
||||
setHttpMethod(data.method.value)
|
||||
data.headers.flattenForEach { key, value -> addHeader(key, value) }
|
||||
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
|
||||
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
|
||||
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
|
||||
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, executor) }
|
||||
}.build().apply {
|
||||
start()
|
||||
callContext.job.invokeOnCompletion { cancel() }
|
||||
|
||||
@@ -7,17 +7,11 @@ import okio.source
|
||||
|
||||
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
|
||||
val source = inputStream.source().buffer()
|
||||
fun read(): String {
|
||||
return source.readUtf8LineStrict()
|
||||
}
|
||||
fun read(): String = source.readUtf8LineStrict()
|
||||
|
||||
fun readInt(): Int {
|
||||
return read().toInt()
|
||||
}
|
||||
fun readInt(): Int = read().toInt()
|
||||
|
||||
fun readLong(): Long {
|
||||
return read().toLong()
|
||||
}
|
||||
fun readLong(): Long = read().toLong()
|
||||
repeat(2) { read() } // We assert that only info v2
|
||||
val gid = readLong()
|
||||
val token = read()
|
||||
|
||||
@@ -5,11 +5,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.SimpleTagsConverter
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import com.hippo.files.openInputStream
|
||||
import com.hippo.files.openOutputStream
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -18,15 +15,19 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
import nl.adaptivity.xmlutil.XmlUtilInternal
|
||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||
import nl.adaptivity.xmlutil.newGenericWriter
|
||||
import nl.adaptivity.xmlutil.newWriter
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import nl.adaptivity.xmlutil.serialization.XmlElement
|
||||
import nl.adaptivity.xmlutil.xmlStreaming
|
||||
import okio.Path
|
||||
|
||||
const val COMIC_INFO_FILE = "ComicInfo.xml"
|
||||
private const val TAG_ORIGINAL = "original"
|
||||
|
||||
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
|
||||
@OptIn(XmlUtilInternal::class)
|
||||
private val xml = XML {
|
||||
recommended {
|
||||
ignoreUnknownChildren()
|
||||
@@ -76,7 +77,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
}
|
||||
return ComicInfo(
|
||||
series = title,
|
||||
alternateSeries = titleJpn.takeUnless { it.isNullOrBlank() },
|
||||
alternateSeries = titleJpn?.ifBlank { null },
|
||||
writer = groups.ifEmpty { null },
|
||||
penciller = artists.ifEmpty { null },
|
||||
genre = otherTags.ifEmpty { null },
|
||||
@@ -97,22 +98,18 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
|
||||
teams,
|
||||
).flatten().ifEmpty { null }
|
||||
|
||||
fun ComicInfo.write(file: UniFile) {
|
||||
file.openOutputStream().use {
|
||||
OutputStreamWriter(it, Charsets.UTF_8).use { writer ->
|
||||
xmlStreaming.newGenericWriter(writer).use { xmlWriter ->
|
||||
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
|
||||
}
|
||||
fun ComicInfo.write(file: Path) {
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
xmlStreaming.newWriter(it).use { writer ->
|
||||
xml.encodeToWriter(writer, ComicInfo.serializer(), this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readComicInfo(file: UniFile): ComicInfo? = runCatching {
|
||||
file.openInputStream().use {
|
||||
InputStreamReader(it, Charsets.UTF_8).use { reader ->
|
||||
xmlStreaming.newGenericReader(reader).use { xmlReader ->
|
||||
xml.decodeFromReader(ComicInfo.serializer(), xmlReader)
|
||||
}
|
||||
fun readComicInfo(file: Path): ComicInfo? = runCatching {
|
||||
file.openInputStream().bufferedReader().use {
|
||||
xmlStreaming.newReader(it).use { reader ->
|
||||
xml.decodeFromReader(ComicInfo.serializer(), reader)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
@@ -18,12 +18,10 @@ object DownloadInfoMagics {
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> {
|
||||
return if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
|
||||
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
|
||||
a to b
|
||||
} else {
|
||||
encoded to null
|
||||
}
|
||||
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
|
||||
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
|
||||
a to b
|
||||
} else {
|
||||
encoded to null
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,22 @@ import com.hippo.ehviewer.client.executeSafely
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.coil.suspendEdit
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.download.tempDownloadDir
|
||||
import com.hippo.ehviewer.image.UniFileSource
|
||||
import com.hippo.ehviewer.image.PathSource
|
||||
import com.hippo.ehviewer.jni.archiveFdBatch
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.exists
|
||||
import com.hippo.files.find
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.list
|
||||
import com.hippo.files.openFileDescriptor
|
||||
import com.hippo.files.openOutputStream
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
@@ -51,16 +57,23 @@ import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import kotlin.io.path.readText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okio.Path
|
||||
|
||||
class SpiderDen(val info: GalleryInfo) {
|
||||
private val gid = info.gid
|
||||
var downloadDir: UniFile? = null
|
||||
var downloadDir: Path? = null
|
||||
private set
|
||||
|
||||
private var tempDownloadDir: UniFile? = null
|
||||
private var tempDownloadDir: Path? = null
|
||||
private val saveAsCbz = Settings.saveAsCbz
|
||||
private val archiveName = "$gid.cbz"
|
||||
|
||||
// Search in both directories to maintain compatibility
|
||||
private val fileCache by lazy {
|
||||
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
|
||||
.associateBy { it.name } as MutableMap
|
||||
}
|
||||
|
||||
private val imageDir
|
||||
get() = tempDownloadDir.takeIf { saveAsCbz } ?: downloadDir
|
||||
|
||||
@@ -77,10 +90,10 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
mode = value
|
||||
if (mode == SpiderQueen.MODE_DOWNLOAD) {
|
||||
if (downloadDir == null) {
|
||||
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() }
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
}
|
||||
if (saveAsCbz && tempDownloadDir == null) {
|
||||
tempDownloadDir = info.tempDownloadDir?.takeIf { it.ensureDir() }
|
||||
tempDownloadDir = info.tempDownloadDir!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,14 +103,12 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return sCache.read(key) { true } ?: false
|
||||
}
|
||||
|
||||
// Search in both directories to maintain compatibility
|
||||
private fun findImageFile(index: Int): UniFile? {
|
||||
return tempDownloadDir?.findImageFile(index) ?: downloadDir?.findImageFile(index)
|
||||
private fun findImageFile(index: Int) = synchronized(fileCache) {
|
||||
val head = perFilename(index)
|
||||
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
|
||||
}
|
||||
|
||||
private fun containInDownloadDir(index: Int): Boolean {
|
||||
return findImageFile(index) != null
|
||||
}
|
||||
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
|
||||
|
||||
private fun copyFromCacheToDownloadDir(index: Int): Boolean {
|
||||
val dir = imageDir ?: return false
|
||||
@@ -105,8 +116,8 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return runCatching {
|
||||
sCache.read(key) {
|
||||
val extension = metadata.toFile().readText()
|
||||
val file = dir.createFile(perFilename(index, extension)) ?: return false
|
||||
data.asUniFile() sendTo file
|
||||
val file = dir.findDownloadFileForIndex(index, extension)
|
||||
data sendTo file
|
||||
true
|
||||
} ?: false
|
||||
}.onFailure {
|
||||
@@ -114,19 +125,17 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
operator fun contains(index: Int): Boolean {
|
||||
return when (mode) {
|
||||
SpiderQueen.MODE_READ -> {
|
||||
containInCache(index) || containInDownloadDir(index)
|
||||
}
|
||||
operator fun contains(index: Int): Boolean = when (mode) {
|
||||
SpiderQueen.MODE_READ -> {
|
||||
containInCache(index) || containInDownloadDir(index)
|
||||
}
|
||||
|
||||
SpiderQueen.MODE_DOWNLOAD -> {
|
||||
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
|
||||
}
|
||||
SpiderQueen.MODE_DOWNLOAD -> {
|
||||
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,16 +144,20 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return sCache.remove(key)
|
||||
}
|
||||
|
||||
private fun removeFromDownloadDir(index: Int): Boolean {
|
||||
return findImageFile(index)?.delete() ?: false
|
||||
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
|
||||
delete()
|
||||
synchronized(fileCache) {
|
||||
fileCache.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(index: Int): Boolean {
|
||||
return removeFromCache(index) or removeFromDownloadDir(index)
|
||||
}
|
||||
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
|
||||
|
||||
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? {
|
||||
return imageDir?.createFile(perFilename(index, extension))
|
||||
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
|
||||
val name = perFilename(index, extension)
|
||||
return synchronized(fileCache) {
|
||||
fileCache.getOrPut(name) { resolve(name) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun makeHttpCallAndSaveImage(
|
||||
@@ -169,10 +182,10 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
private suspend inline fun saveResponseMeta(
|
||||
index: Int,
|
||||
ext: String,
|
||||
crossinline fops: suspend (UniFile) -> Unit,
|
||||
crossinline fops: suspend (Path) -> Unit,
|
||||
): Boolean {
|
||||
findDownloadFileForIndex(index, ext)?.run {
|
||||
fops(this)
|
||||
imageDir?.run {
|
||||
fops(findDownloadFileForIndex(index, ext))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -181,7 +194,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
val key = getImageKey(gid, index)
|
||||
return sCache.suspendEdit(key) {
|
||||
metadata.toFile().writeText(ext)
|
||||
fops(data.asUniFile())
|
||||
fops(data)
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -194,16 +207,19 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
outFile.openOutputStream().use {
|
||||
response.bodyAsChannel().copyTo(it.channel)
|
||||
}
|
||||
val expected = FileHashRegex.findAll(url).last().groupValues[1]
|
||||
val actual = outFile.sha1()
|
||||
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToUniFile(index: Int, file: UniFile): Boolean {
|
||||
fun saveToPath(index: Int, file: Path): Boolean {
|
||||
val key = getImageKey(gid, index)
|
||||
|
||||
// Read from diskCache first
|
||||
sCache.read(key) {
|
||||
runCatching {
|
||||
data.asUniFile() sendTo file
|
||||
data sendTo file
|
||||
return true
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
@@ -229,13 +245,13 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
|
||||
}
|
||||
|
||||
fun getImageSource(index: Int): UniFileSource? {
|
||||
fun getImageSource(index: Int): PathSource? {
|
||||
if (mode == SpiderQueen.MODE_READ) {
|
||||
val key = getImageKey(gid, index)
|
||||
val snapshot = sCache.openSnapshot(key)
|
||||
if (snapshot != null) {
|
||||
return object : UniFileSource, AutoCloseable by snapshot {
|
||||
override val source = snapshot.data.asUniFile()
|
||||
return object : PathSource, AutoCloseable by snapshot {
|
||||
override val source = snapshot.data
|
||||
override val type by lazy {
|
||||
snapshot.metadata.toNioPath().readText()
|
||||
}
|
||||
@@ -243,7 +259,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
}
|
||||
val source = findImageFile(index) ?: return null
|
||||
return object : UniFileSource {
|
||||
return object : PathSource {
|
||||
override val source = source
|
||||
override val type by lazy {
|
||||
FileUtils.getExtensionFromFilename(source.name)!!
|
||||
@@ -256,7 +272,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
suspend fun archive() {
|
||||
if (saveAsCbz) {
|
||||
downloadDir?.run {
|
||||
findFile(archiveName) ?: createFile(archiveName)?.let { file ->
|
||||
resolve(archiveName).let { file ->
|
||||
runCatching {
|
||||
archiveTo(file)
|
||||
}.onFailure {
|
||||
@@ -271,23 +287,23 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
|
||||
suspend fun postArchive(): Boolean {
|
||||
val dir = downloadDir
|
||||
val archived = saveAsCbz && dir?.findFile(archiveName) != null
|
||||
val archived = saveAsCbz && dir?.find(archiveName) != null
|
||||
if (archived) {
|
||||
dir.listFiles().parMap(concurrency = 10) {
|
||||
if (it.name?.matches(filenamePattern) == true) {
|
||||
dir.list().parMap(concurrency = 10) {
|
||||
if (it.name.matches(FileNameRegex)) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
dir.findFile(SpiderQueen.SPIDER_INFO_FILENAME)?.delete()
|
||||
(dir / SpiderQueen.SPIDER_INFO_FILENAME).delete()
|
||||
tempDownloadDir?.delete()
|
||||
}
|
||||
return archived
|
||||
}
|
||||
|
||||
suspend fun exportAsCbz(file: UniFile) =
|
||||
downloadDir!!.findFile(archiveName)?.sendTo(file) ?: archiveTo(file)
|
||||
suspend fun exportAsCbz(file: Path) =
|
||||
downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
|
||||
|
||||
private suspend fun archiveTo(file: UniFile) = resourceScope {
|
||||
private suspend fun archiveTo(file: Path) = resourceScope {
|
||||
val comicInfo = closeable {
|
||||
val f = downloadDir!! / COMIC_INFO_FILE
|
||||
if (!f.exists()) {
|
||||
@@ -307,25 +323,28 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
|
||||
suspend fun initDownloadDirIfExist() {
|
||||
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.isDirectory }
|
||||
downloadDir = getGalleryDownloadDir(info).takeIf { it.isDirectory }
|
||||
tempDownloadDir = info.tempDownloadDir?.takeIf { it.isDirectory }
|
||||
}
|
||||
|
||||
suspend fun initDownloadDir() {
|
||||
downloadDir = getGalleryDownloadDir(gid) ?: (downloadLocation / info.putToDownloadDir())
|
||||
check(downloadDir!!.ensureDir())
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
}
|
||||
|
||||
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
|
||||
downloadDir?.run {
|
||||
createFile(COMIC_INFO_FILE)?.also {
|
||||
resolve(COMIC_INFO_FILE).also {
|
||||
runCatching {
|
||||
if (info !is GalleryDetail && fetchMetadata) {
|
||||
withNonCancellableContext {
|
||||
EhEngine.fillGalleryListByApi(listOf(info))
|
||||
EhEngine.fillGalleryListByApi(listOf(info))
|
||||
}
|
||||
info.getComicInfo().apply {
|
||||
write(it)
|
||||
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
|
||||
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
|
||||
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
|
||||
}
|
||||
}
|
||||
info.getComicInfo().write(it)
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
@@ -334,25 +353,22 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
private val filenamePattern = Regex("^\\d{8}\\.\\w{3,4}")
|
||||
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
|
||||
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
|
||||
|
||||
fun perFilename(index: Int, extension: String = ""): String {
|
||||
return "%08d.%s".format(index + 1, extension)
|
||||
}
|
||||
fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
|
||||
|
||||
private fun UniFile.findImageFile(index: Int): UniFile? {
|
||||
val head = perFilename(index)
|
||||
return findFirst { name -> name.startsWith(head) }
|
||||
}
|
||||
|
||||
suspend fun GalleryInfo.putToDownloadDir(): String {
|
||||
val title = getSuitableTitle(this)
|
||||
val dirname = FileUtils.sanitizeFilename("$gid-$title")
|
||||
EhDB.putDownloadDirname(gid, dirname)
|
||||
suspend fun GalleryInfo.downloadDirname(): String {
|
||||
var dirname = EhDB.getDownloadDirname(gid)
|
||||
if (dirname == null) {
|
||||
val title = getSuitableTitle(this)
|
||||
dirname = FileUtils.sanitizeFilename("$gid-$title")
|
||||
EhDB.putDownloadDirname(gid, dirname)
|
||||
}
|
||||
return dirname
|
||||
}
|
||||
|
||||
suspend fun getGalleryDownloadDir(gid: Long): UniFile? {
|
||||
val dirname = EhDB.getDownloadDirname(gid) ?: return null
|
||||
suspend fun getGalleryDownloadDir(info: GalleryInfo): Path {
|
||||
val dirname = info.downloadDirname()
|
||||
return downloadLocation / dirname
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import com.hippo.ehviewer.coil.edit
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.legacy.readLegacySpiderInfo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import com.hippo.files.openInputStream
|
||||
import com.hippo.files.openOutputStream
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.init.appCtx
|
||||
|
||||
@@ -35,7 +35,7 @@ private val cbor = Cbor {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun SpiderInfo.write(file: UniFile) {
|
||||
fun SpiderInfo.write(file: Path) {
|
||||
file.openOutputStream().use {
|
||||
it.write(cbor.encodeToByteArray(this))
|
||||
}
|
||||
@@ -58,24 +58,20 @@ private val spiderInfoCache by lazy {
|
||||
}
|
||||
}
|
||||
|
||||
fun readFromCache(gid: Long): SpiderInfo? {
|
||||
return spiderInfoCache.read(gid.toString()) {
|
||||
runCatching {
|
||||
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun readCompatFromUniFile(file: UniFile): SpiderInfo? {
|
||||
return runCatching {
|
||||
file.openInputStream().use {
|
||||
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
|
||||
}
|
||||
}.getOrNull() ?: runCatching {
|
||||
file.openInputStream().use { readLegacySpiderInfo(it) }
|
||||
fun readFromCache(gid: Long): SpiderInfo? = spiderInfoCache.read(gid.toString()) {
|
||||
runCatching {
|
||||
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun readCompatFromPath(file: Path): SpiderInfo? = runCatching {
|
||||
file.openInputStream().use {
|
||||
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
|
||||
}
|
||||
}.getOrNull() ?: runCatching {
|
||||
file.openInputStream().use { readLegacySpiderInfo(it) }
|
||||
}.getOrNull()
|
||||
|
||||
const val TOKEN_FAILED = "failed"
|
||||
@@ -38,7 +38,7 @@ import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.files.find
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -61,6 +61,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope {
|
||||
@@ -244,12 +245,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (archiveJob.isActive) {
|
||||
archiveJob.join()
|
||||
}
|
||||
runCatching {
|
||||
if (!mSpiderDen.postArchive()) {
|
||||
if (!mSpiderDen.postArchive()) {
|
||||
if (mWorkerScope.isDownloadMode) {
|
||||
mSpiderDen.writeComicInfo()
|
||||
}
|
||||
runCatching {
|
||||
writeSpiderInfoToLocal()
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
queenScope.cancel()
|
||||
}
|
||||
@@ -299,12 +303,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
mWorkerScope.launch(index, force, orgImg)
|
||||
}
|
||||
|
||||
fun save(index: Int, file: UniFile): Boolean {
|
||||
fun save(index: Int, file: Path): Boolean {
|
||||
val state = getPageState(index)
|
||||
return if (STATE_FINISHED != state) {
|
||||
false
|
||||
} else {
|
||||
mSpiderDen.saveToUniFile(index, file)
|
||||
mSpiderDen.saveToPath(index, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,16 +321,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSpiderInfoFromLocal(): SpiderInfo? {
|
||||
return mSpiderDen.downloadDir?.run {
|
||||
findFile(SPIDER_INFO_FILENAME)?.let { file ->
|
||||
readCompatFromUniFile(file)?.takeIf {
|
||||
it.gid == galleryInfo.gid && it.token == galleryInfo.token
|
||||
}
|
||||
private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
|
||||
find(SPIDER_INFO_FILENAME)?.let { file ->
|
||||
readCompatFromPath(file)?.takeIf {
|
||||
it.gid == galleryInfo.gid && it.token == galleryInfo.token
|
||||
}
|
||||
}
|
||||
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
|
||||
}
|
||||
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
|
||||
|
||||
private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) {
|
||||
spiderInfo.previewPages = parsePreviewPages(body)
|
||||
@@ -346,21 +348,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? {
|
||||
return runSuspendCatching {
|
||||
ehRequest(
|
||||
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
|
||||
referer,
|
||||
).fetchUsingAsText {
|
||||
val pages = parsePages(this)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
|
||||
readPreviews(this, 0, spiderInfo)
|
||||
spiderInfo
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}.getOrNull()
|
||||
}
|
||||
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? = runSuspendCatching {
|
||||
ehRequest(
|
||||
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
|
||||
referer,
|
||||
).fetchUsingAsText {
|
||||
val pages = parsePages(this)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
|
||||
readPreviews(this, 0, spiderInfo)
|
||||
spiderInfo
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
|
||||
val spiderInfo = mSpiderInfo
|
||||
@@ -414,13 +414,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
@Synchronized
|
||||
private fun writeSpiderInfoToLocal() {
|
||||
if (!isReady) return
|
||||
mSpiderDen.downloadDir?.run { createFile(SPIDER_INFO_FILENAME)?.also { mSpiderInfo.write(it) } }
|
||||
mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
|
||||
mSpiderInfo.saveToCache()
|
||||
}
|
||||
|
||||
private fun isStateDone(state: Int): Boolean {
|
||||
return state == STATE_FINISHED || state == STATE_FAILED
|
||||
}
|
||||
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
|
||||
|
||||
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
|
||||
var oldState: Int
|
||||
@@ -483,13 +481,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
val gid = galleryInfo.gid
|
||||
return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply {
|
||||
setMode(mode)
|
||||
launch {
|
||||
// Will create download dir if not exists
|
||||
updateMode()
|
||||
if (mode == MODE_DOWNLOAD) {
|
||||
mSpiderDen.writeComicInfo()
|
||||
}
|
||||
}
|
||||
launch { updateMode() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +508,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private var lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
private var isDownloadMode = false
|
||||
var isDownloadMode = false
|
||||
private set
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
decoder.cancel(index)
|
||||
@@ -547,16 +540,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
val state = mPageStateArray[index]
|
||||
if (!force) {
|
||||
if (state == STATE_FINISHED) return
|
||||
if (index in mSpiderDen) return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
if (!force && state == STATE_FINISHED) return
|
||||
val currentJob = mFetcherJobMap[index]
|
||||
val skipHath = force && !orgImg && currentJob?.isActive == true
|
||||
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
runCatching {
|
||||
if (!force && index in mSpiderDen) {
|
||||
return@runCatching updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
@@ -648,15 +641,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
pToken,
|
||||
localShowKey,
|
||||
previousPToken,
|
||||
).let {
|
||||
check509(it.imageUrl)
|
||||
imageUrl = it.imageUrl
|
||||
skipHathKey = it.skipHathKey
|
||||
originImageUrl = it.originImageUrl
|
||||
}
|
||||
}.onFailure {
|
||||
)
|
||||
}.getOrElse {
|
||||
forceHtml = true
|
||||
return@repeat
|
||||
}.let {
|
||||
check509(it.imageUrl)
|
||||
imageUrl = it.imageUrl
|
||||
skipHathKey = it.skipHathKey
|
||||
originImageUrl = it.originImageUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import androidx.core.content.ContextCompat
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
@@ -58,13 +57,16 @@ import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.bgWork
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toLocalDateTime
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.files.delete
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.openOutputStream
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
@@ -78,20 +80,20 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.todayIn
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
private fun removeNoMediaFile(downloadDir: UniFile) {
|
||||
val noMedia = downloadDir / ".nomedia"
|
||||
noMedia.delete()
|
||||
private fun removeNoMediaFile(downloadDir: Path) {
|
||||
(downloadDir / ".nomedia").delete()
|
||||
}
|
||||
|
||||
private fun ensureNoMediaFile(downloadDir: UniFile) {
|
||||
downloadDir.createFile(".nomedia") ?: return
|
||||
private fun ensureNoMediaFile(downloadDir: Path) {
|
||||
(downloadDir / ".nomedia").openOutputStream().close()
|
||||
}
|
||||
|
||||
private val lck = Mutex()
|
||||
|
||||
suspend fun keepNoMediaFileStatus(downloadDir: UniFile = downloadLocation) {
|
||||
suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
|
||||
if (downloadDir.isDirectory) {
|
||||
lck.withLock {
|
||||
if (Settings.mediaScan) {
|
||||
@@ -161,7 +163,7 @@ suspend fun DialogState.startDownload(
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val (selected, checked) = showSelectItemWithCheckBox(
|
||||
val (selected, checked) = awaitSelectItemWithCheckBox(
|
||||
items,
|
||||
title = R.string.download,
|
||||
checkBoxText = R.string.remember_download_label,
|
||||
@@ -190,7 +192,7 @@ suspend fun DialogState.startDownload(
|
||||
|
||||
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
|
||||
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
|
||||
if (EhCookieStore.hasSignedIn()) {
|
||||
if (Settings.hasSignedIn.value) {
|
||||
val isFavorited = galleryInfo.favoriteSlot != NOT_FAVORITED
|
||||
val defaultFavSlot = Settings.defaultFavSlot
|
||||
if (defaultFavSlot == -2) {
|
||||
@@ -206,10 +208,14 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
|
||||
add(localFav)
|
||||
addAll(cloudFav)
|
||||
}
|
||||
val (slot, note) = showSelectItemWithIconAndTextField(
|
||||
if (galleryInfo.favoriteSlot >= 0 && galleryInfo.favoriteNote == null) {
|
||||
galleryInfo.favoriteNote = bgWork { EhEngine.getFavoriteNote(galleryInfo.gid, galleryInfo.token) }
|
||||
}
|
||||
val (slot, note) = awaitSelectItemWithIconAndTextField(
|
||||
items,
|
||||
title = R.string.add_favorites_dialog_title,
|
||||
hint = R.string.favorite_note,
|
||||
initialNote = galleryInfo.favoriteNote.orEmpty(),
|
||||
maxChar = MAX_FAVNOTE_CHAR,
|
||||
)
|
||||
return doModifyFavorites(galleryInfo, if (isFavorited) slot - 2 else slot - 1, localFavorited, note)
|
||||
@@ -248,6 +254,7 @@ private suspend fun doModifyFavorites(
|
||||
|
||||
in 0..9 -> {
|
||||
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note)
|
||||
galleryInfo.favoriteNote = note
|
||||
true
|
||||
}
|
||||
|
||||
@@ -311,7 +318,7 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
add(Icons.AutoMirrored.Default.DriveFileMove to R.string.download_move_dialog_title)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
|
||||
val selected = awaitSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
|
||||
with(findActivity<MainActivity>()) {
|
||||
when (selected) {
|
||||
0 -> {
|
||||
@@ -410,7 +417,7 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val downloadInfo = DownloadManager.getDownloadInfo(info.gid) ?: return
|
||||
val label = if (selected == 0) null else labels[selected]
|
||||
DownloadManager.changeLabel(listOf(downloadInfo), label)
|
||||
@@ -424,25 +431,23 @@ suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val label = if (selected == 0) null else labels[selected]
|
||||
DownloadManager.changeLabel(list, label)
|
||||
return label
|
||||
}
|
||||
|
||||
suspend fun DialogState.showDatePicker(): String? {
|
||||
suspend fun DialogState.awaitSelectDate(): String? {
|
||||
val initial = LocalDate(2007, 3, 21)
|
||||
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
|
||||
val initialMillis = initial.toEpochMillis()
|
||||
val yesterdayMillis = yesterday.toEpochMillis()
|
||||
val dateRange = initialMillis..yesterdayMillis
|
||||
val dateMillis = showDatePicker(
|
||||
val dateMillis = awaitSelectDate(
|
||||
title = R.string.go_to,
|
||||
yearRange = initial.year..yesterday.year,
|
||||
selectableDates = object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
return utcTimeMillis in dateRange
|
||||
}
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in dateRange
|
||||
},
|
||||
)
|
||||
val date = dateMillis?.run { toLocalDateTime().date.toString() }
|
||||
|
||||
@@ -16,16 +16,26 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.util.isAtLeastR
|
||||
import eu.kanade.tachiyomi.util.view.setSecureScreen
|
||||
|
||||
abstract class EhActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// https://issuetracker.google.com/204791558
|
||||
// Fix system bars insets still exist in fullscreen mode on API < 30
|
||||
@Suppress("DEPRECATION")
|
||||
if (!isAtLeastR) {
|
||||
with(window.decorView) {
|
||||
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material3.DrawerDefaults
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerState2
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -64,9 +65,9 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.rememberDrawerState2
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -76,6 +77,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -92,9 +94,9 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -127,14 +129,13 @@ import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
|
||||
import com.hippo.ehviewer.ui.screen.asDst
|
||||
import com.hippo.ehviewer.ui.screen.asDstWith
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navigateTo
|
||||
import com.hippo.ehviewer.ui.settings.showNewVersion
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.calculateFraction
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
@@ -142,11 +143,13 @@ import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.getUrlFromClipboard
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.sha1
|
||||
import com.hippo.ehviewer.util.sha1
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.toOkioPath
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -218,13 +221,14 @@ class MainActivity : EhActivity() {
|
||||
val snackbarState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
|
||||
navDrawerState.close()
|
||||
callback()
|
||||
}
|
||||
|
||||
suspend fun DialogState.checkDownloadLocation() {
|
||||
val valid = withIOContext { downloadLocation.ensureDir() }
|
||||
val valid = withIOContext { downloadLocation.isDirectory }
|
||||
if (!valid) {
|
||||
awaitPermissionOrCancel(
|
||||
confirmText = R.string.open_settings,
|
||||
@@ -236,21 +240,23 @@ class MainActivity : EhActivity() {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
navController.navigateTo(DownloadScreenDestination)
|
||||
navigator.navigate(DownloadScreenDestination)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { dialogState.checkDownloadLocation() }
|
||||
runCatching { dialogState.checkAppLinkVerify() }
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
if (!AppConfig.isBenchmark) {
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { dialogState.checkDownloadLocation() }
|
||||
runCatching { dialogState.checkAppLinkVerify() }
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
|
||||
}
|
||||
}.onFailure {
|
||||
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +266,7 @@ class MainActivity : EhActivity() {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navController.navWithUrl(url)) {
|
||||
if (url != null && !navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
}
|
||||
@@ -269,14 +275,14 @@ class MainActivity : EhActivity() {
|
||||
val type = intent.type
|
||||
if ("text/plain" == type) {
|
||||
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (keyword != null && !navController.navWithUrl(keyword)) {
|
||||
navController.navigateTo(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
if (keyword != null && !navigator.navWithUrl(keyword)) {
|
||||
navigator.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
}
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
val hash = withIOContext { uri.asUniFile().sha1() }
|
||||
navController.navigateTo(
|
||||
val hash = withIOContext { uri.toOkioPath().sha1() }
|
||||
navigator.navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = hash,
|
||||
@@ -286,8 +292,11 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navigateTo(DownloadsScreenDestination)
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)!!
|
||||
if (args.getString(DownloadService.KEY_ACTION) == DownloadService.ACTION_CLEAR_DOWNLOAD_SERVICE) {
|
||||
DownloadService.clear()
|
||||
}
|
||||
navigator.navigate(DownloadsScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,11 +336,11 @@ class MainActivity : EhActivity() {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
launch = { navController.navigateTo(result1.gid asDstWith result1.token) }
|
||||
launch = { navigator.navigate(result1.gid asDstWith result1.token) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
launch = { navController.navigateTo(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
launch = { navigator.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
}
|
||||
launch?.let {
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
|
||||
@@ -343,17 +352,18 @@ class MainActivity : EhActivity() {
|
||||
onPauseOrDispose { job.cancel() }
|
||||
}
|
||||
val currentDestination by navController.currentDestinationAsState()
|
||||
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
|
||||
val drawerHandle = remember { mutableStateListOf<Int>() }
|
||||
var snackbarFabPadding by remember { mutableStateOf(0.dp) }
|
||||
val drawerLocked = lockDrawerHandle.isNotEmpty()
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
val drawerEnabled = drawerHandle.isNotEmpty()
|
||||
val density = LocalDensity.current
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
CompositionLocalProvider(
|
||||
LocalNavDrawerState provides navDrawerState,
|
||||
LocalSideSheetState provides sideSheetState,
|
||||
LocalDrawerLockHandle provides lockDrawerHandle,
|
||||
LocalDrawerHandle provides drawerHandle,
|
||||
LocalSnackBarHostState provides snackbarState,
|
||||
LocalSnackBarFabPadding provides animateDpAsState(snackbarFabPadding, label = "SnackbarFabPadding"),
|
||||
LocalWindowSizeClass provides adaptiveInfo.windowSizeClass,
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
@@ -367,92 +377,85 @@ class MainActivity : EhActivity() {
|
||||
)
|
||||
},
|
||||
) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
val minOffset = -with(density) { 360.dp.toPx() }
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerState = navDrawerState,
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
var minOffset by remember {
|
||||
mutableFloatStateOf(-with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() })
|
||||
}
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerState = navDrawerState,
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp)
|
||||
.onSizeChanged { minOffset = -it.width.toFloat() },
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
navItems.forEach { (direction, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = currentDestination === direction,
|
||||
onClick = {
|
||||
navigator.navigate(direction)
|
||||
closeDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
navItems.forEach { (direction, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = currentDestination === direction,
|
||||
onClick = {
|
||||
navController.navigate(direction.route)
|
||||
closeDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = navDrawerState,
|
||||
gesturesEnabled = drawerEnabled && sideSheetState.isClosed || navDrawerState.isOpen,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
|
||||
with(density) { lerp(0, 10, step).dp.toPx() }
|
||||
}
|
||||
}.collectAsState(0f)
|
||||
ModalSideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
|
||||
) {
|
||||
sheet(sideSheetState)
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = navDrawerState,
|
||||
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (radius != 0f) {
|
||||
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && drawerEnabled,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
|
||||
with(density) { lerp(0, 10, step).dp.toPx() }
|
||||
}
|
||||
}.collectAsState(0f)
|
||||
ModalSideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Vertical + WindowInsetsSides.End),
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
sheet(sideSheetState)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (radius != 0f) {
|
||||
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && !drawerLocked,
|
||||
) {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
CompositionLocalProvider(
|
||||
LocalViewConfiguration provides viewConfiguration,
|
||||
LocalWindowSizeClass provides windowSizeClass,
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,16 +534,15 @@ class MainActivity : EhActivity() {
|
||||
|
||||
val LocalNavDrawerState = compositionLocalOf<DrawerState> { error("CompositionLocal LocalNavDrawerState not present!") }
|
||||
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerLockHandle not present!") }
|
||||
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
|
||||
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
|
||||
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
|
||||
|
||||
@Composable
|
||||
fun LockDrawer(value: Boolean) {
|
||||
val updated by rememberUpdatedState(value)
|
||||
if (updated) {
|
||||
fun DrawerHandle(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
val current = currentCompositeKeyHash
|
||||
val handle = LocalDrawerLockHandle.current
|
||||
val handle = LocalDrawerHandle.current
|
||||
DisposableEffect(current) {
|
||||
handle.add(current)
|
||||
onDispose {
|
||||
|
||||
@@ -5,8 +5,6 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -27,7 +25,6 @@ inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -41,7 +38,6 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.DrawableWrapper
|
||||
import android.text.Html
|
||||
import android.widget.TextView
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user