Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4ab6fdab | ||
|
|
e60b6922c3 | ||
|
|
d3d2c907ad | ||
|
|
84306e0b28 | ||
|
|
f7c2148d5f | ||
|
|
46e5a0e121 | ||
|
|
2f5b52f12c | ||
|
|
20f9120583 | ||
|
|
e65428cae1 | ||
|
|
e5862e807c | ||
|
|
911fb3cae0 | ||
|
|
d76821f6a6 | ||
|
|
1d5a680a5b | ||
|
|
2f38e17fd6 | ||
|
|
7de4bea5f7 | ||
|
|
35db8baa2a | ||
|
|
e774dd3a6c | ||
|
|
f3619c6b43 | ||
|
|
b807081f9d | ||
|
|
0807d7f1ce | ||
|
|
8b6894716b | ||
|
|
cb733d8e38 | ||
|
|
d9c7daebea | ||
|
|
bbe8fd6b8f | ||
|
|
8f8fe513fa | ||
|
|
8a4df1f2ba | ||
|
|
dacbf5edb3 | ||
|
|
4e145f46db | ||
|
|
53dc622376 | ||
|
|
aab8fabb1f | ||
|
|
86bb240633 | ||
|
|
c6b3aec1af | ||
|
|
e6c4b32a47 | ||
|
|
b71e83047d | ||
|
|
8609336240 | ||
|
|
ded11a81a5 | ||
|
|
0c00ea9292 | ||
|
|
672b53d8a9 | ||
|
|
bc5032b2d0 | ||
|
|
4cefa439ec | ||
|
|
4d2b7b3ad0 | ||
|
|
58f85cc436 | ||
|
|
5c50d7622b | ||
|
|
e90b31bf69 | ||
|
|
b43d60138b | ||
|
|
9f4faebfe6 | ||
|
|
b6f84d8de6 | ||
|
|
230eec7c59 | ||
|
|
3b9cfefd5f | ||
|
|
c1b701f1f9 | ||
|
|
4744caaacc | ||
|
|
b58912462a | ||
|
|
f3cadf594d | ||
|
|
a8834ee21c | ||
|
|
0c59403e49 | ||
|
|
431a771672 | ||
|
|
b043b06208 | ||
|
|
abccf713df | ||
|
|
4b83f6f28f | ||
|
|
e843a547a5 | ||
|
|
40d9bfa30b | ||
|
|
69669f0a50 | ||
|
|
4c20d27d33 | ||
|
|
06eb277b34 | ||
|
|
86120277c7 | ||
|
|
62927cb6a8 | ||
|
|
22f31f2ebc | ||
|
|
c8cfaeecf6 | ||
|
|
714d334067 | ||
|
|
ca324995b3 | ||
|
|
f301eab5b1 | ||
|
|
87ac60a3ab | ||
|
|
a2f89f07fa | ||
|
|
57d7d2b798 | ||
|
|
6f3bc25b64 | ||
|
|
62477dac87 | ||
|
|
0dfee513f4 | ||
|
|
31f97cb840 | ||
|
|
93e2d36dbb | ||
|
|
aed3d08167 | ||
|
|
9c5f11dd9d | ||
|
|
1af7aa8a16 | ||
|
|
cf6f793478 | ||
|
|
145948f506 | ||
|
|
b068492d10 | ||
|
|
8c94d39ae4 | ||
|
|
c584d72f41 | ||
|
|
cfd248f818 | ||
|
|
b9e59ee2f8 | ||
|
|
439899fbdc | ||
|
|
177c1889fa | ||
|
|
ebce847724 | ||
|
|
c6b6d9a310 | ||
|
|
6601a45472 | ||
|
|
d66fb9a783 | ||
|
|
c244369875 | ||
|
|
f9c812864e | ||
|
|
87d0cdd9e5 | ||
|
|
d237a18aae | ||
|
|
0159cf30ae | ||
|
|
6aba1670ec | ||
|
|
447a31ea00 | ||
|
|
9e410a61ef | ||
|
|
4588b7eaa5 | ||
|
|
aef55c9207 | ||
|
|
9a587e9dd2 | ||
|
|
cbe834c71d | ||
|
|
020495cebf | ||
|
|
54d3cccb67 | ||
|
|
b4adf57f39 | ||
|
|
b796dbcde3 | ||
|
|
ba2c69690f | ||
|
|
c3b60f1eac | ||
|
|
2bb387eeaf | ||
|
|
fd67c733aa | ||
|
|
eac7048360 | ||
|
|
34de943c9f | ||
|
|
302b43c9e8 | ||
|
|
07b3917c4e | ||
|
|
1ace248121 | ||
|
|
7a96feb049 | ||
|
|
5df1d099a1 | ||
|
|
ed2db49cdb | ||
|
|
7b2c99357e | ||
|
|
4868c40795 | ||
|
|
27ff17db50 | ||
|
|
c15a52eb2a | ||
|
|
faa54dedb1 | ||
|
|
59bdb4b2f0 | ||
|
|
4990d757ae | ||
|
|
5f71c3dcb4 | ||
|
|
1892a750b7 | ||
|
|
020f853bb1 | ||
|
|
9ccddae60a | ||
|
|
b7a2d93f03 | ||
|
|
2fc8b421b0 | ||
|
|
180466674f | ||
|
|
ac23a4312d | ||
|
|
aaea83b932 | ||
|
|
8425ea0b2f | ||
|
|
100a6569b7 | ||
|
|
e8c4b9ab78 | ||
|
|
eff5467ccf | ||
|
|
769308c9f0 | ||
|
|
e1de57c98c | ||
|
|
9c6a5af87e | ||
|
|
9d88577108 | ||
|
|
dbebc5956f | ||
|
|
52ca5a7eb8 | ||
|
|
78b898dc5c | ||
|
|
1e81157d5c | ||
|
|
5cff3db9b5 | ||
|
|
25fe80f252 | ||
|
|
413bb2cd82 | ||
|
|
b867bbc7a6 | ||
|
|
3bf6f763aa | ||
|
|
2c0d621b16 | ||
|
|
e930ebf42f | ||
|
|
4e3862b1c5 | ||
|
|
e3ffb9a473 | ||
|
|
e485d79bb1 | ||
|
|
49edbb8185 | ||
|
|
32f7407752 | ||
|
|
aef02d34a5 | ||
|
|
bb2f9bb309 | ||
|
|
140b6528a6 | ||
|
|
fa3cf650ea | ||
|
|
cfe2cff74c | ||
|
|
1def52e41a | ||
|
|
cc4eab3244 | ||
|
|
4327e48c1f | ||
|
|
c8c077c14b | ||
|
|
f82221552b | ||
|
|
bfe0aad951 | ||
|
|
2f5d8edb03 | ||
|
|
ac0acafff9 | ||
|
|
9333150c3b | ||
|
|
0c71c6856b | ||
|
|
6a47a8a1e1 | ||
|
|
04ee1a6362 | ||
|
|
9ead29643d | ||
|
|
d8e7bd4ccc | ||
|
|
dd2949638c | ||
|
|
f71aa4902b | ||
|
|
af7e411932 | ||
|
|
c54ac1a4c0 | ||
|
|
00d6a2e403 | ||
|
|
b67d0938b0 | ||
|
|
8447760cb5 | ||
|
|
24d2072586 | ||
|
|
a52ab28534 | ||
|
|
601713d8d7 | ||
|
|
7bfa5be53c | ||
|
|
ae3a66903a | ||
|
|
5438fd5d9b | ||
|
|
a74ffca03d | ||
|
|
c5a83d567e | ||
|
|
1b1f781dd9 | ||
|
|
988195e4fc | ||
|
|
4d4124458d | ||
|
|
0ae2843676 | ||
|
|
beaddc9eeb | ||
|
|
d843b3a14b | ||
|
|
b9da0eda8b | ||
|
|
ba75cda036 | ||
|
|
9d59af29c5 | ||
|
|
660d6e32f1 | ||
|
|
c07c663105 | ||
|
|
3673f6f7f9 | ||
|
|
e8120e12d0 | ||
|
|
f7459886b2 | ||
|
|
6fe5319c08 | ||
|
|
3a42208a36 | ||
|
|
e4eacce5bf | ||
|
|
d1fdea8d3e | ||
|
|
98e452e39e | ||
|
|
02e8fcc534 | ||
|
|
70852c2725 | ||
|
|
ea67d6e078 | ||
|
|
d2aa3b5347 | ||
|
|
937792ba91 | ||
|
|
fcfdaf59f8 | ||
|
|
0d4da998a1 | ||
|
|
0e7a32d9af | ||
|
|
4bb6b0baf6 | ||
|
|
0591b3224f | ||
|
|
eaf8dd21ff | ||
|
|
9bc6cccf26 | ||
|
|
eb3b53ee5b | ||
|
|
731af586af | ||
|
|
3acb70b8e1 | ||
|
|
836b1841e3 | ||
|
|
d37f4580a9 | ||
|
|
70fc964b4d | ||
|
|
c3c5fbfc79 | ||
|
|
04103f9280 | ||
|
|
69370efcf5 | ||
|
|
f0d3b0c8ae | ||
|
|
370c485048 | ||
|
|
7e4c910cd7 | ||
|
|
e8c1c80ce5 | ||
|
|
0d55f319c9 | ||
|
|
42ed51094e | ||
|
|
84c3fee5da | ||
|
|
b3af2c9481 | ||
|
|
33cab186fd | ||
|
|
e3f52412ee | ||
|
|
080810c707 | ||
|
|
964e827cbe | ||
|
|
cae02acfc4 | ||
|
|
31ce4eac92 | ||
|
|
bd5bd1e571 | ||
|
|
2b4a6dc57a | ||
|
|
68932731b9 | ||
|
|
aab7ba0d53 | ||
|
|
59bf4f2702 | ||
|
|
e0e4b739f9 | ||
|
|
21d6b3bbb8 | ||
|
|
dae5d67515 | ||
|
|
a6a814bbbb | ||
|
|
0b0555af9d | ||
|
|
6e0d77a8ab | ||
|
|
5c06a69c8a | ||
|
|
188a236a90 | ||
|
|
63ec1aa217 | ||
|
|
829316d8b9 | ||
|
|
337739fdad | ||
|
|
f144ac701d | ||
|
|
9c9b8b74cd | ||
|
|
154c7f3710 | ||
|
|
d3308bc987 | ||
|
|
04d9ee57d7 | ||
|
|
f3eecf18fb | ||
|
|
6591495d2d | ||
|
|
2497835628 | ||
|
|
8fad9b2abe | ||
|
|
fdd0ec9fd2 | ||
|
|
1219ff99ff | ||
|
|
c60b14998a | ||
|
|
380554bd19 | ||
|
|
2b969030da | ||
|
|
da917de1f4 | ||
|
|
541f9e89f3 | ||
|
|
f4a987c83f | ||
|
|
37474ed6ac | ||
|
|
cb69a168f3 | ||
|
|
94366553de | ||
|
|
383332bfbb | ||
|
|
4bb1a17d39 | ||
|
|
94d68fe38b | ||
|
|
cf6c850d2a | ||
|
|
c8a3a0fc20 | ||
|
|
433896b267 | ||
|
|
7e9c1f00ef | ||
|
|
2b5b0d695a | ||
|
|
2265b0943e | ||
|
|
78f12065ab | ||
|
|
f28c45a13a | ||
|
|
e030500917 | ||
|
|
9c042cd323 | ||
|
|
44eea7a273 | ||
|
|
834cfc5e8c | ||
|
|
876bb68873 | ||
|
|
40c1b7ef21 |
No files matched your search
@@ -2,3 +2,4 @@
|
||||
ij_kotlin_imports_layout = *
|
||||
ktlint_code_style = intellij_idea
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
ktlint_standard_class-signature = disabled
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- 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
|
||||
run: rustup default 1.81.0 && 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
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
- name: Clippy Check
|
||||
working-directory: ./app/src/main/rust
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- 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
|
||||
run: rustup default 1.81.0 && 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
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-default-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
key: cmake-default-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
|
||||
restore-keys: cmake-default-
|
||||
|
||||
- name: Build
|
||||
@@ -104,6 +104,7 @@ jobs:
|
||||
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-mapping-${{ github.sha }}
|
||||
@@ -128,7 +129,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- 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
|
||||
run: rustup default 1.81.0 && 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
|
||||
@@ -146,7 +147,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-marshmallow-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
key: cmake-marshmallow-${{ hashFiles('settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
|
||||
restore-keys: cmake-marshmallow-
|
||||
|
||||
- name: Build
|
||||
@@ -171,6 +172,7 @@ jobs:
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-mapping-${{ github.sha }}
|
||||
|
||||
@@ -9,6 +9,15 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: rm -rf $AGENT_TOOLSDIRECTORY
|
||||
|
||||
- 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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -18,19 +27,8 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
shopt -s extglob
|
||||
rm -rf ${ANDROID_SDK_ROOT}/ndk/!(27*)
|
||||
|
||||
- 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: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
run: rustup default 1.81.0 && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -38,10 +36,10 @@ jobs:
|
||||
workspaces: "./app/src/main/rust"
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: app/.cxx
|
||||
key: cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
key: cmake-
|
||||
restore-keys: cmake-
|
||||
|
||||
- name: Setup Gradle
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,64 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.13.0] - 2024-11-01
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 不再捆绑 Cronet 库,在不支持 HttpEngine 的设备上回退到 OkHttp
|
||||
* 适配 E 站缩略图和 WebP 等相关变更
|
||||
|
||||
### 改进
|
||||
|
||||
* 使用磁力链接代替种子下载
|
||||
* 在搜索栏右侧显示论坛头像
|
||||
* 支持重置下载路径
|
||||
* 为 Android 10 以下且没有 DocumentsUI 的设备创建默认下载目录 [#1735](https://github.com/FooIbar/EhViewer/issues/1735)
|
||||
* 重新添加了强制使用 e-hentai 缩略图服务器的选项
|
||||
* 阅读器支持 PageUp/PageDown 和方向键上/下翻页 [#1801](https://github.com/FooIbar/EhViewer/issues/1801)
|
||||
* 未找到搜索结果时显示可能存在的警告 [#1787](https://github.com/FooIbar/EhViewer/issues/1787)
|
||||
* 更新了日本语翻译
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 阅读器屏幕方向与手机屏幕方向不同时闪退 [#1779](https://github.com/FooIbar/EhViewer/issues/1779)
|
||||
* 下载文件哈希不匹配导致下载失败 [#1811](https://github.com/FooIbar/EhViewer/issues/1811)
|
||||
* 无法同时下载和查看 [#1822](https://github.com/FooIbar/EhViewer/issues/1822)
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.12.1] - 2024-09-28
|
||||
|
||||
### 新功能
|
||||
|
||||
* 屏蔽含有二维码的图片(位于高级设置)
|
||||
|
||||
### 改进
|
||||
|
||||
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
|
||||
* 画廊预览与画廊详情页面合并
|
||||
* 为快速搜索和下载标签列表添加了滚动条
|
||||
* 无网络条件下启动时自动转到下载页面
|
||||
* 杂项优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
|
||||
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
|
||||
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
|
||||
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
|
||||
* 加载大于 100 MB 的位图时崩溃
|
||||
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
|
||||
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
|
||||
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
|
||||
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
|
||||
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
|
||||
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
|
||||
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
|
||||
* 选择无效的下载位置后闪退
|
||||
* 杂项修复
|
||||
|
||||
|
||||
## [1.12.0] - 2024-08-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
+41
-43
@@ -21,10 +21,23 @@ plugins {
|
||||
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
buildToolsVersion = "35.0.0"
|
||||
ndkVersion = "27.0.12077973"
|
||||
androidResources.generateLocaleConfig = true
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
)
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
@@ -61,32 +74,13 @@ android {
|
||||
}.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 chromeVersion = rootProject.layout.projectDirectory.file("chrome.version").asFile.readText().trim()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 180059
|
||||
versionName = "1.12.0"
|
||||
versionCode = 180061
|
||||
versionName = "1.13.0"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
"zh-rCN",
|
||||
"zh-rHK",
|
||||
"zh-rTW",
|
||||
"es",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"th",
|
||||
"tr",
|
||||
"nb-rNO",
|
||||
),
|
||||
)
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("long", "COMMIT_TIME", commitTime)
|
||||
@@ -136,6 +130,19 @@ android {
|
||||
dex {
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
resources {
|
||||
// Required by Layout Inspector
|
||||
pickFirsts += "/META-INF/androidx.compose.ui_ui.version"
|
||||
|
||||
excludes += listOf(
|
||||
"/META-INF/**",
|
||||
"/kotlin/**",
|
||||
"**.txt",
|
||||
"**.bin",
|
||||
"**.{html,mmd}", // Compose Destination
|
||||
"/okhttp3/**", // Okhttp public suffix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo.includeInApk = false
|
||||
@@ -149,9 +156,6 @@ android {
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
create("benchmarkRelease") {
|
||||
initWith(buildTypes.getByName("release"))
|
||||
@@ -174,17 +178,6 @@ composeCompiler {
|
||||
featureFlags = setOf(ComposeFeatureFlag.OptimizeNonSkippingGroups)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) {
|
||||
it.packaging.resources.excludes.addAll(
|
||||
"/META-INF/**",
|
||||
"/kotlin/**",
|
||||
"**.txt",
|
||||
"**.bin",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
}
|
||||
@@ -223,9 +216,14 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.material.motion.core)
|
||||
implementation(libs.material.kolor)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp.android)
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation(libs.logcat)
|
||||
@@ -248,7 +246,7 @@ dependencies {
|
||||
|
||||
implementation(libs.telephoto.zoomable)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
implementation(libs.bundles.kotlinx.serialization)
|
||||
|
||||
@@ -258,8 +256,6 @@ dependencies {
|
||||
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
|
||||
implementation(libs.cronet.embedded)
|
||||
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
"baselineProfile"(project(":benchmark"))
|
||||
|
||||
@@ -275,6 +271,8 @@ kotlin {
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xcontext-receivers",
|
||||
"-Xwhen-guards",
|
||||
"-Xsuppress-warning=CONTEXT_RECEIVERS_DEPRECATED",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
|
||||
Vendored
-2
@@ -14,8 +14,6 @@
|
||||
# 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*
|
||||
|
||||
@@ -23,7 +23,8 @@ set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
|
||||
FetchContent_Declare(
|
||||
liblzma
|
||||
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
|
||||
GIT_TAG v5.6.2
|
||||
GIT_TAG v5.6.3
|
||||
GIT_SHALLOW 1
|
||||
PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
|
||||
)
|
||||
|
||||
@@ -44,10 +45,12 @@ set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}"
|
||||
ExternalProject_Add(
|
||||
nettle
|
||||
PREFIX nettle
|
||||
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
|
||||
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
|
||||
GIT_REPOSITORY https://gitlab.com/gnutls/nettle.git
|
||||
GIT_TAG nettle_3.10_release_20240616
|
||||
GIT_SHALLOW 1
|
||||
GIT_CONFIG core.autocrlf=input
|
||||
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}'"
|
||||
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS} -Qunused-arguments'"
|
||||
INSTALL_COMMAND ""
|
||||
BUILD_IN_SOURCE 1
|
||||
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
|
||||
@@ -89,7 +92,8 @@ set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
|
||||
GIT_TAG v3.7.4
|
||||
GIT_TAG v3.7.7
|
||||
GIT_SHALLOW 1
|
||||
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
|
||||
)
|
||||
|
||||
@@ -100,12 +104,16 @@ FetchContent_Declare(
|
||||
Corrosion
|
||||
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
|
||||
GIT_TAG v0.5
|
||||
GIT_SHALLOW 1
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml LOCKED)
|
||||
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
|
||||
if (ANDROID_NATIVE_API_LEVEL GREATER_EQUAL 26)
|
||||
corrosion_set_features(ehviewer_rust FEATURES api-level-26)
|
||||
endif ()
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
|
||||
@@ -1,241 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <android/bitmap.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/** A line will be considered as having content if 0.25% of it is filled. */
|
||||
const float filledRatioLimit = 0.0025;
|
||||
|
||||
/** When the threshold is closer to 1, less content will be cropped. **/
|
||||
#define THRESHOLD 0.75
|
||||
|
||||
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
|
||||
|
||||
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
|
||||
|
||||
static uint8_t inline grey(uint32_t pixel) {
|
||||
const uint8_t r = ((uint8_t *) &pixel)[0];
|
||||
const uint8_t g = ((uint8_t *) &pixel)[1];
|
||||
const uint8_t b = ((uint8_t *) &pixel)[2];
|
||||
return (r * 77 + g * 151 + b * 28) >> 8;
|
||||
}
|
||||
|
||||
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) < thresholdForBlack;
|
||||
}
|
||||
|
||||
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) > thresholdForWhite;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the left. */
|
||||
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, 0, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, 0, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = 1; x < width; x++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the right. */
|
||||
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
uint32_t lastX = width - 1;
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, lastX, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, lastX, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return width;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = width - 2; x > 0; x--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the top. */
|
||||
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, 0)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, 0)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = 1; y < height; y++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the bottom. */
|
||||
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
uint32_t lastY = height - 1;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, lastY)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, lastY)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return height;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = height - 2; y > 0; y--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return height;
|
||||
}
|
||||
|
||||
JNIEXPORT jintArray JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
|
||||
void *pixels = NULL;
|
||||
AndroidBitmapInfo info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
AndroidBitmap_getInfo(env, bitmap, &info);
|
||||
AndroidBitmap_lockPixels(env, bitmap, &pixels);
|
||||
uint32_t width = info.width;
|
||||
uint32_t height = info.height;
|
||||
uint32_t top = findBorderTop(pixels, width, height);
|
||||
uint32_t bottom = findBorderBottom(pixels, width, height);
|
||||
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
|
||||
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
|
||||
int array[4] = {left, top, right, bottom};
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
jintArray ret = (*env)->NewIntArray(env, 4);
|
||||
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
|
||||
return ret;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
SUFFIX=toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
|
||||
CI_NDK_LOCATION=$ANDROID_HOME/ndk/$1/$SUFFIX
|
||||
ARCH_NDK_LOCATION=$ANDROID_HOME/ndk-bundle/$SUFFIX
|
||||
if test -e $CI_NDK_LOCATION ;then
|
||||
TOOLCHAIN=$CI_NDK_LOCATION
|
||||
else
|
||||
TOOLCHAIN=$ARCH_NDK_LOCATION
|
||||
fi
|
||||
TARGET=$2
|
||||
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
./.bootstrap && ./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
|
||||
AR=$TOOLCHAIN/llvm-ar \
|
||||
CC="$TOOLCHAIN/clang -target $TARGET" \
|
||||
RANLIB=$TOOLCHAIN/llvm-ranlib
|
||||
@@ -30,7 +30,6 @@
|
||||
* negative chars in their default char type.
|
||||
*/
|
||||
|
||||
#include <stddef.h> /* size_t */
|
||||
#include <ctype.h>
|
||||
|
||||
#include "strnatcmp.h"
|
||||
@@ -39,28 +38,21 @@
|
||||
/* These are defined as macros to make it easier to adapt this code to
|
||||
* different characters types or comparison functions. */
|
||||
static inline int
|
||||
nat_isdigit(nat_char a)
|
||||
nat_isdigit(const char *a)
|
||||
{
|
||||
return isdigit((unsigned char) a);
|
||||
return isdigit((unsigned char) *a);
|
||||
}
|
||||
|
||||
|
||||
static inline int
|
||||
nat_isspace(nat_char a)
|
||||
nat_isspace(const char *a)
|
||||
{
|
||||
return isspace((unsigned char) a);
|
||||
}
|
||||
|
||||
|
||||
static inline nat_char
|
||||
nat_toupper(nat_char a)
|
||||
{
|
||||
return toupper((unsigned char) a);
|
||||
return isspace((unsigned char) *a) || *a == '0' && nat_isdigit(a + 1);
|
||||
}
|
||||
|
||||
|
||||
static int
|
||||
compare_right(nat_char const *a, nat_char const *b)
|
||||
compare_right(const char *a, const char *b)
|
||||
{
|
||||
int bias = 0;
|
||||
|
||||
@@ -69,11 +61,11 @@ compare_right(nat_char const *a, nat_char const *b)
|
||||
both numbers to know that they have the same magnitude, so we
|
||||
remember it in BIAS. */
|
||||
for (;; a++, b++) {
|
||||
if (!nat_isdigit(*a) && !nat_isdigit(*b))
|
||||
if (!nat_isdigit(a) && !nat_isdigit(b))
|
||||
return bias;
|
||||
if (!nat_isdigit(*a))
|
||||
if (!nat_isdigit(a))
|
||||
return -1;
|
||||
if (!nat_isdigit(*b))
|
||||
if (!nat_isdigit(b))
|
||||
return +1;
|
||||
if (*a < *b) {
|
||||
if (!bias)
|
||||
@@ -87,60 +79,38 @@ compare_right(nat_char const *a, nat_char const *b)
|
||||
}
|
||||
|
||||
|
||||
static int
|
||||
strnatcmp0(nat_char const *a, nat_char const *b, int fold_case)
|
||||
int
|
||||
strnatcmp(const char *a, const char *b)
|
||||
{
|
||||
int ai, bi;
|
||||
nat_char ca, cb;
|
||||
int result;
|
||||
int result;
|
||||
|
||||
ai = bi = 0;
|
||||
while (1) {
|
||||
ca = a[ai]; cb = b[bi];
|
||||
while (1) {
|
||||
/* skip over leading spaces or zeros */
|
||||
while (nat_isspace(a))
|
||||
a++;
|
||||
|
||||
/* skip over leading spaces or zeros */
|
||||
while (nat_isspace(ca) || ca == '0')
|
||||
ca = a[++ai];
|
||||
|
||||
while (nat_isspace(cb) || cb == '0')
|
||||
cb = b[++bi];
|
||||
while (nat_isspace(b))
|
||||
b++;
|
||||
|
||||
/* process run of digits */
|
||||
if (nat_isdigit(ca) && nat_isdigit(cb)) {
|
||||
if ((result = compare_right(a+ai, b+bi)) != 0)
|
||||
if (nat_isdigit(a) && nat_isdigit(b)) {
|
||||
if ((result = compare_right(a, b)) != 0)
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!ca && !cb) {
|
||||
if (!*a && !*b) {
|
||||
/* The strings compare the same. Perhaps the caller
|
||||
will want to call strcmp to break the tie. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (fold_case) {
|
||||
ca = nat_toupper(ca);
|
||||
cb = nat_toupper(cb);
|
||||
}
|
||||
|
||||
if (ca < cb)
|
||||
if (*a < *b)
|
||||
return -1;
|
||||
|
||||
if (ca > cb)
|
||||
if (*a > *b)
|
||||
return +1;
|
||||
|
||||
++ai; ++bi;
|
||||
a++; b++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
strnatcmp(nat_char const *a, nat_char const *b) {
|
||||
return strnatcmp0(a, b, 0);
|
||||
}
|
||||
|
||||
|
||||
/* Compare, recognizing numeric string and ignoring case. */
|
||||
int
|
||||
strnatcasecmp(nat_char const *a, nat_char const *b) {
|
||||
return strnatcmp0(a, b, 1);
|
||||
}
|
||||
@@ -24,14 +24,7 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* CUSTOMIZATION SECTION
|
||||
*
|
||||
* You can change this typedef, but must then also change the inline
|
||||
* functions in strnatcmp.c */
|
||||
typedef char nat_char;
|
||||
|
||||
int strnatcmp(nat_char const *a, nat_char const *b);
|
||||
int strnatcasecmp(nat_char const *a, nat_char const *b);
|
||||
int strnatcmp(const char *a, const char *b);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
+65
-49
@@ -24,6 +24,8 @@ 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.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
@@ -243,9 +245,8 @@ private class AnchoredHorizontalDraggableNode<T>(
|
||||
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())
|
||||
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
|
||||
@@ -256,31 +257,49 @@ internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
|
||||
@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
|
||||
require(!currentOffset.isNaN()) { "The offset provided to computeTarget must not be NaN." }
|
||||
val velocitySign = sign(velocity)
|
||||
val isMovingForward = velocitySign == 1.0f
|
||||
val isMoving = isMovingForward || velocitySign == -1.0f
|
||||
// When we're not moving, just pick the closest anchor and don't consider directionality
|
||||
return if (!isMoving) {
|
||||
currentAnchors.closestAnchor(currentOffset)!!
|
||||
} else if (abs(velocity) >= abs(velocityThreshold())) {
|
||||
currentAnchors.closestAnchor(currentOffset, searchUpwards = isMovingForward)!!
|
||||
} 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
|
||||
}
|
||||
val left = currentAnchors.closestAnchor(currentOffset, false)!!
|
||||
val leftAnchorPosition = currentAnchors.positionOf(left)
|
||||
val right = currentAnchors.closestAnchor(currentOffset, true)!!
|
||||
val rightAnchorPosition = currentAnchors.positionOf(right)
|
||||
val distance = abs(leftAnchorPosition - rightAnchorPosition)
|
||||
val relativeThreshold = abs(positionalThreshold(distance))
|
||||
val closestAnchorFromStart =
|
||||
if (isMovingForward) leftAnchorPosition else rightAnchorPosition
|
||||
val relativePosition = abs(closestAnchorFromStart - currentOffset)
|
||||
if (relativePosition <= relativeThreshold) left else right
|
||||
}
|
||||
}
|
||||
|
||||
// https://issuetracker.google.com/374241709
|
||||
@Composable
|
||||
fun <T> flingBehavior(
|
||||
state: AnchoredDraggableState<T>,
|
||||
positionalThreshold: (totalDistance: Float) -> Float = AnchoredDraggableDefaults.PositionalThreshold,
|
||||
animationSpec: AnimationSpec<Float> = AnchoredDraggableDefaults.SnapAnimationSpec,
|
||||
): TargetedFlingBehavior {
|
||||
val density = LocalDensity.current
|
||||
return remember(density, state, positionalThreshold, animationSpec) {
|
||||
anchoredDraggableFlingBehavior(
|
||||
state = state,
|
||||
density = density,
|
||||
positionalThreshold = positionalThreshold,
|
||||
snapAnimationSpec = animationSpec,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,17 +318,16 @@ internal fun <T> anchoredDraggableFlingBehavior(
|
||||
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() } },
|
||||
),
|
||||
)
|
||||
): TargetedFlingBehavior = snapFlingBehavior(
|
||||
decayAnimationSpec = NoOpDecayAnimationSpec,
|
||||
snapAnimationSpec = snapAnimationSpec,
|
||||
snapLayoutInfoProvider =
|
||||
AnchoredDraggableLayoutInfoProvider(
|
||||
state = state,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = { with(density) { 125.dp.toPx() } },
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -317,25 +335,23 @@ private fun <T> AnchoredDraggableLayoutInfoProvider(
|
||||
state: AnchoredDraggableState<T>,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
velocityThreshold: () -> Float,
|
||||
): SnapLayoutInfoProvider =
|
||||
object : SnapLayoutInfoProvider {
|
||||
): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
|
||||
|
||||
// We never decay in AnchoredDraggable's fling
|
||||
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
|
||||
// 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
|
||||
}
|
||||
override fun calculateSnapOffset(velocity: Float): Float {
|
||||
val currentOffset = state.requireOffset()
|
||||
val target =
|
||||
state.anchors.computeTarget(
|
||||
currentOffset = currentOffset,
|
||||
velocity = velocity,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = velocityThreshold,
|
||||
)
|
||||
return state.anchors.positionOf(target) - currentOffset
|
||||
}
|
||||
}
|
||||
|
||||
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
|
||||
object : FloatDecayAnimationSpec {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val textColor = LocalTextStyle.current.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier = modifier.sizeIn(
|
||||
minWidth = SearchBarMinWidth,
|
||||
maxWidth = SearchBarMaxWidth,
|
||||
minHeight = InputFieldHeight,
|
||||
).focusRequester(focusRequester).onFocusChanged {
|
||||
if (it.isFocused) onExpandedChange(true)
|
||||
}.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
onKeyboardAction = { onSearch() },
|
||||
interactionSource = interactionSource,
|
||||
decorator = @Composable { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
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,
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val shouldClearFocus = !expanded && focused
|
||||
LaunchedEffect(expanded) {
|
||||
if (shouldClearFocus) {
|
||||
// Not strictly needed according to the motion spec, but since the animation
|
||||
// already has a delay, this works around b/261632544.
|
||||
delay(AnimationDelayMillis.toLong())
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SearchBarIconOffsetX = 4.dp
|
||||
val SearchBarMinWidth = 360.dp
|
||||
val SearchBarMaxWidth = 720.dp
|
||||
const val AnimationDelayMillis = 100
|
||||
@@ -0,0 +1,191 @@
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.material3.SearchBarDefaults.inputFieldColors
|
||||
import androidx.compose.material3.SearchBarDefaults.inputFieldShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.ColorProducer
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A text field to input a query in a search bar.
|
||||
*
|
||||
* @param state [TextFieldState] that holds the internal editing state of the input field.
|
||||
* @param onSearch the callback to be invoked when the input service triggers the
|
||||
* [ImeAction.Search] action. The current query in the [state] comes as a parameter of the
|
||||
* callback.
|
||||
* @param expanded whether the search bar is expanded and showing search results.
|
||||
* @param onExpandedChange the callback to be invoked when the search bar's expanded state is
|
||||
* changed.
|
||||
* @param modifier the [Modifier] to be applied to this input field.
|
||||
* @param enabled the enabled state of this input field. When `false`, this component will not
|
||||
* respond to user input, and it will appear visually disabled and disabled to accessibility
|
||||
* services.
|
||||
* @param readOnly controls the editable state of the input field. When `true`, the field cannot
|
||||
* be modified. However, a user can focus it and copy text from it.
|
||||
* @param textStyle the style to be applied to the input text. Defaults to [LocalTextStyle].
|
||||
* @param placeholder the placeholder to be displayed when the input text is empty.
|
||||
* @param leadingIcon the leading icon to be displayed at the start of the input field.
|
||||
* @param trailingIcon the trailing icon to be displayed at the end of the input field.
|
||||
* @param prefix the optional prefix to be displayed before the input text.
|
||||
* @param suffix the optional suffix to be displayed after the input text.
|
||||
* @param inputTransformation optional [InputTransformation] that will be used to transform
|
||||
* changes to the [TextFieldState] made by the user. The transformation will be applied to
|
||||
* changes made by hardware and software keyboard events, pasting or dropping text,
|
||||
* accessibility services, and tests. The transformation will _not_ be applied when changing
|
||||
* the [state] programmatically, or when the transformation is changed. If the transformation
|
||||
* is changed on an existing text field, it will be applied to the next user edit. The
|
||||
* transformation will not immediately affect the current [state].
|
||||
* @param outputTransformation optional [OutputTransformation] that transforms how the contents
|
||||
* of the text field are presented.
|
||||
* @param scrollState scroll state that manages the horizontal scroll of the input field.
|
||||
* @param shape the shape of the input field.
|
||||
* @param colors [TextFieldColors] that will be used to resolve the colors used for this input
|
||||
* field in different states. See [SearchBarDefaults.inputFieldColors].
|
||||
* @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
|
||||
* emitting [Interaction]s for this input field. You can use this to change the search bar's
|
||||
* appearance or preview the search bar in different states. Note that if `null` is provided,
|
||||
* interactions will still happen internally.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: (String) -> Unit,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
prefix: @Composable (() -> Unit)? = null,
|
||||
suffix: @Composable (() -> Unit)? = null,
|
||||
inputTransformation: InputTransformation? = null,
|
||||
outputTransformation: OutputTransformation? = null,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
shape: Shape = inputFieldShape,
|
||||
colors: TextFieldColors = inputFieldColors(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val textColor = textStyle.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
|
||||
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
.sizeIn(
|
||||
minWidth = SearchBarMinWidth,
|
||||
maxWidth = SearchBarMaxWidth,
|
||||
minHeight = InputFieldHeight,
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { if (it.isFocused) onExpandedChange(true) }
|
||||
.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = mergedTextStyle,
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
onKeyboardAction = { onSearch(state.text.toString()) },
|
||||
interactionSource = interactionSource,
|
||||
inputTransformation = inputTransformation,
|
||||
outputTransformation = outputTransformation,
|
||||
scrollState = scrollState,
|
||||
decorator = TextFieldDefaults.decorator(
|
||||
state = state,
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
outputTransformation = outputTransformation,
|
||||
interactionSource = interactionSource,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading ->
|
||||
{ Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() } }
|
||||
},
|
||||
trailingIcon = trailingIcon?.let { trailing ->
|
||||
{ Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } }
|
||||
},
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
|
||||
container = {
|
||||
val containerColor =
|
||||
animateColorAsState(
|
||||
targetValue = if (focused) colors.focusedContainerColor else colors.unfocusedContainerColor,
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "ContainerColor",
|
||||
)
|
||||
Box(Modifier.textFieldBackground(containerColor::value, shape))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val shouldClearFocus = !expanded && focused
|
||||
LaunchedEffect(expanded) {
|
||||
if (shouldClearFocus) {
|
||||
// Not strictly needed according to the motion spec, but since the animation
|
||||
// already has a delay, this works around b/261632544.
|
||||
delay(AnimationDelayMillis.toLong())
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.textFieldBackground(
|
||||
color: ColorProducer,
|
||||
shape: Shape,
|
||||
) = drawWithCache {
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
onDrawBehind { drawOutline(outline, color = color()) }
|
||||
}
|
||||
|
||||
private val SearchBarMinWidth = 360.dp
|
||||
private val SearchBarMaxWidth = 720.dp
|
||||
private val SearchBarIconOffsetX = 4.dp
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
private const val AnimationDelayMillis = 100
|
||||
@@ -7,11 +7,11 @@ import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
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.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.flingBehavior
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -206,11 +206,10 @@ class DrawerState2(
|
||||
/**
|
||||
* The default [Saver] implementation for [DrawerState2].
|
||||
*/
|
||||
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
|
||||
Saver<DrawerState2, DrawerValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { DrawerState2(it, confirmStateChange) },
|
||||
)
|
||||
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) = Saver<DrawerState2, DrawerValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { DrawerState2(it, confirmStateChange) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +257,7 @@ fun ModalSideDrawer(
|
||||
enableDragFromStartToEnd = drawerState.isOpen,
|
||||
enableDragFromEndToStart = drawerState.isClosed,
|
||||
enabled = gesturesEnabled,
|
||||
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
|
||||
flingBehavior = flingBehavior(
|
||||
state = drawerState.anchoredDraggableState,
|
||||
animationSpec = AnimationSpec,
|
||||
),
|
||||
|
||||
@@ -18,13 +18,13 @@ 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.flingBehavior
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -208,7 +208,7 @@ fun SwipeToDismissBox(
|
||||
enableDragFromStartToEnd = enableDismissFromStartToEnd,
|
||||
enableDragFromEndToStart = enableDismissFromEndToStart,
|
||||
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
|
||||
flingBehavior = flingBehavior(
|
||||
state = state.anchoredDraggableState,
|
||||
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
|
||||
animationSpec = SnapAnimationSpec,
|
||||
|
||||
+18
-9
@@ -69,6 +69,11 @@ interface SystemUiController {
|
||||
isStatusBarVisible = value
|
||||
isNavigationBarVisible = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Property which holds whether the status bar icons + content are 'dark' or not.
|
||||
*/
|
||||
var statusBarDarkContentEnabled: Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,16 +98,14 @@ fun rememberSystemUiController(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun findWindow(): Window? =
|
||||
(LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
?: LocalView.current.context.findWindow()
|
||||
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
|
||||
}
|
||||
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
|
||||
@@ -149,4 +152,10 @@ internal class AndroidSystemUiController(
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var statusBarDarkContentEnabled: Boolean
|
||||
get() = windowInsetsController?.isAppearanceLightStatusBars == true
|
||||
set(value) {
|
||||
windowInsetsController?.isAppearanceLightStatusBars = value
|
||||
}
|
||||
}
|
||||
@@ -635,8 +635,7 @@ data class WebViewError(
|
||||
fun rememberWebViewState(
|
||||
url: String,
|
||||
additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
): WebViewState =
|
||||
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||
): WebViewState = // Rather than using .apply {} here we will recreate the state, this prevents
|
||||
// a recomposition loop when the webview updates the url itself.
|
||||
remember {
|
||||
WebViewState(
|
||||
|
||||
@@ -21,8 +21,6 @@ import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
@@ -30,20 +28,22 @@ import coil3.SingletonImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.ktor2.KtorNetworkFetcherFactory
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.serviceLoaderEnabled
|
||||
import coil3.util.DebugLogger
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.coil.CropBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DetectBorderInterceptor
|
||||
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
|
||||
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
|
||||
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.coil.QrCodeInterceptor
|
||||
import com.hippo.ehviewer.coil.limitConcurrency
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
@@ -51,22 +51,29 @@ import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.Cronet
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.ktor.configureClient
|
||||
import com.hippo.ehviewer.ktor.configureCommon
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.ui.screen.detailCache
|
||||
import com.hippo.ehviewer.ui.tools.dataStateFlow
|
||||
import com.hippo.ehviewer.ui.tools.initSETConnection
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.CrashHandler
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isAtLeastSExtension7
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.AndroidLogcatLogger
|
||||
import logcat.LogPriority
|
||||
@@ -83,6 +90,7 @@ class EhApplication :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
initSETConnection()
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
val mode = Settings.theme
|
||||
@@ -96,22 +104,15 @@ class EhApplication :
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lockObserver)
|
||||
val handler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { t, e ->
|
||||
try {
|
||||
if (Settings.saveCrashLog) {
|
||||
Crash.saveCrashLog(e)
|
||||
}
|
||||
} catch (ignored: Throwable) {
|
||||
}
|
||||
handler?.uncaughtException(t, e)
|
||||
}
|
||||
CrashHandler.install()
|
||||
super.onCreate()
|
||||
System.loadLibrary("ehviewer")
|
||||
lifecycleScope.launchIO {
|
||||
launchUI { FavouriteStatusRouter.collect { (gid, slot) -> detailCache[gid]?.favoriteSlot = slot } }
|
||||
launch { EhTagDatabase }
|
||||
launch { EhDB }
|
||||
dataStateFlow.value
|
||||
launchIO { dataStateFlow.value }
|
||||
launchIO { OSUtils.totalMemory }
|
||||
launch {
|
||||
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
|
||||
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
|
||||
@@ -122,23 +123,13 @@ class EhApplication :
|
||||
FileUtils.cleanupDirectory(AppConfig.externalCrashDir)
|
||||
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
|
||||
}
|
||||
launch {
|
||||
cleanupDownload()
|
||||
}
|
||||
launch { cleanupDownload() }
|
||||
if (Settings.requestNews) {
|
||||
launch {
|
||||
checkDawn()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
cleanObsoleteCache()
|
||||
launch { checkDawn() }
|
||||
}
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat(LogPriority.VERBOSE) { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,15 +158,19 @@ class EhApplication :
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context) = context.imageLoader {
|
||||
interceptorCoroutineContext(Dispatchers.Default)
|
||||
components {
|
||||
serviceLoaderEnabled(false)
|
||||
add(KtorNetworkFetcherFactory { ktorClient })
|
||||
add(NetworkFetcher.Factory({ ktorClient.limitConcurrency() }) { ConnectivityChecker.ONLINE })
|
||||
add(MergeInterceptor)
|
||||
add(DownloadThumbInterceptor)
|
||||
if (isAtLeastO) {
|
||||
add(HardwareBitmapInterceptor)
|
||||
}
|
||||
add(CropBorderInterceptor)
|
||||
add(DetectBorderInterceptor)
|
||||
add(QrCodeInterceptor)
|
||||
add(MapExtraInfoInterceptor)
|
||||
if (isAtLeastP) {
|
||||
add(AnimatedImageDecoder.Factory(false))
|
||||
} else {
|
||||
@@ -201,30 +196,22 @@ class EhApplication :
|
||||
|
||||
companion object {
|
||||
val ktorClient by lazy {
|
||||
HttpClient(Cronet) {
|
||||
engine {
|
||||
client = cronetHttpClient
|
||||
if (isAtLeastSExtension7 && Settings.enableCronet) {
|
||||
HttpClient(Cronet) {
|
||||
engine { configureClient() }
|
||||
configureCommon()
|
||||
}
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
} else {
|
||||
HttpClient(OkHttp) {
|
||||
engine { configureClient() }
|
||||
configureCommon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val noRedirectKtorClient by lazy {
|
||||
HttpClient(ktorClient.engine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val galleryDetailCache by lazy {
|
||||
LruCache<Long, GalleryDetail>(25).also {
|
||||
lifecycleScope.launch {
|
||||
FavouriteStatusRouter.globalFlow.collect { (gid, slot) -> it[gid]?.favoriteSlot = slot }
|
||||
}
|
||||
configureCommon(redirect = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ object EhDB {
|
||||
if (artists.isNotEmpty()) {
|
||||
val dao = db.downloadArtistDao()
|
||||
dao.deleteByGid(gid)
|
||||
dao.insert(artists)
|
||||
dao.insertOrIgnore(artists)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.runtime.Stable
|
||||
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.EhCookieStore
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
@@ -107,6 +106,7 @@ object Settings : DataStorePreferences(null) {
|
||||
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
|
||||
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
|
||||
|
||||
val gallerySite = intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
|
||||
val listMode = intPref("list_mode_2", 0)
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
@@ -114,7 +114,11 @@ object Settings : DataStorePreferences(null) {
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
|
||||
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
|
||||
|
||||
// Enabled by default as some ExHentai thumbnail servers do not support HTTP/2
|
||||
val forceEhThumb = boolPref("force_eh_thumb", true)
|
||||
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
|
||||
val needSignIn = boolPref("need_sign_in", true)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
val gridView = boolPref("grid_view", false)
|
||||
@@ -123,21 +127,20 @@ object Settings : DataStorePreferences(null) {
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val displayName = stringOrNullPref("display_name", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
|
||||
val avatar = stringOrNullPref("avatar", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
var downloadPath by stringOrNullPref("image_path", null)
|
||||
var downloadQuery by stringOrNullPref("image_query", null)
|
||||
var downloadFragment by stringOrNullPref("image_fragment", null)
|
||||
var archivePasswds by stringSetOrNullPref("archive_passwds")
|
||||
var archivePasswds by stringSetPref("archive_passwds")
|
||||
var downloadDelay by intPref("download_delay_3", 1000)
|
||||
var gallerySite by intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
|
||||
var multiThreadDownload by intPref("download_thread_2", 3)
|
||||
var preloadImage by intPref("preload_image_2", 5)
|
||||
var downloadTimeout by intPref("download_timeout", 60)
|
||||
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
|
||||
var thumbResolution by intPref("thumb_resolution_2", 0)
|
||||
var readCacheSize by intPref("read_cache_size_2", 640)
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
@@ -156,12 +159,13 @@ object Settings : DataStorePreferences(null) {
|
||||
var saveParseErrorBody by boolPref("save_parse_error_body", true)
|
||||
var saveCrashLog by boolPref("save_crash_log", true)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var needSignIn by boolPref("need_sign_in", true)
|
||||
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
|
||||
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
var saveAsCbz by boolPref("save_as_cbz", false)
|
||||
var archiveMetadata by boolPref("archive_metadata", true)
|
||||
var enableCronet by boolPref("enable_cronet", true)
|
||||
var desktopSite by boolPref("desktop_site", false)
|
||||
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
@@ -170,7 +174,6 @@ object Settings : DataStorePreferences(null) {
|
||||
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 lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
|
||||
|
||||
@@ -192,9 +195,6 @@ object Settings : DataStorePreferences(null) {
|
||||
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
|
||||
val readerLongTapAction = boolPref("reader_long_tap", true)
|
||||
val pageTransitions = boolPref("pref_enable_transitions_key", true)
|
||||
val readWithVolumeKeys = boolPref("reader_volume_keys", false)
|
||||
val readWithVolumeKeysInterval = intPref("reader_volume_keys_interval", 0)
|
||||
val readWithVolumeKeysInverted = boolPref("reader_volume_keys_inverted", false)
|
||||
val grayScale = boolPref("pref_grayscale", false)
|
||||
val invertedColors = boolPref("pref_inverted_colors", false)
|
||||
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
|
||||
@@ -208,6 +208,10 @@ object Settings : DataStorePreferences(null) {
|
||||
val zoomStart = intPref("pref_zoom_start_key", 1)
|
||||
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
|
||||
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
|
||||
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
|
||||
|
||||
val userAgent
|
||||
get() = if (desktopSite) CHROME_USER_AGENT else CHROME_MOBILE_USER_AGENT
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
@@ -215,6 +219,9 @@ object Settings : DataStorePreferences(null) {
|
||||
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
|
||||
}
|
||||
}
|
||||
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
|
||||
recentDownloadLabel.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
interface Delegate<R> {
|
||||
@@ -247,3 +254,6 @@ object Settings : DataStorePreferences(null) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CHROME_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Safari/537.36"
|
||||
private const val CHROME_MOBILE_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"
|
||||
@@ -15,35 +15,45 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
|
||||
// Normal Preview: https://*.hath.network/cm/[timed token]/[gid]-[index].jpg
|
||||
// ExHentai Large Preview: https://s.exhentai.org/t/***
|
||||
// E-Hentai Large Preview: https://ehgt.org/***
|
||||
// Normal Preview (v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp)
|
||||
// ExHentai Large Preview (v1 Cover): https://s.exhentai.org/t/***
|
||||
// E-Hentai Large Preview (v1 Cover): https://ehgt.org/***
|
||||
// ExHentai v2 Cover: https://s.exhentai.org/**.webp
|
||||
// E-Hentai v2 Cover: https://ehgt.org/**.webp
|
||||
|
||||
private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/t/"
|
||||
private const val NORMAL_PREVIEW_PREFIX = "$"
|
||||
private val NormalPreviewKeyRegex = Regex("/(\\d+-\\d+)\\.jpg$")
|
||||
const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
|
||||
const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
|
||||
const val URL_SIGNATURE_THUMB_NORMAL = ".hath.network/c"
|
||||
private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
|
||||
private val V2PreviewKeyRegex = Regex("/c([m12]/)[^/]+/(\\d+-\\d+)")
|
||||
|
||||
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
|
||||
|
||||
fun getThumbKey(url: String): String = url.removePrefix(thumbPrefix)
|
||||
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_V1_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
|
||||
|
||||
val String.isNormalPreviewKey
|
||||
get() = startsWith(NORMAL_PREVIEW_PREFIX)
|
||||
fun getV2PreviewKey(url: String) = "$".plus(
|
||||
V2PreviewKeyRegex.find(url)?.let {
|
||||
it.groupValues[1] + it.groupValues[2]
|
||||
} ?: url,
|
||||
)
|
||||
|
||||
val GalleryPreview.imageKey
|
||||
get() = if (this is NormalGalleryPreview) {
|
||||
NormalPreviewKeyRegex.find(url)?.run { NORMAL_PREVIEW_PREFIX + groupValues[1] }
|
||||
} else {
|
||||
getThumbKey(url)
|
||||
}
|
||||
val String.isV2PreviewKey
|
||||
get() = startsWith('$')
|
||||
|
||||
val GalleryInfo.thumbUrl
|
||||
get() = thumbPrefix + EhUtils.handleThumbUrlResolution(thumbKey!!)
|
||||
get() = thumbKey!!.let {
|
||||
if (it.endsWith("webp")) {
|
||||
if (useExThumb) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
} else {
|
||||
v1ThumbPrefix
|
||||
} + it
|
||||
}
|
||||
|
||||
private val thumbPrefix
|
||||
get() = if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
val v1ThumbPrefix
|
||||
get() = if (useExThumb) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
|
||||
|
||||
private val useExThumb
|
||||
get() = EhUtils.isExHentai && !Settings.forceEhThumb.value
|
||||
@@ -33,6 +33,8 @@ object EhCookieStore : CookiesStorage {
|
||||
)
|
||||
}
|
||||
|
||||
fun getUserId() = getCookies(EhUrl.HOST_E)?.get(KEY_IPB_MEMBER_ID)
|
||||
|
||||
fun getIdentityCookies(): List<Pair<String, String?>> {
|
||||
val eCookies = getCookies(EhUrl.HOST_E)
|
||||
val exCookies = getCookies(EhUrl.HOST_EX)
|
||||
@@ -48,8 +50,6 @@ object EhCookieStore : CookiesStorage {
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
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) {
|
||||
if (cookie.name != KEY_UTMP_NAME) {
|
||||
|
||||
@@ -64,6 +64,7 @@ import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
@@ -131,10 +132,10 @@ fun rethrowExactly(status: HttpStatusCode, body: Either<String, ByteBuffer>, e:
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
when (val message = e.cause?.message) {
|
||||
"No hits found!" -> throw NoHitsFoundException()
|
||||
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
"Not logged in!" -> throw NotLoggedInException()
|
||||
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
|
||||
"0" -> throw NoHitsFoundException()
|
||||
"1" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
"2" -> throw NotLoggedInException()
|
||||
is String if message.startsWith('3') -> throw EhException(message.drop(1))
|
||||
}
|
||||
if (Settings.saveParseErrorBody) body.saveParseError(e)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
@@ -179,6 +180,11 @@ object EhEngine {
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getTorrentKey() = EhCookieStore.getUserId()?.let { id ->
|
||||
val key = ehRequest(EhUrl.URL_HOME).fetchUsingAsText(HomeParser::parseTorrentKey)
|
||||
"${id}x$key"
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -272,7 +278,7 @@ object EhEngine {
|
||||
ehRequest(location, url).fetchUsingAsText {
|
||||
val document = Jsoup.parse(this)
|
||||
val elements = document.select("#chd + p")
|
||||
if (elements.size > 0) {
|
||||
if (elements.isNotEmpty()) {
|
||||
throw EhException(elements[0].text())
|
||||
}
|
||||
GalleryDetailParser.parseComments(document)
|
||||
@@ -297,12 +303,11 @@ object EhEngine {
|
||||
}.executeSafely { }
|
||||
}
|
||||
|
||||
suspend fun getFavoriteNote(gid: Long, token: String) =
|
||||
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(FavoritesParser::parseNote)
|
||||
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)
|
||||
suspend fun downloadArchive(gid: Long, token: String, res: String, isHAtH: Boolean): String? {
|
||||
val url = EhUrl.getDownloadArchive(gid, token)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
val request = ehRequest(url, referer, EhUrl.origin) {
|
||||
formBody {
|
||||
@@ -333,7 +338,7 @@ object EhEngine {
|
||||
|
||||
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
|
||||
formBody {
|
||||
append("reset_imagelimit", "Reset Limit")
|
||||
append("reset_imagelimit", "Reset Quota")
|
||||
}
|
||||
}.fetchUsingAsText(HomeParser::parseResetLimits)
|
||||
|
||||
@@ -378,54 +383,51 @@ object EhEngine {
|
||||
}
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) =
|
||||
galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
}
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) = galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
}
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult =
|
||||
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "votecomment")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", requireNotNull(apiKey))
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "votecomment")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", requireNotNull(apiKey))
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
|
||||
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) =
|
||||
ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "taggallery")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", requireNotNull(apiKey))
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.fetchUsingAsText(VoteTagParser::parse)
|
||||
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "taggallery")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", requireNotNull(apiKey))
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.fetchUsingAsText(VoteTagParser::parse)
|
||||
|
||||
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
*/
|
||||
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
|
||||
import com.hippo.ehviewer.util.bodyAsUtf8Text
|
||||
import com.hippo.ehviewer.util.ensureSuccess
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
@@ -30,11 +28,9 @@ import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.content.TextContent
|
||||
import io.ktor.http.userAgent
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArrayBuilder
|
||||
@@ -56,17 +52,9 @@ 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/${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"
|
||||
|
||||
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
|
||||
method = HttpMethod.Get
|
||||
header("Referer", referer)
|
||||
header("Origin", origin)
|
||||
userAgent(Settings.userAgent)
|
||||
header(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
|
||||
}
|
||||
|
||||
suspend inline fun ehRequest(
|
||||
|
||||
@@ -32,7 +32,6 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -104,7 +103,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
val prefix = keyword.dropLast(mKeyword.length)
|
||||
val namespace = mKeyword.substringBefore(':')
|
||||
val tagKeyword = mKeyword.drop(namespace.length + 1)
|
||||
val namespacePrefix = TagNamespace(namespace).toPrefix() ?: namespace
|
||||
val namespacePrefix = TagNamespace.from(namespace)?.prefix ?: namespace
|
||||
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
|
||||
tags?.let {
|
||||
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
|
||||
@@ -125,8 +124,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
private fun String.removeSpace(): String = replace(" ", "")
|
||||
|
||||
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
|
||||
removeSpace().contains(other.removeSpace(), ignoreCase)
|
||||
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = removeSpace().contains(other.removeSpace(), ignoreCase)
|
||||
|
||||
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
|
||||
.takeIf { it.size == 4 }
|
||||
@@ -134,7 +132,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
|
||||
|
||||
private fun getFileContent(file: File): String? = runCatching {
|
||||
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
|
||||
file.source().buffer().use { it.readUtf8() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
|
||||
|
||||
@@ -53,66 +53,65 @@ object EhUrl {
|
||||
const val ORIGIN_E = REFERER_E
|
||||
|
||||
val domain: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> DOMAIN_E
|
||||
SITE_EX -> DOMAIN_EX
|
||||
else -> DOMAIN_E
|
||||
}
|
||||
|
||||
val apiUrl: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
|
||||
val referer: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> REFERER_E
|
||||
SITE_EX -> REFERER_EX
|
||||
else -> REFERER_E
|
||||
}
|
||||
|
||||
val origin: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> ORIGIN_E
|
||||
SITE_EX -> ORIGIN_EX
|
||||
else -> ORIGIN_E
|
||||
}
|
||||
|
||||
val uConfigUrl: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> URL_UCONFIG_E
|
||||
SITE_EX -> URL_UCONFIG_EX
|
||||
else -> URL_UCONFIG_E
|
||||
}
|
||||
|
||||
val myTagsUrl: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> URL_MY_TAGS_E
|
||||
SITE_EX -> URL_MY_TAGS_EX
|
||||
else -> URL_MY_TAGS_E
|
||||
}
|
||||
|
||||
val popularUrl: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> URL_POPULAR_E
|
||||
SITE_EX -> URL_POPULAR_EX
|
||||
else -> URL_POPULAR_E
|
||||
}
|
||||
|
||||
val imageSearchUrl: String
|
||||
get() = when (Settings.gallerySite) {
|
||||
get() = when (Settings.gallerySite.value) {
|
||||
SITE_E -> URL_IMAGE_SEARCH_E
|
||||
SITE_EX -> URL_IMAGE_SEARCH_EX
|
||||
else -> URL_IMAGE_SEARCH_E
|
||||
}
|
||||
|
||||
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) =
|
||||
ehUrl(listOf("g", "$gid", token)) {
|
||||
if (index != 0) addQueryParameter("p", "$index")
|
||||
if (allComment) addQueryParameter("hc", "1")
|
||||
}.buildString()
|
||||
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) = ehUrl(listOf("g", "$gid", token)) {
|
||||
if (index != 0) addQueryParameter("p", "$index")
|
||||
if (allComment) addQueryParameter("hc", "1")
|
||||
}.buildString()
|
||||
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", "$gid", token)).buildString()
|
||||
|
||||
@@ -126,13 +125,17 @@ object EhUrl {
|
||||
addQueryParameter("act", "addfav")
|
||||
}.buildString()
|
||||
|
||||
fun getDownloadArchive(gid: Long, token: String, or: String) = ehUrl("archiver.php") {
|
||||
fun getDownloadArchive(gid: Long, token: String) = ehUrl("archiver.php") {
|
||||
addQueryParameter("gid", "$gid")
|
||||
addQueryParameter("token", token)
|
||||
addQueryParameter("or", or)
|
||||
}.buildString()
|
||||
|
||||
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
|
||||
fun getTrackerUrl(gid: Long, key: String?) = ehUrl(
|
||||
path = listOfNotNull("$gid", key, "announce"),
|
||||
host = "ehtracker.org",
|
||||
).buildString()
|
||||
}
|
||||
|
||||
inline fun ehUrl(path: String? = null, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
|
||||
@@ -20,11 +20,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import arrow.core.memoize
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.materialkolor.hct.Hct
|
||||
import com.materialkolor.ktx.from
|
||||
import com.materialkolor.ktx.toColor
|
||||
import kotlin.math.abs
|
||||
|
||||
object EhUtils {
|
||||
@@ -84,7 +85,7 @@ object EhUtils {
|
||||
private val CATEGORY_STRINGS = CATEGORY_VALUES.entries.map { (k, v) -> v to k }
|
||||
|
||||
val isExHentai: Boolean
|
||||
get() = Settings.gallerySite == EhUrl.SITE_EX
|
||||
get() = Settings.gallerySite.value == EhUrl.SITE_EX
|
||||
|
||||
fun getCategory(type: String?): Int {
|
||||
for (entry in CATEGORY_STRINGS) {
|
||||
@@ -99,51 +100,54 @@ object EhUtils {
|
||||
|
||||
fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
|
||||
|
||||
private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
|
||||
fun invCategory(category: Int): Int = category.inv() and ALL_CATEGORY
|
||||
|
||||
private fun sanitizeDegreesDouble(degrees: Float): Float {
|
||||
val deg = degrees % 360.0f
|
||||
return if (deg < 0) deg + 360.0f else deg
|
||||
private fun differenceDegrees(a: Double, b: Double): Double = 180.0 - abs(abs(a - b) - 180.0)
|
||||
|
||||
private fun sanitizeDegreesDouble(degrees: Double): Double {
|
||||
val deg = degrees % 360.0
|
||||
return if (deg < 0) deg + 360.0 else deg
|
||||
}
|
||||
|
||||
private fun rotationDirection(from: Float, to: Float): Float {
|
||||
private fun rotationDirection(from: Double, to: Double): Double {
|
||||
val increasingDifference = sanitizeDegreesDouble(to - from)
|
||||
return if (increasingDifference <= 180.0) 1.0f else -1.0f
|
||||
return if (increasingDifference <= 180.0) 1.0 else -1.0
|
||||
}
|
||||
|
||||
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
|
||||
val fromHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(src, this) }
|
||||
val toHct = FloatArray(3).apply { ColorUtils.colorToM3HCT(primaryContainer, this) }
|
||||
val differenceDegrees = differenceDegrees(fromHct[0], toHct[0])
|
||||
val rotationDegrees = minOf(differenceDegrees * 0.5f, 15.0f)
|
||||
val outputHue = sanitizeDegreesDouble(fromHct[0] + rotationDegrees * rotationDirection(fromHct[0], toHct[0]))
|
||||
ColorUtils.M3HCTToColor(outputHue, toHct[1], toHct[2])
|
||||
val harmonizeWithRole = { primaryContainer: Color, src: Color ->
|
||||
val fromHct = Hct.from(src)
|
||||
val toHct = Hct.from(primaryContainer)
|
||||
val differenceDegrees = differenceDegrees(toHct.hue, fromHct.hue)
|
||||
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
|
||||
val outputHue = sanitizeDegreesDouble(fromHct.hue + rotationDegrees * rotationDirection(fromHct.hue, toHct.hue))
|
||||
Hct.from(outputHue, toHct.chroma, toHct.tone).toColor()
|
||||
}.memoize()
|
||||
|
||||
@Stable
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun getCategoryColor(category: Int): Color {
|
||||
val primary = when (category) {
|
||||
DOUJINSHI -> BG_COLOR_DOUJINSHI
|
||||
MANGA -> BG_COLOR_MANGA
|
||||
ARTIST_CG -> BG_COLOR_ARTIST_CG
|
||||
GAME_CG -> BG_COLOR_GAME_CG
|
||||
WESTERN -> BG_COLOR_WESTERN
|
||||
NON_H -> BG_COLOR_NON_H
|
||||
IMAGE_SET -> BG_COLOR_IMAGE_SET
|
||||
COSPLAY -> BG_COLOR_COSPLAY
|
||||
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
|
||||
MISC -> BG_COLOR_MISC
|
||||
else -> BG_COLOR_UNKNOWN
|
||||
}.toInt()
|
||||
val color = if (Settings.harmonizeCategoryColor) {
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
|
||||
val primary = Color(
|
||||
when (category) {
|
||||
DOUJINSHI -> BG_COLOR_DOUJINSHI
|
||||
MANGA -> BG_COLOR_MANGA
|
||||
ARTIST_CG -> BG_COLOR_ARTIST_CG
|
||||
GAME_CG -> BG_COLOR_GAME_CG
|
||||
WESTERN -> BG_COLOR_WESTERN
|
||||
NON_H -> BG_COLOR_NON_H
|
||||
IMAGE_SET -> BG_COLOR_IMAGE_SET
|
||||
COSPLAY -> BG_COLOR_COSPLAY
|
||||
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
|
||||
MISC -> BG_COLOR_MISC
|
||||
else -> BG_COLOR_UNKNOWN
|
||||
}.toInt(),
|
||||
)
|
||||
return if (Settings.harmonizeCategoryColor) {
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
harmonizeWithRole(primaryContainer, primary)
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
val categoryTextColor = Color(0xffe6e0e9)
|
||||
@@ -152,8 +156,8 @@ object EhUtils {
|
||||
EhCookieStore.removeAllCookies()
|
||||
Settings.displayName.value = null
|
||||
Settings.hasSignedIn.value = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
Settings.needSignIn = true
|
||||
Settings.gallerySite.value = EhUrl.SITE_E
|
||||
Settings.needSignIn.value = true
|
||||
}
|
||||
|
||||
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
|
||||
@@ -171,20 +175,4 @@ object EhUtils {
|
||||
// TODO But not sure every '|' means that
|
||||
return title.substringBeforeLast('|').trim().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun handleThumbUrlResolution(url: String): String {
|
||||
val resolution = when (Settings.thumbResolution) {
|
||||
0 -> return url
|
||||
1 -> "250"
|
||||
2 -> "300"
|
||||
else -> return url
|
||||
}
|
||||
val index1 = url.lastIndexOf('_')
|
||||
val index2 = url.lastIndexOf('.')
|
||||
return if (index1 >= 0 && index2 >= 0 && index1 < index2) {
|
||||
url.substring(0, index1 + 1) + resolution + url.substring(index2)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,10 @@ import kotlinx.serialization.Serializable
|
||||
data class BaseGalleryInfo(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "GID")
|
||||
override var gid: Long,
|
||||
override var gid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = "TOKEN")
|
||||
override var token: String,
|
||||
override var token: String = "",
|
||||
|
||||
@ColumnInfo(name = "TITLE")
|
||||
override var title: String? = null,
|
||||
@@ -88,10 +88,7 @@ data class BaseGalleryInfo(
|
||||
|
||||
@Ignore
|
||||
override var favoriteNote: String? = null,
|
||||
) : GalleryInfo,
|
||||
Parcelable {
|
||||
constructor() : this(0, "")
|
||||
}
|
||||
) : GalleryInfo, Parcelable
|
||||
|
||||
class SimpleTagsConverter {
|
||||
@TypeConverter
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.ui.util.fastFlatMap
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@@ -51,15 +52,14 @@ data class GalleryDetail(
|
||||
var size: String? = null,
|
||||
var favoriteCount: Int = 0,
|
||||
var ratingCount: Int = 0,
|
||||
val tags: List<GalleryTagGroup>,
|
||||
val tagGroups: List<GalleryTagGroup>,
|
||||
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]
|
||||
simpleTags = tags.flatten()
|
||||
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags)
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,27 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.v1ThumbPrefix
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface GalleryPreview : Parcelable {
|
||||
sealed interface GalleryPreview : Parcelable {
|
||||
val url: String
|
||||
val imageKey: String
|
||||
val position: Int
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class LargeGalleryPreview(
|
||||
override val url: String,
|
||||
data class V1GalleryPreview(
|
||||
override val imageKey: String,
|
||||
override val position: Int,
|
||||
) : GalleryPreview
|
||||
) : GalleryPreview {
|
||||
override val url get() = v1ThumbPrefix + imageKey
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class NormalGalleryPreview(
|
||||
data class V2GalleryPreview(
|
||||
override val url: String,
|
||||
override val imageKey: String,
|
||||
override val position: Int,
|
||||
val offsetX: Int,
|
||||
val clipWidth: Int,
|
||||
|
||||
@@ -20,7 +20,6 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryTagGroup(
|
||||
val groupName: String,
|
||||
private val tagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable,
|
||||
MutableList<String> by tagList
|
||||
val nameSpace: TagNamespace,
|
||||
val tags: List<String>,
|
||||
) : Parcelable
|
||||
@@ -16,7 +16,6 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.IntDef
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
@@ -25,20 +24,18 @@ import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.ui.main.AdvanceTable
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.net.URLDecoder
|
||||
import io.ktor.http.Parameters
|
||||
import kotlin.text.toIntOrNull
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class ListUrlBuilder(
|
||||
@get:Mode @Mode
|
||||
var mode: Int = MODE_NORMAL,
|
||||
private var mPrev: String? = null,
|
||||
var mNext: String? = null,
|
||||
private var prev: String? = null,
|
||||
var next: String? = null,
|
||||
// Reset to null after initial loading
|
||||
var mJumpTo: String? = null,
|
||||
private var mRange: Int = 0,
|
||||
var jumpTo: String? = null,
|
||||
private var range: Int = 0,
|
||||
var category: Int = EhUtils.NONE,
|
||||
private var mKeyword: String? = null,
|
||||
var hash: String? = null,
|
||||
@@ -47,24 +44,23 @@ data class ListUrlBuilder(
|
||||
var minRating: Int = -1,
|
||||
var pageFrom: Int = -1,
|
||||
var pageTo: Int = -1,
|
||||
var imagePath: String? = null,
|
||||
) : Parcelable {
|
||||
|
||||
fun setIndex(index: String, isNext: Boolean = true) {
|
||||
mNext = index.takeIf { isNext }
|
||||
mPrev = index.takeUnless { isNext }
|
||||
mRange = 0
|
||||
next = index.takeIf { isNext }
|
||||
prev = index.takeUnless { isNext }
|
||||
range = 0
|
||||
}
|
||||
|
||||
fun setJumpTo(jumpTo: Int) {
|
||||
mJumpTo = jumpTo.takeUnless { it == 0 }?.toString()
|
||||
fun setJumpTo(to: Int) {
|
||||
jumpTo = to.takeUnless { it == 0 }?.toString()
|
||||
}
|
||||
|
||||
fun setRange(range: Int) {
|
||||
mRange = range
|
||||
mPrev = null
|
||||
mNext = null
|
||||
mJumpTo = null
|
||||
fun setRange(to: Int) {
|
||||
range = to
|
||||
prev = null
|
||||
next = null
|
||||
jumpTo = null
|
||||
}
|
||||
|
||||
var keyword: String?
|
||||
@@ -73,18 +69,18 @@ data class ListUrlBuilder(
|
||||
mKeyword = keyword
|
||||
}
|
||||
|
||||
constructor(q: QuickSearch) : this() {
|
||||
mode = q.mode
|
||||
this.category = q.category
|
||||
mKeyword = q.keyword
|
||||
advanceSearch = q.advanceSearch
|
||||
minRating = q.minRating
|
||||
pageFrom = q.pageFrom
|
||||
pageTo = q.pageTo
|
||||
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
|
||||
}
|
||||
constructor(q: QuickSearch) : this(
|
||||
mode = q.mode,
|
||||
category = q.category,
|
||||
mKeyword = q.keyword,
|
||||
advanceSearch = q.advanceSearch,
|
||||
minRating = q.minRating,
|
||||
pageFrom = q.pageFrom,
|
||||
pageTo = q.pageTo,
|
||||
next = q.name.substringAfterLast('@', "").ifEmpty { null },
|
||||
)
|
||||
|
||||
fun toQuickSearch(name: String): QuickSearch = QuickSearch(
|
||||
fun toQuickSearch(name: String) = QuickSearch(
|
||||
name = name,
|
||||
mode = mode,
|
||||
category = category,
|
||||
@@ -121,140 +117,34 @@ data class ListUrlBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query xxx=yyy&mmm=nnn
|
||||
*/
|
||||
constructor(query: String?) : this() {
|
||||
// TODO page
|
||||
if (query.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
val queries = query.split('&')
|
||||
var category = 0
|
||||
var keyword: String? = null
|
||||
var enableAdvanceSearch = false
|
||||
var advanceSearch = 0
|
||||
var enableMinRating = false
|
||||
var minRating = -1
|
||||
var enablePage = false
|
||||
var pageFrom = -1
|
||||
var pageTo = -1
|
||||
for (str in queries) {
|
||||
val index = str.indexOf('=')
|
||||
if (index < 0) {
|
||||
continue
|
||||
constructor(params: Parameters) : this(
|
||||
prev = params["prev"],
|
||||
next = params["next"],
|
||||
jumpTo = params["seek"],
|
||||
range = params["range"]?.toIntOrNull() ?: 0,
|
||||
category = params["f_cats"]?.toIntOrNull()?.let(EhUtils::invCategory) ?: EhUtils.NONE,
|
||||
mKeyword = params["f_search"],
|
||||
) {
|
||||
if (params["advsearch"] == "1") {
|
||||
advanceSearch = 0
|
||||
if (params["f_sh"] == "on") {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SH
|
||||
}
|
||||
val key = str.substring(0, index)
|
||||
val value = str.substring(index + 1)
|
||||
when (key) {
|
||||
"f_cats" -> {
|
||||
val cats = value.toIntOrDefault(EhUtils.ALL_CATEGORY)
|
||||
category = category or (cats.inv() and EhUtils.ALL_CATEGORY)
|
||||
}
|
||||
|
||||
"f_doujinshi" -> if ("1" == value) {
|
||||
category = category or EhUtils.DOUJINSHI
|
||||
}
|
||||
|
||||
"f_manga" -> if ("1" == value) {
|
||||
category = category or EhUtils.MANGA
|
||||
}
|
||||
|
||||
"f_artistcg" -> if ("1" == value) {
|
||||
category = category or EhUtils.ARTIST_CG
|
||||
}
|
||||
|
||||
"f_gamecg" -> if ("1" == value) {
|
||||
category = category or EhUtils.GAME_CG
|
||||
}
|
||||
|
||||
"f_western" -> if ("1" == value) {
|
||||
category = category or EhUtils.WESTERN
|
||||
}
|
||||
|
||||
"f_non-h" -> if ("1" == value) {
|
||||
category = category or EhUtils.NON_H
|
||||
}
|
||||
|
||||
"f_imageset" -> if ("1" == value) {
|
||||
category = category or EhUtils.IMAGE_SET
|
||||
}
|
||||
|
||||
"f_cosplay" -> if ("1" == value) {
|
||||
category = category or EhUtils.COSPLAY
|
||||
}
|
||||
|
||||
"f_asianporn" -> if ("1" == value) {
|
||||
category = category or EhUtils.ASIAN_PORN
|
||||
}
|
||||
|
||||
"f_misc" -> if ("1" == value) {
|
||||
category = category or EhUtils.MISC
|
||||
}
|
||||
|
||||
"f_search" -> try {
|
||||
keyword = URLDecoder.decode(value, "utf-8")
|
||||
} catch (e: UnsupportedEncodingException) {
|
||||
// Ignore
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
|
||||
"advsearch" -> if ("1" == value) {
|
||||
enableAdvanceSearch = true
|
||||
}
|
||||
|
||||
"f_sh" -> if ("on" == value) {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SH
|
||||
}
|
||||
|
||||
"f_sto" -> if ("on" == value) {
|
||||
advanceSearch = advanceSearch or AdvanceTable.STO
|
||||
}
|
||||
|
||||
"f_sfl" -> if ("on" == value) {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFL
|
||||
}
|
||||
|
||||
"f_sfu" -> if ("on" == value) {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFU
|
||||
}
|
||||
|
||||
"f_sft" -> if ("on" == value) {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFT
|
||||
}
|
||||
|
||||
"f_sr" -> if ("on" == value) {
|
||||
enableMinRating = true
|
||||
}
|
||||
|
||||
"f_srdd" -> minRating = value.toIntOrDefault(-1)
|
||||
"f_sp" -> if ("on" == value) {
|
||||
enablePage = true
|
||||
}
|
||||
|
||||
"f_spf" -> pageFrom = value.toIntOrDefault(-1)
|
||||
"f_spt" -> pageTo = value.toIntOrDefault(-1)
|
||||
"f_shash" -> hash = value
|
||||
if (params["f_sto"] == "on") {
|
||||
advanceSearch = advanceSearch or AdvanceTable.STO
|
||||
}
|
||||
}
|
||||
this.category = category
|
||||
mKeyword = keyword
|
||||
if (enableAdvanceSearch) {
|
||||
this.advanceSearch = advanceSearch
|
||||
if (enableMinRating) {
|
||||
this.minRating = minRating
|
||||
} else {
|
||||
this.minRating = -1
|
||||
if (params["f_sfl"] == "on") {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFL
|
||||
}
|
||||
if (enablePage) {
|
||||
this.pageFrom = pageFrom
|
||||
this.pageTo = pageTo
|
||||
} else {
|
||||
this.pageFrom = -1
|
||||
this.pageTo = -1
|
||||
if (params["f_sfu"] == "on") {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFU
|
||||
}
|
||||
} else {
|
||||
this.advanceSearch = -1
|
||||
if (params["f_sft"] == "on") {
|
||||
advanceSearch = advanceSearch or AdvanceTable.SFT
|
||||
}
|
||||
minRating = params["f_srdd"]?.toIntOrNull() ?: -1
|
||||
pageFrom = params["f_spf"]?.toIntOrNull() ?: -1
|
||||
pageTo = params["f_spt"]?.toIntOrNull() ?: -1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +152,7 @@ data class ListUrlBuilder(
|
||||
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())
|
||||
addQueryParameter("f_cats", "${EhUtils.invCategory(category)}")
|
||||
}
|
||||
val query = mKeyword?.let { keyword ->
|
||||
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
|
||||
@@ -277,10 +167,10 @@ data class ListUrlBuilder(
|
||||
}
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
|
||||
addQueryParameterIfNotBlank("prev", prev)
|
||||
addQueryParameterIfNotBlank("next", next)
|
||||
addQueryParameterIfNotBlank("seek", jumpTo)
|
||||
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
|
||||
// Advance search
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
@@ -301,12 +191,10 @@ data class ListUrlBuilder(
|
||||
}
|
||||
// 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(),
|
||||
@@ -322,10 +210,10 @@ data class ListUrlBuilder(
|
||||
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())
|
||||
addQueryParameterIfNotBlank("prev", prev)
|
||||
addQueryParameterIfNotBlank("next", next)
|
||||
addQueryParameterIfNotBlank("seek", jumpTo)
|
||||
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
|
||||
}.buildString()
|
||||
}
|
||||
|
||||
@@ -333,27 +221,14 @@ data class ListUrlBuilder(
|
||||
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()
|
||||
}
|
||||
MODE_TOPLIST -> ehUrl("toplist.php", EhUrl.DOMAIN_E) {
|
||||
addQueryParameter("tl", requireNotNull(mKeyword))
|
||||
addQueryParameterIfNotBlank("p", jumpTo)
|
||||
}.buildString()
|
||||
|
||||
else -> throw IllegalStateException("Unexpected value: $mode")
|
||||
}
|
||||
|
||||
@IntDef(
|
||||
MODE_NORMAL,
|
||||
MODE_UPLOADER,
|
||||
MODE_TAG,
|
||||
MODE_WHATS_HOT,
|
||||
MODE_IMAGE_SEARCH,
|
||||
MODE_SUBSCRIPTION,
|
||||
MODE_TOPLIST,
|
||||
)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
private annotation class Mode
|
||||
companion object {
|
||||
const val MODE_NORMAL = 0x0
|
||||
const val MODE_UPLOADER = 0x1
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
@JvmInline
|
||||
value class TagNamespace(val value: String) {
|
||||
fun toPrefix(): String? = prefixMap[this]
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
sealed class TagNamespace(val value: String, val prefix: String?) : Parcelable {
|
||||
object Artist : TagNamespace("artist", "a")
|
||||
object Cosplayer : TagNamespace("cosplayer", "cos")
|
||||
object Character : TagNamespace("character", "c")
|
||||
object Female : TagNamespace("female", "f")
|
||||
object Group : TagNamespace("group", "g")
|
||||
object Language : TagNamespace("language", "l")
|
||||
object Male : TagNamespace("male", "m")
|
||||
object Mixed : TagNamespace("mixed", "x")
|
||||
object Other : TagNamespace("other", "o")
|
||||
object Parody : TagNamespace("parody", "p")
|
||||
object Reclass : TagNamespace("reclass", "r")
|
||||
object Temp : TagNamespace("temp", null)
|
||||
|
||||
companion object {
|
||||
val Artist = TagNamespace("artist")
|
||||
val Cosplayer = TagNamespace("cosplayer")
|
||||
val Character = TagNamespace("character")
|
||||
val Female = TagNamespace("female")
|
||||
val Group = TagNamespace("group")
|
||||
val Language = TagNamespace("language")
|
||||
val Male = TagNamespace("male")
|
||||
val Mixed = TagNamespace("mixed")
|
||||
val Other = TagNamespace("other")
|
||||
val Parody = TagNamespace("parody")
|
||||
val Reclass = TagNamespace("reclass")
|
||||
private val prefixMap = mapOf(
|
||||
Artist to "a",
|
||||
Cosplayer to "cos",
|
||||
Character to "c",
|
||||
Female to "f",
|
||||
Group to "g",
|
||||
Language to "l",
|
||||
Male to "m",
|
||||
Mixed to "x",
|
||||
Other to "o",
|
||||
Parody to "p",
|
||||
Reclass to "r",
|
||||
)
|
||||
fun from(value: String) = when (value) {
|
||||
Artist.value -> Artist
|
||||
Cosplayer.value -> Cosplayer
|
||||
Character.value -> Character
|
||||
Female.value -> Female
|
||||
Group.value -> Group
|
||||
Language.value -> Language
|
||||
Male.value -> Male
|
||||
Mixed.value -> Mixed
|
||||
Other.value -> Other
|
||||
Parody.value -> Parody
|
||||
Reclass.value -> Reclass
|
||||
Temp.value -> Temp
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,6 @@ object ArchiveParser {
|
||||
Regex("<strong>(.*)</strong>.*<a href=\"([^\"]*)\">Click Here To Start Downloading</a>")
|
||||
private val PATTERN_CURRENT_FUNDS =
|
||||
Regex("<p>([\\d,]+) GP \\[[^]]*] ([\\d,]+) Credits \\[[^]]*]</p>")
|
||||
private val PATTERN_HATH_FORM =
|
||||
Regex("<form id=\"hathdl_form\" action=\"[^\"]*?or=([^=\"]*?)\" method=\"post\">")
|
||||
private val PATTERN_HATH_ARCHIVE =
|
||||
Regex("<p><a href=\"[^\"]*\" onclick=\"return do_hathdl\\('([0-9]+|org)'\\)\">([^<]+)</a></p>\\s*<p>([\\w. ]+)</p>\\s*<p>([\\w. ]+)</p>")
|
||||
private const val ERROR_NEED_HATH_CLIENT =
|
||||
@@ -35,8 +33,6 @@ object ArchiveParser {
|
||||
"You do not have enough funds to download this archive."
|
||||
|
||||
fun parse(body: String, maybeFunds: Funds?): Result {
|
||||
val m = PATTERN_HATH_FORM.find(body)!!
|
||||
val paramOr = m.groupValues[1]
|
||||
val archiveList = ArrayList<Archive>()
|
||||
Jsoup.parse(body).select("#db>div>div").forEach { element ->
|
||||
if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) {
|
||||
@@ -60,11 +56,11 @@ object ArchiveParser {
|
||||
archiveList.add(item)
|
||||
}
|
||||
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
|
||||
val fundsGP = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsGP = ParserUtils.parseInt(get(1), 0) / 1000
|
||||
val fundsC = ParserUtils.parseInt(get(2), 0)
|
||||
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
|
||||
Funds(fundsGP, fundsC)
|
||||
}
|
||||
return Result(paramOr, archiveList, funds)
|
||||
return Result(archiveList, funds)
|
||||
}
|
||||
|
||||
fun parseArchiveUrl(body: String): String? {
|
||||
@@ -78,7 +74,7 @@ object ArchiveParser {
|
||||
// TODO: Check more errors
|
||||
}
|
||||
|
||||
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
|
||||
data class Result(val archiveList: List<Archive>, val funds: Funds)
|
||||
}
|
||||
|
||||
class Archive(
|
||||
|
||||
@@ -15,19 +15,19 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object ForumsParser {
|
||||
fun parse(body: String): String = runCatching {
|
||||
fun parse(body: String): String = Either.catch {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -15,28 +15,29 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.unzip
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.EhUtils.getCategory
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.GalleryCommentList
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.GalleryTagGroup
|
||||
import com.hippo.ehviewer.client.data.LargeGalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.client.data.V1GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.V2GalleryPreview
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.OffensiveException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.exception.PiningException
|
||||
import com.hippo.ehviewer.client.getThumbKey
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.ehviewer.client.getV2PreviewKey
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toFloatOrDefault
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
@@ -50,7 +51,6 @@ import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.Node
|
||||
import org.jsoup.select.Elements
|
||||
import org.jsoup.select.NodeTraversor
|
||||
import org.jsoup.select.NodeVisitor
|
||||
|
||||
@@ -61,7 +61,7 @@ object GalleryDetailParser {
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
private val PATTERN_TORRENT =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download \\((\\d+)\\)</a>")
|
||||
private val PATTERN_ARCHIVE =
|
||||
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
|
||||
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
|
||||
@@ -69,14 +69,12 @@ object GalleryDetailParser {
|
||||
Regex("<tr><td[^<>]*>Length:</td><td[^<>]*>([\\d,]+) pages</td></tr>")
|
||||
private val PATTERN_PREVIEW_PAGES =
|
||||
Regex("<td[^>]+><a[^>]+>([\\d,]+)</a></td><td[^>]+>(?:<a[^>]+>)?>(?:</a>)?</td>")
|
||||
private val PATTERN_NORMAL_PREVIEW =
|
||||
Regex("<div class=\"gdtm\"[^<>]*><div[^<>]*width:(\\d+)[^<>]*height:(\\d+)[^<>]*\\((.+?)\\)[^<>]*-(\\d+)px[^<>]*><a[^<>]*href=\"(.+?)\"[^<>]*><img alt=\"([\\d,]+)\"")
|
||||
private val PATTERN_LARGE_PREVIEW =
|
||||
Regex("<div class=\"gdtl\".+?<a href=\"(.+?)\"><img alt=\"([\\d,]+)\".+?src=\"(.+?)\"")
|
||||
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
|
||||
private val PATTERN_FAVORITE_SLOT =
|
||||
Regex("/fav.png\\); background-position:0px -(\\d+)px")
|
||||
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
|
||||
private val PreviewRegex =
|
||||
Regex("<a href=\"([^\"]+)\">(?:<div>)?<div title=\"Page (\\d+)(?:[^\"]+\"){2}\\D+(\\d+)\\D+(\\d+)[^(]+\\(([^)]+)\\)(?: -(\\d+))?")
|
||||
|
||||
// dd MMMM yyyy, HH:mm
|
||||
private val WEB_COMMENT_DATE_FORMAT = LocalDateTime.Format {
|
||||
@@ -106,7 +104,7 @@ object GalleryDetailParser {
|
||||
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
|
||||
val document = Jsoup.parse(body)
|
||||
val galleryDetail = GalleryDetail(
|
||||
tags = parseTagGroups(document),
|
||||
tagGroups = parseTagGroups(document),
|
||||
comments = parseComments(document),
|
||||
previewPages = parsePreviewPages(body),
|
||||
previewList = parsePreviewList(body).first,
|
||||
@@ -135,7 +133,7 @@ object GalleryDetailParser {
|
||||
PATTERN_ARCHIVE.find(body)?.run {
|
||||
gd.archiveUrl = groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
try {
|
||||
Either.catch {
|
||||
val gm = d.getElementsByClass("gm")[0]
|
||||
// Thumb url
|
||||
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
|
||||
@@ -146,16 +144,15 @@ object GalleryDetailParser {
|
||||
gd.titleJpn = gm.getElementById("gj")?.text()?.trim()
|
||||
|
||||
// Category
|
||||
try {
|
||||
gd.category = Either.catch {
|
||||
val gdc = gm.getElementById("gdc")!!
|
||||
var ce = gdc.getElementsByClass("cn").first()
|
||||
if (ce == null) {
|
||||
ce = gdc.getElementsByClass("cs").first()
|
||||
}
|
||||
gd.category = getCategory(ce!!.text())
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
gd.category = EhUtils.UNKNOWN
|
||||
EhUtils.getCategory(ce!!.text())
|
||||
}.getOrElse {
|
||||
EhUtils.UNKNOWN
|
||||
}
|
||||
|
||||
// Uploader
|
||||
@@ -218,8 +215,7 @@ object GalleryDetailParser {
|
||||
if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) {
|
||||
gd.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
}.getOrElse {
|
||||
throw ParseException("Can't parse gallery detail")
|
||||
}
|
||||
|
||||
@@ -278,161 +274,141 @@ object GalleryDetailParser {
|
||||
}
|
||||
}
|
||||
|
||||
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 (it.className() == "gtw") {
|
||||
tag = "_$tag" // weak tag
|
||||
}
|
||||
group.add(tag)
|
||||
private fun parseSingleTagGroup(element: Element): GalleryTagGroup? = Either.catch {
|
||||
val nameSpace = with(element.child(0).text()) {
|
||||
// Remove last ':'
|
||||
TagNamespace.from(substring(0, length - 1))
|
||||
}
|
||||
if (group.size > 0) group else null
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
checkNotNull(nameSpace) { "$nameSpace is not a valid tag namespace!" }
|
||||
val tags = element.child(1).children().map { e ->
|
||||
val text = e.text()
|
||||
// Sometimes parody tag is followed with '|' and english translate, just remove them
|
||||
val index = text.indexOf('|')
|
||||
val tag = if (index >= 0) text.substring(0, index).trim() else text
|
||||
// weak tag
|
||||
if (e.className() == "gtw") "_$tag" else tag
|
||||
}
|
||||
check(tags.isNotEmpty()) { "TagGroup is empty!" }
|
||||
GalleryTagGroup(nameSpace, tags)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tag groups with html parser
|
||||
*/
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = Either.catch {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
val tagGroups = taglist.child(0).child(0).children()
|
||||
parseTagGroups(tagGroups)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
taglist.child(0).child(0).children().mapNotNull(::parseSingleTagGroup)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
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? {
|
||||
return try {
|
||||
// Id
|
||||
val a = element.previousElementSibling()
|
||||
val name = a!!.attr("name")
|
||||
val id = name trimAnd { substring(1).toInt().toLong() }
|
||||
// Editable, vote up and vote down
|
||||
val c4 = element.getElementsByClass("c4").first()
|
||||
var voteUpAble = false
|
||||
var voteUpEd = false
|
||||
var voteDownAble = false
|
||||
var voteDownEd = false
|
||||
var editable = false
|
||||
var uploader = false
|
||||
if (null != c4) {
|
||||
if ("Uploader Comment" == c4.text()) {
|
||||
uploader = true
|
||||
}
|
||||
for (e in c4.children()) {
|
||||
when (e.text()) {
|
||||
"Vote+" -> {
|
||||
voteUpAble = true
|
||||
voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Vote-" -> {
|
||||
voteDownAble = true
|
||||
voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Edit" -> editable = true
|
||||
private suspend fun parseComment(element: Element): GalleryComment? = Either.catch {
|
||||
// Id
|
||||
val a = element.previousElementSibling()
|
||||
val name = a!!.attr("name")
|
||||
val id = name trimAnd { substring(1).toInt().toLong() }
|
||||
// Editable, vote up and vote down
|
||||
val c4 = element.getElementsByClass("c4").first()
|
||||
var voteUpAble = false
|
||||
var voteUpEd = false
|
||||
var voteDownAble = false
|
||||
var voteDownEd = false
|
||||
var editable = false
|
||||
var uploader = false
|
||||
if (null != c4) {
|
||||
if ("Uploader Comment" == c4.text()) {
|
||||
uploader = true
|
||||
}
|
||||
for (e in c4.children()) {
|
||||
when (e.text()) {
|
||||
"Vote+" -> {
|
||||
voteUpAble = true
|
||||
voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Vote-" -> {
|
||||
voteDownAble = true
|
||||
voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Edit" -> editable = true
|
||||
}
|
||||
}
|
||||
// Vote state
|
||||
val c7 = element.getElementsByClass("c7").first()
|
||||
val voteState = c7?.text()?.trim()
|
||||
// Score
|
||||
val c5 = element.getElementsByClass("c5").first()
|
||||
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
|
||||
// time
|
||||
val c3 = element.getElementsByClass("c3").first()
|
||||
val temp = c3!!.ownText()
|
||||
val (timeStr, user) = if (temp.endsWith(':')) {
|
||||
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
|
||||
} else {
|
||||
temp.substring("Posted on ".length) to null
|
||||
}
|
||||
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
|
||||
// comment
|
||||
val c6 = element.getElementsByClass("c6").first()
|
||||
// fix underline support
|
||||
for (e in c6!!.children()) {
|
||||
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
|
||||
e.tagName("u")
|
||||
}
|
||||
}
|
||||
val commentHtml = c6.html()
|
||||
// filter comment
|
||||
if (!uploader) {
|
||||
val sEhFilter = EhFilter
|
||||
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// last edited
|
||||
val c8 = element.getElementsByClass("c8").first()
|
||||
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
|
||||
GalleryComment(
|
||||
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
|
||||
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
null
|
||||
}
|
||||
// Vote state
|
||||
val c7 = element.getElementsByClass("c7").first()
|
||||
val voteState = c7?.text()?.trim()
|
||||
// Score
|
||||
val c5 = element.getElementsByClass("c5").first()
|
||||
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
|
||||
// time
|
||||
val c3 = element.getElementsByClass("c3").first()
|
||||
val temp = c3!!.ownText()
|
||||
val (timeStr, user) = if (temp.endsWith(':')) {
|
||||
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
|
||||
} else {
|
||||
temp.substring("Posted on ".length) to null
|
||||
}
|
||||
val time = WEB_COMMENT_DATE_FORMAT.parse(timeStr).toEpochMillis()
|
||||
// comment
|
||||
val c6 = element.getElementsByClass("c6").first()
|
||||
// fix underline support
|
||||
for (e in c6!!.children()) {
|
||||
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
|
||||
e.tagName("u")
|
||||
}
|
||||
}
|
||||
val commentHtml = c6.html()
|
||||
// filter comment
|
||||
if (!uploader) {
|
||||
val sEhFilter = EhFilter
|
||||
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// last edited
|
||||
val c8 = element.getElementsByClass("c8").first()
|
||||
val lastEdited = c8?.children()?.first()?.run { WEB_COMMENT_DATE_FORMAT.parse(text()).toEpochMillis() } ?: 0
|
||||
GalleryComment(
|
||||
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
|
||||
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
|
||||
)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse comments with html parser
|
||||
*/
|
||||
suspend fun parseComments(document: Document): GalleryCommentList {
|
||||
suspend fun parseComments(document: Document): GalleryCommentList = Either.catch {
|
||||
// Disable pretty print to get comments in raw html
|
||||
document.outputSettings().prettyPrint(false)
|
||||
return try {
|
||||
val cdiv = document.getElementById("cdiv")!!
|
||||
val c1s = cdiv.getElementsByClass("c1")
|
||||
val list = c1s.mapNotNull { parseComment(it) }
|
||||
val chd = cdiv.getElementById("chd")
|
||||
var hasMore = false
|
||||
NodeTraversor.traverse(
|
||||
object : NodeVisitor {
|
||||
override fun head(node: Node, depth: Int) {
|
||||
if (node is Element && node.text() == "click to show all") {
|
||||
hasMore = true
|
||||
}
|
||||
val cdiv = document.getElementById("cdiv")!!
|
||||
val c1s = cdiv.getElementsByClass("c1")
|
||||
val list = c1s.mapNotNull { parseComment(it) }
|
||||
val chd = cdiv.getElementById("chd")
|
||||
var hasMore = false
|
||||
NodeTraversor.traverse(
|
||||
object : NodeVisitor {
|
||||
override fun head(node: Node, depth: Int) {
|
||||
if (node is Element && node.text() == "click to show all") {
|
||||
hasMore = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun tail(node: Node, depth: Int) {}
|
||||
},
|
||||
chd!!,
|
||||
)
|
||||
GalleryCommentList(list, hasMore)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
logcat(e)
|
||||
EMPTY_GALLERY_COMMENT_LIST
|
||||
}
|
||||
override fun tail(node: Node, depth: Int) {}
|
||||
},
|
||||
chd!!,
|
||||
)
|
||||
GalleryCommentList(list, hasMore)
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
EMPTY_GALLERY_COMMENT_LIST
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,32 +423,23 @@ object GalleryDetailParser {
|
||||
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>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
|
||||
|
||||
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
|
||||
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[2].toInt() - 1
|
||||
val url = it.groupValues[3].trim()
|
||||
val pageUrl = it.groupValues[1].trim()
|
||||
LargeGalleryPreview(url, position) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNormalPreview(body: String): Pair<List<GalleryPreview>, List<String>> {
|
||||
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
|
||||
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
|
||||
val position = it.groupValues[6].toInt() - 1
|
||||
val url = it.groupValues[3].trim()
|
||||
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
|
||||
val width = it.groupValues[1].toInt()
|
||||
val height = it.groupValues[2].toInt()
|
||||
val pageUrl = it.groupValues[5].trim()
|
||||
NormalGalleryPreview(url, position, xOffset, width, height) to pageUrl
|
||||
}.run {
|
||||
first.toList() to second.toList()
|
||||
}
|
||||
fun parsePreviewList(body: String) = PreviewRegex.findAll(body).unzip {
|
||||
val pageUrl = it.groupValues[1]
|
||||
val position = it.groupValues[2].toInt() - 1
|
||||
val url = it.groupValues[5]
|
||||
val offset = it.groupValues[6]
|
||||
if (offset.isEmpty()) {
|
||||
val imageKey = getThumbKey(url)
|
||||
V1GalleryPreview(imageKey, position)
|
||||
} else {
|
||||
val imageKey = getV2PreviewKey(url)
|
||||
val width = it.groupValues[3].toInt()
|
||||
val height = it.groupValues[4].toInt()
|
||||
V2GalleryPreview(url, imageKey, position, offset.toInt(), width, height)
|
||||
} to pageUrl
|
||||
}.run {
|
||||
first.toList().apply {
|
||||
if (isEmpty()) throw ParseException("Parse preview list error")
|
||||
} to second.toList()
|
||||
}
|
||||
}
|
||||
@@ -15,124 +15,56 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import arrow.core.Either
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
|
||||
object GalleryListUrlParser {
|
||||
private val VALID_HOSTS = arrayOf(EhUrl.DOMAIN_EX, EhUrl.DOMAIN_E)
|
||||
private const val PATH_NORMAL = "/"
|
||||
private const val PATH_UPLOADER = "/uploader/"
|
||||
private const val PATH_TAG = "/tag/"
|
||||
private const val PATH_TOPLIST = "/toplist.php"
|
||||
fun parse(urlStr: String): ListUrlBuilder? {
|
||||
val url = try {
|
||||
URL(urlStr)
|
||||
} catch (e: MalformedURLException) {
|
||||
fun parse(urlStr: String) = Either.catch {
|
||||
val url = Url(urlStr)
|
||||
if (url.host != EhUrl.DOMAIN_E && url.host != EhUrl.DOMAIN_EX) {
|
||||
return null
|
||||
}
|
||||
if (url.host !in VALID_HOSTS) {
|
||||
return null
|
||||
}
|
||||
val path = url.path ?: return null
|
||||
return if (PATH_NORMAL == path || path.isEmpty()) {
|
||||
ListUrlBuilder(url.query)
|
||||
} else if (path.startsWith(PATH_UPLOADER)) {
|
||||
parseUploader(path)
|
||||
} else if (path.startsWith(PATH_TAG)) {
|
||||
parseTag(path)
|
||||
} else if (path.startsWith(PATH_TOPLIST)) {
|
||||
parseToplist(urlStr)
|
||||
} else if (path.startsWith("/")) {
|
||||
val category = try {
|
||||
path.substring(1).toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
return null
|
||||
val segments = url.segments
|
||||
if (segments.isEmpty()) {
|
||||
ListUrlBuilder(url.parameters).apply {
|
||||
url.parameters["f_shash"]?.let {
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH
|
||||
hash = it
|
||||
}
|
||||
}
|
||||
ListUrlBuilder(url.query).also { it.category = category }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
when (val head = segments[0]) {
|
||||
"uploader", "tag" -> ListUrlBuilder(url.parameters).apply {
|
||||
mode = if (head == "uploader") ListUrlBuilder.MODE_UPLOADER else ListUrlBuilder.MODE_TAG
|
||||
keyword = segments[1].decodeURLQueryComponent(plusIsSpace = true)
|
||||
}
|
||||
|
||||
// TODO get page
|
||||
private fun parseUploader(path: String): ListUrlBuilder? {
|
||||
var uploader: String?
|
||||
val prefixLength = PATH_UPLOADER.length
|
||||
val index = path.indexOf('/', prefixLength)
|
||||
uploader = if (index < 0) {
|
||||
path.substring(prefixLength)
|
||||
} else {
|
||||
path.substring(prefixLength, index)
|
||||
}
|
||||
uploader = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
URLDecoder.decode(uploader, StandardCharsets.UTF_8)
|
||||
} else {
|
||||
try {
|
||||
URLDecoder.decode(uploader, StandardCharsets.UTF_8.displayName())
|
||||
} catch (e: UnsupportedEncodingException) {
|
||||
logcat(e)
|
||||
return null
|
||||
"toplist.php" -> {
|
||||
val tl = url.parameters["tl"]
|
||||
if (tl != null && tl in arrayOf("11", "12", "13", "15")) {
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_TOPLIST,
|
||||
jumpTo = url.parameters["p"],
|
||||
mKeyword = tl,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val category = EhUtils.getCategory(head)
|
||||
if (category != EhUtils.UNKNOWN) {
|
||||
ListUrlBuilder(url.parameters).also { it.category = category }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploader.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val builder = ListUrlBuilder()
|
||||
builder.mode = ListUrlBuilder.MODE_UPLOADER
|
||||
builder.keyword = uploader
|
||||
return builder
|
||||
}
|
||||
|
||||
// TODO get page
|
||||
private fun parseTag(path: String): ListUrlBuilder? {
|
||||
var tag: String?
|
||||
val prefixLength = PATH_TAG.length
|
||||
val index = path.indexOf('/', prefixLength)
|
||||
tag = if (index < 0) {
|
||||
path.substring(prefixLength)
|
||||
} else {
|
||||
path.substring(prefixLength, index)
|
||||
}
|
||||
tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
URLDecoder.decode(tag, StandardCharsets.UTF_8)
|
||||
} else {
|
||||
try {
|
||||
URLDecoder.decode(tag, StandardCharsets.UTF_8.displayName())
|
||||
} catch (e: UnsupportedEncodingException) {
|
||||
logcat(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (tag.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val builder = ListUrlBuilder()
|
||||
builder.mode = ListUrlBuilder.MODE_TAG
|
||||
builder.keyword = tag
|
||||
return builder
|
||||
}
|
||||
|
||||
// TODO get page
|
||||
private fun parseToplist(path: String): ListUrlBuilder? {
|
||||
val uri = Uri.parse(path)
|
||||
if (uri == null || uri.getQueryParameter("tl").isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val tl = uri.getQueryParameter("tl")!!.toInt()
|
||||
if (tl > 15 || tl < 11) {
|
||||
return null
|
||||
}
|
||||
val builder = ListUrlBuilder()
|
||||
builder.mode = ListUrlBuilder.MODE_TOPLIST
|
||||
builder.keyword = tl.toString()
|
||||
return builder
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
+4
-8
@@ -19,22 +19,18 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object GalleryMultiPageViewerPTokenParser {
|
||||
private const val IMAGE_LIST_STRING = "var imagelist = "
|
||||
|
||||
fun parse(body: String): List<String> {
|
||||
fun parse(body: String): List<String> = runCatching {
|
||||
val index = body.indexOf(IMAGE_LIST_STRING)
|
||||
val imagelist = body.substring(index + IMAGE_LIST_STRING.length, body.indexOf(";", index))
|
||||
return runCatching {
|
||||
imagelist.parseAs<List<Item>>().map(Item::token)
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw ParseException("Parse pToken from MPV error", it)
|
||||
}
|
||||
imagelist.parseAs<List<Item>>().map(Item::token)
|
||||
}.getOrElse {
|
||||
throw ParseException("Parse pToken from MPV error", it)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object GalleryNotAvailableParser {
|
||||
fun parse(body: String): String? = runCatching {
|
||||
fun parse(body: String): String? = Either.catch {
|
||||
val e = Jsoup.parse(body).getElementsByClass("d").first()
|
||||
e!!.child(0).html().replace("<br>", "\n")
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
logcat(it)
|
||||
null
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -32,10 +33,9 @@ object GalleryTokenApiParser {
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
fun parse(body: String): String = runCatching {
|
||||
fun parse(body: String): String = Either.catch {
|
||||
body.parseAs<Result>().tokenList[0].token
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw EhException(body.parseAs<Error>().error)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object HomeParser {
|
||||
private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
|
||||
private val TorrentKeyRegex = Regex("Your current key is: <[^>]*>([^<]*)<")
|
||||
private val FundsRegex = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
|
||||
private const val INSUFFICIENT_FUNDS = "Insufficient funds."
|
||||
|
||||
fun parse(body: ByteBuffer) = runCatching {
|
||||
@@ -21,21 +23,24 @@ object HomeParser {
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTorrentKey(body: String) = TorrentKeyRegex.find(body)?.run { groupValues[1] }
|
||||
?: throw ParseException("Parse torrent key error")
|
||||
|
||||
fun parseFunds(body: String): Funds {
|
||||
PATTERN_FUNDS.find(body)?.groupValues?.run {
|
||||
FundsRegex.find(body)?.groupValues?.run {
|
||||
val fundsC = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
|
||||
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
|
||||
val fundsGP = ParserUtils.parseInt(get(2), 0)
|
||||
return Funds(fundsGP, fundsC)
|
||||
}
|
||||
throw ParseException("Parse funds error")
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Result(val limits: Limits, val funds: Funds) : Parcelable
|
||||
data class Result(val limits: Limits, val funds: Funds, private val id: Int = Random.nextInt()) : Parcelable
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val gp: String, val credit: String) : Parcelable
|
||||
data class Funds(val gp: Int, val credit: Int) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
|
||||
@@ -15,18 +15,19 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
object ProfileParser {
|
||||
fun parse(body: String): Result = runCatching {
|
||||
fun parse(body: String): Result = Either.catch {
|
||||
val d = Jsoup.parse(body)
|
||||
val profilename = d.getElementById("profilename")
|
||||
val displayName = profilename!!.child(0).text()
|
||||
val avatar = runCatching {
|
||||
val avatar = Either.catch {
|
||||
val avatar =
|
||||
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
|
||||
if (avatar.isEmpty()) {
|
||||
@@ -37,13 +38,11 @@ object ProfileParser {
|
||||
avatar
|
||||
}
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
logcat { "No avatar" }
|
||||
null
|
||||
}
|
||||
Result(displayName, avatar)
|
||||
}.getOrElse {
|
||||
ExceptionUtils.throwIfFatal(it)
|
||||
throw ParseException("Parse forums error")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +1,58 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Extras
|
||||
import coil3.Image
|
||||
import coil3.getExtra
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toIntRect
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import coil3.asImage
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.intercept.Interceptor.Chain
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.image.detectBorder
|
||||
import com.hippo.ehviewer.image.copyBitmapToAHB
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val FORMAT = HardwareBuffer.RGBA_8888
|
||||
|
||||
private val maybeCropBorderKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
|
||||
extras[maybeCropBorderKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.maybeCropBorder: Boolean
|
||||
get() = getExtra(maybeCropBorderKey)
|
||||
|
||||
class BitmapImageWithRect(
|
||||
val rect: IntRect,
|
||||
val image: BitmapImage,
|
||||
) : Image by image
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private const val USAGE = HardwareBuffer.USAGE_CPU_WRITE_RARELY or HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE
|
||||
|
||||
object CropBorderInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.maybeCropBorder && result is SuccessResult) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
// Copy with cropped region
|
||||
val srcSize = IntSize(image.width, image.height)
|
||||
if (image.rect.size != srcSize) {
|
||||
val (x, y) = image.rect.topLeft
|
||||
val (w, h) = image.rect.size
|
||||
val src = image.image.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.
|
||||
val meetHardwareThreshold = maxOf(w, h) <= chain.request.hardwareThreshold
|
||||
val bitmap = when {
|
||||
isAtLeastQ && meetHardwareThreshold -> runSuspendCatching {
|
||||
resourceScope {
|
||||
val buffer = autoCloseable { HardwareBuffer.create(w, h, FORMAT, 1, USAGE) }
|
||||
copyBitmapToAHB(src, buffer, x, y)
|
||||
Bitmap.wrapHardwareBuffer(buffer, src.colorSpace)
|
||||
}
|
||||
}.onFailure { logcat(it) }.getOrNull()
|
||||
else -> null
|
||||
} ?: Bitmap.createBitmap(src, x, y, w, h)
|
||||
|
||||
src.recycle()
|
||||
return result.copy(image = image.copy(image = bitmap.asImage(), rect = image.rect.size.toIntRect()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.Extras
|
||||
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 com.hippo.ehviewer.image.detectBorder
|
||||
|
||||
private const val CROP_THRESHOLD = 0.75f
|
||||
private const val RATIO_THRESHOLD = 2
|
||||
|
||||
private val maybeCropBorderKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.maybeCropBorder(enable: Boolean) = apply {
|
||||
extras[maybeCropBorderKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.maybeCropBorder: Boolean
|
||||
get() = getExtra(maybeCropBorderKey)
|
||||
|
||||
object DetectBorderInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.maybeCropBorder && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImageWithExtraInfo && !image.hasQrCode) {
|
||||
val bitmap = image.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 = image.copy(rect = rect)
|
||||
return result.copy(image = new)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,49 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
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.files.isDirectory
|
||||
import com.hippo.files.isFile
|
||||
import com.hippo.files.toUri
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
private val emptyPath = "".toPath()
|
||||
private val downloadLocationKey = Extras.Key(default = emptyPath)
|
||||
|
||||
fun ImageRequest.Builder.downloadLocation(path: Path) = apply {
|
||||
extras[downloadLocationKey] = path
|
||||
}
|
||||
|
||||
val ImageRequest.downloadLocation: Path
|
||||
get() = getExtra(downloadLocationKey)
|
||||
|
||||
object DownloadThumbInterceptor : Interceptor {
|
||||
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 { downloadLocation / location / THUMB_FILE }
|
||||
if (withIOContext { thumb.isFile }) {
|
||||
val new = chain.request.newBuilder().data(thumb.toUri()).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) return result
|
||||
}
|
||||
val new = chain.request.newBuilder().data(url).build()
|
||||
val thumb = chain.request.downloadLocation
|
||||
if (thumb != emptyPath) {
|
||||
if (thumb.isFile) {
|
||||
val new = chain.request.newBuilder().data(thumb.toUri()).build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult) {
|
||||
withIOContext {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
if (result is SuccessResult) return result
|
||||
}
|
||||
val new = chain.request.newBuilder().build()
|
||||
val result = chain.withRequest(new).proceed()
|
||||
if (result is SuccessResult && thumb.parent?.isDirectory == true) {
|
||||
// Accessing the recreated file immediately after deleting it throws
|
||||
// FileNotFoundException, so we just overwrite the existing file.
|
||||
val key = requireNotNull(chain.request.memoryCacheKey)
|
||||
imageCache.read(key) {
|
||||
data sendTo thumb
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return chain.proceed()
|
||||
}
|
||||
|
||||
@@ -1,52 +1,36 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.network.NetworkHeaders
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.decode.BlackholeDecoder
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.imageKey
|
||||
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
|
||||
import io.ktor.http.HttpHeaders
|
||||
|
||||
private val header = NetworkHeaders.Builder().apply {
|
||||
add(HttpHeaders.UserAgent, Settings.userAgent)
|
||||
add(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
add(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
|
||||
}.build()
|
||||
import com.hippo.ehviewer.client.data.V2GalleryPreview
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
|
||||
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
|
||||
val key = info.thumbKey!!
|
||||
data(encodeMagicRequestOrUrl(info))
|
||||
data(info.thumbUrl)
|
||||
val format = info.thumbUrl.substringAfterLast('.', "")
|
||||
check(format.isNotBlank())
|
||||
if (info is DownloadInfo && !info.dirname.isNullOrBlank()) {
|
||||
downloadLocation(downloadLocation / info.dirname / "thumb.$format")
|
||||
}
|
||||
memoryCacheKey(key)
|
||||
diskCacheKey(key)
|
||||
httpHeaders(header)
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
data(preview.url)
|
||||
memoryCacheKey(preview.imageKey)
|
||||
diskCacheKey(preview.imageKey)
|
||||
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
|
||||
httpHeaders(header)
|
||||
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = with(preview) {
|
||||
data(url)
|
||||
if (preview is V2GalleryPreview) size(Size.ORIGINAL)
|
||||
memoryCacheKey(imageKey)
|
||||
diskCacheKey(imageKey)
|
||||
}
|
||||
|
||||
private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
|
||||
private val stubResult = DecodeResult(stubImage, false)
|
||||
private val stubFactory = Decoder { stubResult }
|
||||
|
||||
fun ImageRequest.Builder.justDownload() = apply {
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
decoderFactory { _, _, _ -> stubFactory }
|
||||
decoderFactory(BlackholeDecoder.Factory())
|
||||
}
|
||||
@@ -3,8 +3,6 @@ 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
|
||||
@@ -30,25 +28,18 @@ object HardwareBitmapInterceptor : Interceptor {
|
||||
val result = chain.proceed()
|
||||
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,
|
||||
)
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
val bitmap = image.image.bitmap
|
||||
val isHardware = bitmap.config == Bitmap.Config.HARDWARE
|
||||
// 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 (!isHardware && maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
|
||||
bitmap.recycle()
|
||||
return result.copy(image = image.copy(image = hwBitmap.asImage()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.network.NetworkClient
|
||||
import coil3.network.NetworkRequest
|
||||
import coil3.network.NetworkResponse
|
||||
import coil3.network.ktor3.asNetworkClient
|
||||
import com.hippo.ehviewer.client.URL_PREFIX_THUMB_EX
|
||||
import com.hippo.ehviewer.client.URL_SIGNATURE_THUMB_NORMAL
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.tarsin.coroutines.NamedSemaphore
|
||||
import moe.tarsin.coroutines.withLock
|
||||
|
||||
class LimitConcurrencyNetworkClient(val impl: NetworkClient) : NetworkClient {
|
||||
val semaphores = NamedSemaphore<String>(permits = 8)
|
||||
override suspend fun <T> executeRequest(req: NetworkRequest, f: suspend (NetworkResponse) -> T): T {
|
||||
val url = req.url
|
||||
return when {
|
||||
// Ex thumb server may not have h2 multiplexing support
|
||||
URL_PREFIX_THUMB_EX in url -> semaphores.withLock(URL_PREFIX_THUMB_EX) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
|
||||
// H@H server may not have h2 multiplexing support
|
||||
URL_SIGNATURE_THUMB_NORMAL in url -> semaphores.withLock(url.substringBefore(URL_SIGNATURE_THUMB_NORMAL)) { withContext(NonCancellable) { impl.executeRequest(req, f) } }
|
||||
// H2 multiplexing enabled
|
||||
else -> impl.executeRequest(req, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpClient.limitConcurrency() = LimitConcurrencyNetworkClient(asNetworkClient())
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import coil3.BitmapImage
|
||||
import coil3.Image
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
|
||||
data class BitmapImageWithExtraInfo(
|
||||
val image: BitmapImage,
|
||||
val rect: IntRect = IntRect(0, 0, image.width, image.height),
|
||||
val hasQrCode: Boolean = false,
|
||||
) : Image by image
|
||||
|
||||
object MapExtraInfoInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
val needMap = with(chain.request) { maybeCropBorder || detectQrCode || !allowHardware }
|
||||
if (needMap && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImage) {
|
||||
return result.copy(image = BitmapImageWithExtraInfo(image))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,28 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.decode.DataSource
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.isNormalPreviewKey
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.hippo.ehviewer.client.isV2PreviewKey
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLockNeedSuspend
|
||||
|
||||
object MergeInterceptor : Interceptor {
|
||||
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
|
||||
private val pendingContinuationMapLock = Any()
|
||||
private val notifyScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val EMPTY_LIST = mutableListOf<CancellableContinuation<Unit>>()
|
||||
|
||||
private fun triggerSuccessor(key: String) {
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
// Wake up a pending continuation to continue executing task
|
||||
val successor = pendingContinuationMap[key]?.removeFirstOrNull()?.apply { resume(Unit) { triggerSuccessor(key) } }
|
||||
// If no successor, delete this entry from hashmap
|
||||
successor ?: pendingContinuationMap.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerFailure(key: String, e: Throwable) {
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
pendingContinuationMap.remove(key)?.forEach { it.resumeWithException(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
private val mutex = NamedMutex<String>()
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.isNormalPreviewKey || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
val existPendingContinuations = pendingContinuationMap[key]
|
||||
if (existPendingContinuations == null) {
|
||||
pendingContinuationMap[key] = EMPTY_LIST
|
||||
continuation.resume(Unit) {
|
||||
triggerSuccessor(key)
|
||||
}
|
||||
} else {
|
||||
if (existPendingContinuations === EMPTY_LIST) pendingContinuationMap[key] = mutableListOf()
|
||||
pendingContinuationMap[key]!!.apply {
|
||||
add(continuation)
|
||||
continuation.invokeOnCancellation { remove(continuation) }
|
||||
}
|
||||
}
|
||||
val key = req.memoryCacheKey?.takeIf { it.isV2PreviewKey || Settings.preloadThumbAggressively }
|
||||
return if (key != null) {
|
||||
val (result, suspended) = mutex.withLockNeedSuspend(key) { chain.proceed() }
|
||||
when (result) {
|
||||
is SuccessResult if (suspended) -> result.copy(dataSource = DataSource.MEMORY)
|
||||
else -> result
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
pendingContinuationMap.remove(key)?.forEach { it.resume(Unit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
triggerSuccessor(key)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Wake all pending continuations since this request is to be failed
|
||||
triggerFailure(key, e)
|
||||
throw e
|
||||
} else {
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.Extras
|
||||
import coil3.getExtra
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.ehviewer.image.hasQrCode
|
||||
|
||||
private val detectQrCodeKey = Extras.Key(default = false)
|
||||
|
||||
fun ImageRequest.Builder.detectQrCode(enable: Boolean) = apply {
|
||||
extras[detectQrCodeKey] = enable
|
||||
}
|
||||
|
||||
val ImageRequest.detectQrCode: Boolean
|
||||
get() = getExtra(detectQrCodeKey)
|
||||
|
||||
object QrCodeInterceptor : Interceptor {
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val result = chain.proceed()
|
||||
if (chain.request.detectQrCode && result is SuccessResult) {
|
||||
val image = result.image
|
||||
if (image is BitmapImageWithExtraInfo) {
|
||||
val hasQrCode = hasQrCode(image.image.bitmap)
|
||||
val new = image.copy(hasQrCode = hasQrCode)
|
||||
return result.copy(image = new)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import java.io.File
|
||||
import org.chromium.net.CronetEngine
|
||||
import splitties.init.appCtx
|
||||
|
||||
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
|
||||
enableBrotli(true)
|
||||
setUserAgent(Settings.userAgent)
|
||||
|
||||
// Cache Quic hint only since the real cache mechanism should on Ktor layer
|
||||
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)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
}.build()
|
||||
@@ -2,12 +2,13 @@ package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface DownloadArtistDao {
|
||||
@Insert
|
||||
suspend fun insert(downloadArtists: List<DownloadArtist>)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertOrIgnore(downloadArtists: List<DownloadArtist>)
|
||||
|
||||
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
|
||||
suspend fun deleteByGid(gid: Long)
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
|
||||
@Dao
|
||||
interface HistoryDao {
|
||||
@Query("SELECT * FROM HISTORY ORDER BY TIME")
|
||||
@Query("SELECT HISTORY.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME")
|
||||
suspend fun list(): List<HistoryInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM HISTORY JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
|
||||
|
||||
@@ -14,7 +14,7 @@ interface LocalFavoritesDao {
|
||||
@Query("SELECT COUNT(*) FROM LOCAL_FAVORITES")
|
||||
fun count(): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM LOCAL_FAVORITES ORDER BY TIME")
|
||||
@Query("SELECT LOCAL_FAVORITES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME")
|
||||
suspend fun list(): List<LocalFavoriteInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY TIME DESC")
|
||||
@@ -27,7 +27,7 @@ interface LocalFavoritesDao {
|
||||
fun joinListLazy(title: String): PagingSource<Int, BaseGalleryInfo>
|
||||
|
||||
@Query("SELECT GALLERIES.* FROM LOCAL_FAVORITES JOIN GALLERIES USING(GID) ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun random(): BaseGalleryInfo
|
||||
suspend fun random(): BaseGalleryInfo?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT * FROM LOCAL_FAVORITES WHERE GID = :gid)")
|
||||
suspend fun contains(gid: Long): Boolean
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
|
||||
import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
|
||||
@@ -683,14 +682,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetImageSuccess(index: Int, image: Image?) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
override fun onGetImageFailure(index: Int, error: String?) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
interface DownloadListener {
|
||||
/**
|
||||
* Get 509 error
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.closeable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import com.hippo.ehviewer.Settings.archivePasswds
|
||||
import com.hippo.ehviewer.image.ByteBufferSource
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.image.ImageSource
|
||||
import com.hippo.ehviewer.image.byteBufferSource
|
||||
import com.hippo.ehviewer.jni.closeArchive
|
||||
import com.hippo.ehviewer.jni.extractToByteBuffer
|
||||
import com.hippo.ehviewer.jni.extractToFd
|
||||
@@ -27,141 +29,65 @@ import com.hippo.ehviewer.jni.needPassword
|
||||
import com.hippo.ehviewer.jni.openArchive
|
||||
import com.hippo.ehviewer.jni.providePassword
|
||||
import com.hippo.ehviewer.jni.releaseByteBuffer
|
||||
import com.hippo.ehviewer.ui.screen.implicit
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.displayPath
|
||||
import com.hippo.ehviewer.util.displayName
|
||||
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
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import okio.Path
|
||||
|
||||
typealias PasswdInvalidator = (String) -> Boolean
|
||||
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
|
||||
val emptyPasswdProvider: PasswdProvider = { error("Managed Archive have password???") }
|
||||
|
||||
class ArchivePageLoader(
|
||||
private val file: Path,
|
||||
suspend fun <T> useArchivePageLoader(
|
||||
file: Path,
|
||||
gid: Long = 0,
|
||||
startPage: Int = 0,
|
||||
passwdProvider: PasswdProvider? = null,
|
||||
) : PageLoader2(gid, startPage) {
|
||||
private lateinit var pfd: ParcelFileDescriptor
|
||||
private val hostJob = async(start = CoroutineStart.LAZY) {
|
||||
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
|
||||
pfd = file.openFileDescriptor("r")
|
||||
val size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
|
||||
if (size != 0 && passwdProvider != null && needPassword()) {
|
||||
archivePasswds?.forEach {
|
||||
it ?: return@forEach
|
||||
if (providePassword(it)) return@async size
|
||||
}
|
||||
val toAdd = passwdProvider { providePassword(it) }
|
||||
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
|
||||
hasAds: Boolean = false,
|
||||
passwdProvider: PasswdProvider = emptyPasswdProvider,
|
||||
block: suspend (PageLoader) -> T,
|
||||
) = coroutineScope {
|
||||
resourceScope {
|
||||
val pfd = closeable { file.openFileDescriptor("r") }
|
||||
val size = install(
|
||||
{ openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip")) },
|
||||
{ _, _ -> closeArchive() },
|
||||
)
|
||||
check(size >= 0) { "Archive have no content!" }
|
||||
if (needPassword() && archivePasswds.filterNotNull().none { providePassword(it) }) {
|
||||
archivePasswds += passwdProvider { providePassword(it) }
|
||||
}
|
||||
size
|
||||
}
|
||||
val loader = autoCloseable {
|
||||
object : PageLoader(gid, startPage, size, hasAds, implicit<CoroutineScope>()) {
|
||||
override val title by lazy { FileUtils.getNameFromFilename(file.displayName)!! }
|
||||
|
||||
override var size = 0
|
||||
private set
|
||||
override fun getImageExtension(index: Int) = getExtension(index)
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
hostJob.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
closeArchive()
|
||||
pfd.close()
|
||||
logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
|
||||
}
|
||||
|
||||
private val mJobMap = hashMapOf<Int, Job>()
|
||||
private val mWorkerMutex = NamedMutex<Int>()
|
||||
private val mSemaphore = Semaphore(4)
|
||||
|
||||
override fun onRequest(index: Int) {
|
||||
synchronized(mJobMap) {
|
||||
val current = mJobMap[index]
|
||||
if (current?.isActive != true) {
|
||||
mJobMap[index] = launch {
|
||||
mWorkerMutex.withLock(index) {
|
||||
mSemaphore.withPermit {
|
||||
doRealWork(index)
|
||||
}
|
||||
override fun save(index: Int, file: Path) = runCatching {
|
||||
file.openFileDescriptor("w").use {
|
||||
extractToFd(index, it.fd)
|
||||
}
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
false
|
||||
}
|
||||
|
||||
override fun openSource(index: Int): ImageSource {
|
||||
val buffer = extractToByteBuffer(index)
|
||||
checkNotNull(buffer) { "Extract archive content $index failed!" }
|
||||
check(buffer.isDirect)
|
||||
return byteBufferSource(buffer) { releaseByteBuffer(buffer) }
|
||||
}
|
||||
|
||||
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = pages.forEach(::notifySourceReady)
|
||||
|
||||
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = notifySourceReady(index)
|
||||
}
|
||||
}
|
||||
loader.progressJob.join()
|
||||
block(loader)
|
||||
}
|
||||
|
||||
private suspend fun doRealWork(index: Int) {
|
||||
val buffer = extractToByteBuffer(index) ?: return notifyPageFailed(index, null)
|
||||
check(buffer.isDirect)
|
||||
val src = object : ByteBufferSource {
|
||||
override val source: ByteBuffer = buffer
|
||||
|
||||
override fun close() {
|
||||
releaseByteBuffer(buffer)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
src.close()
|
||||
throw it
|
||||
}
|
||||
val image = Image.decode(src) ?: return notifyPageFailed(index, null)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyPageSucceed(index, image)
|
||||
}
|
||||
|
||||
override fun onForceRequest(index: Int, orgImg: Boolean) {
|
||||
onRequest(index)
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
size = hostJob.await()
|
||||
return super.awaitReady() && isReady
|
||||
}
|
||||
|
||||
override val isReady: Boolean
|
||||
get() = size != 0
|
||||
|
||||
override fun onCancelRequest(index: Int) {
|
||||
mJobMap[index]?.cancel()
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
FileUtils.getNameFromFilename(file.name)!!
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int): String = getExtension(index)
|
||||
|
||||
override fun save(index: Int, file: Path): Boolean = runCatching {
|
||||
file.openFileDescriptor("w").use {
|
||||
extractToFd(index, it.fd)
|
||||
}
|
||||
}.getOrElse {
|
||||
logcat(it)
|
||||
false
|
||||
}
|
||||
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) = Unit
|
||||
}
|
||||
|
||||
private const val DEBUG_TAG = "ArchivePageLoader"
|
||||
@@ -1,111 +1,62 @@
|
||||
/*
|
||||
* 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.gallery
|
||||
|
||||
import arrow.fx.coroutines.autoCloseable
|
||||
import arrow.fx.coroutines.resourceScope
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.image.Image
|
||||
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.ui.screen.implicit
|
||||
import com.hippo.ehviewer.util.hasAds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import okio.Path
|
||||
|
||||
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
PageLoader2(mGalleryInfo.gid, startPage),
|
||||
OnSpiderListener {
|
||||
private lateinit var mSpiderQueen: SpiderQueen
|
||||
override fun start() {
|
||||
super.start()
|
||||
if (!::mSpiderQueen.isInitialized) {
|
||||
mSpiderQueen = obtainSpiderQueen(mGalleryInfo, SpiderQueen.MODE_READ)
|
||||
mSpiderQueen.addOnSpiderListener(this)
|
||||
suspend fun <T> useEhPageLoader(
|
||||
info: GalleryInfo,
|
||||
startPage: Int,
|
||||
block: suspend (PageLoader) -> T,
|
||||
) = coroutineScope {
|
||||
resourceScope {
|
||||
val queen = install(
|
||||
{ obtainSpiderQueen(info, SpiderQueen.MODE_READ) },
|
||||
{ queen, _ -> releaseSpiderQueen(queen, SpiderQueen.MODE_READ) },
|
||||
)
|
||||
queen.awaitReady()
|
||||
val loader = autoCloseable {
|
||||
object : PageLoader(info.gid, startPage, queen.size, info.hasAds, implicit<CoroutineScope>()) {
|
||||
override val title by lazy { EhUtils.getSuitableTitle(info) }
|
||||
|
||||
override fun getImageExtension(index: Int) = queen.getExtension(index)
|
||||
|
||||
override fun save(index: Int, file: Path) = queen.save(index, file)
|
||||
|
||||
override fun openSource(index: Int) = queen.spiderDen.getImageSource(index)
|
||||
|
||||
override fun prefetchPages(pages: List<Int>, bounds: IntRange) = queen.preloadPages(pages, bounds)
|
||||
|
||||
override fun onRequest(index: Int, force: Boolean, orgImg: Boolean) = queen.request(index, force, orgImg)
|
||||
}
|
||||
}.apply {
|
||||
val listener = object : OnSpiderListener {
|
||||
override fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {
|
||||
if (contentLength > 0) {
|
||||
notifyPagePercent(index, receivedSize.toFloat() / contentLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) = notifySourceReady(index)
|
||||
|
||||
override fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) = notifyPageFailed(index, error)
|
||||
}
|
||||
install(
|
||||
{ queen.addOnSpiderListener(listener) },
|
||||
{ _, _ -> queen.removeOnSpiderListener(listener) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
mSpiderQueen.removeOnSpiderListener(this)
|
||||
releaseSpiderQueen(mSpiderQueen, SpiderQueen.MODE_READ)
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
EhUtils.getSuitableTitle(mGalleryInfo)
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
|
||||
|
||||
override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
|
||||
|
||||
override val size: Int
|
||||
get() = mSpiderQueen.size
|
||||
|
||||
override fun onRequest(index: Int) {
|
||||
mSpiderQueen.request(index)
|
||||
}
|
||||
|
||||
override fun onForceRequest(index: Int, orgImg: Boolean) {
|
||||
mSpiderQueen.forceRequest(index, orgImg)
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
|
||||
|
||||
override val isReady: Boolean
|
||||
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
|
||||
|
||||
override fun onCancelRequest(index: Int) {
|
||||
mSpiderQueen.cancelRequest(index)
|
||||
}
|
||||
|
||||
override fun onGetPages(pages: Int) {}
|
||||
|
||||
override fun onGet509(index: Int) {}
|
||||
|
||||
override fun onPageDownload(
|
||||
index: Int,
|
||||
contentLength: Long,
|
||||
receivedSize: Long,
|
||||
bytesRead: Int,
|
||||
) {
|
||||
if (contentLength > 0) {
|
||||
notifyPagePercent(index, receivedSize.toFloat() / contentLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {}
|
||||
override fun onPageFailure(
|
||||
index: Int,
|
||||
error: String?,
|
||||
finished: Int,
|
||||
downloaded: Int,
|
||||
total: Int,
|
||||
) {
|
||||
notifyPageFailed(index, error)
|
||||
}
|
||||
|
||||
override fun onFinish(finished: Int, downloaded: Int, total: Int) {}
|
||||
override fun onGetImageSuccess(index: Int, image: Image?) {
|
||||
notifyPageSucceed(index, image!!)
|
||||
}
|
||||
|
||||
override fun onGetImageFailure(index: Int, error: String?) {
|
||||
notifyPageFailed(index, error)
|
||||
}
|
||||
|
||||
override fun prefetchPages(pages: List<Int>, bounds: Pair<Int, Int>) {
|
||||
mSpiderQueen.preloadPages(pages, bounds)
|
||||
loader.progressJob.join()
|
||||
block(loader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class Page(
|
||||
val index: Int,
|
||||
val statusFlow: MutableStateFlow<PageStatus> = MutableStateFlow(PageStatus.Queued),
|
||||
)
|
||||
|
||||
fun Page.unblock() = statusFlow.update { status ->
|
||||
when (status) {
|
||||
is PageStatus.Blocked -> PageStatus.Ready(status.ad)
|
||||
else -> error("Call unblock on page not blocked!!!")
|
||||
}
|
||||
}
|
||||
|
||||
fun Page.reset() = statusFlow.update { PageStatus.Queued }
|
||||
|
||||
val Page.status
|
||||
get() = statusFlow.value
|
||||
|
||||
val Page.statusObserved
|
||||
@Composable
|
||||
get() = statusFlow.collectAsState().value
|
||||
|
||||
val PageStatus.progressObserved
|
||||
@Composable
|
||||
get() = when (this) {
|
||||
is PageStatus.Loading -> progress.collectAsState().value
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
sealed interface PageStatus {
|
||||
data object Queued : PageStatus
|
||||
|
||||
@JvmInline value class Blocked(val ad: Image) : PageStatus
|
||||
|
||||
@JvmInline value class Ready(val image: Image) : PageStatus
|
||||
|
||||
@JvmInline value class Error(val message: String?) : PageStatus
|
||||
|
||||
@JvmInline value class Loading(val progress: MutableStateFlow<Float>) : PageStatus
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.collection.SieveCache
|
||||
import arrow.atomic.value
|
||||
import arrow.fx.coroutines.ExitCase
|
||||
import arrow.fx.coroutines.bracketCase
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.image.ImageSource
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.OSUtils
|
||||
import com.hippo.ehviewer.util.detectAds
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLock
|
||||
import okio.Path
|
||||
|
||||
private val progressScope = CoroutineScope(Dispatchers.IO)
|
||||
private const val MAX_CACHE_SIZE = 512 * 1024 * 1024
|
||||
private const val MIN_CACHE_SIZE = 128 * 1024 * 1024
|
||||
|
||||
abstract class PageLoader(val gid: Long, var startPage: Int, val size: Int, val hasAds: Boolean = false, val scope: CoroutineScope) : AutoCloseable {
|
||||
private val mutex = NamedMutex<Int>()
|
||||
private val semaphore = Semaphore(4)
|
||||
|
||||
private val cache = SieveCache<Int, Image>(
|
||||
maxSize = if (isAtLeastO) {
|
||||
(OSUtils.totalMemory / 16).toInt().coerceIn(MIN_CACHE_SIZE, MAX_CACHE_SIZE)
|
||||
} else {
|
||||
(OSUtils.appMaxMemory / 3 * 2).toInt()
|
||||
},
|
||||
sizeOf = { _, v -> v.allocationSize.toInt() },
|
||||
onEntryRemoved = { k, o, n, _ ->
|
||||
if (o.isRecyclable) {
|
||||
n ?: notifyPageWait(k)
|
||||
o.recycle()
|
||||
} else {
|
||||
o.isRecyclable = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun decodePreloadRange(index: Int) = index - 3..index + 3
|
||||
|
||||
fun needDecode(index: Int) = index in decodePreloadRange(prevIndex) &&
|
||||
lock.read { index !in cache } &&
|
||||
pages[index].status !is PageStatus.Ready &&
|
||||
pages[index].status !is PageStatus.Blocked
|
||||
|
||||
suspend fun atomicallyDecodeAndUpdate(index: Int) {
|
||||
try {
|
||||
bracketCase(
|
||||
{ openSource(index) },
|
||||
{ src -> notifyPageSucceed(index, Image.decode(src, hasAds && detectAds(index, size))) },
|
||||
{ src, case -> if (case !is ExitCase.Completed) src.close() },
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
notifyPageFailed(index, e.displayString())
|
||||
}
|
||||
}
|
||||
|
||||
private val lock = ReentrantReadWriteLock()
|
||||
|
||||
val pages = (0 until size).map { Page(it) }
|
||||
|
||||
private val prefetchPageCount = Settings.preloadImage
|
||||
|
||||
fun restart() {
|
||||
lock.write { cache.evictAll() }
|
||||
pages.forEach(Page::reset)
|
||||
}
|
||||
|
||||
private var prevIndex by AtomicInteger(-1)::value
|
||||
|
||||
fun retryPage(index: Int, orgImg: Boolean = false) {
|
||||
notifyPageWait(index)
|
||||
lock.write { cache.remove(index) }
|
||||
onRequest(index, true, orgImg)
|
||||
}
|
||||
|
||||
protected abstract fun prefetchPages(pages: List<Int>, bounds: IntRange)
|
||||
|
||||
protected abstract fun onRequest(index: Int, force: Boolean = false, orgImg: Boolean = false)
|
||||
|
||||
fun notifyPageWait(index: Int) {
|
||||
pages[index].reset()
|
||||
}
|
||||
|
||||
fun notifyPagePercent(index: Int, percent: Float) {
|
||||
pages[index].statusFlow.update {
|
||||
when (it) {
|
||||
is PageStatus.Loading -> it.apply { progress.update { percent } }
|
||||
else -> PageStatus.Loading(MutableStateFlow(percent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyPageSucceed(index: Int, image: Image, replaceCache: Boolean = true) {
|
||||
if (replaceCache) lock.write { cache[index] = image }
|
||||
pages[index].statusFlow.update { if (image.hasQrCode) PageStatus.Blocked(image) else PageStatus.Ready(image) }
|
||||
}
|
||||
|
||||
fun notifyPageFailed(index: Int, error: String?) {
|
||||
pages[index].statusFlow.update { PageStatus.Error(error) }
|
||||
}
|
||||
|
||||
val progressJob = progressScope.launch {
|
||||
if (startPage == -1) {
|
||||
startPage = EhDB.getReadProgress(gid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
lock.write { cache.evictAll() }
|
||||
if (gid != 0L) {
|
||||
progressScope.launch {
|
||||
EhDB.putReadProgress(gid, startPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val title: String
|
||||
|
||||
protected abstract fun getImageExtension(index: Int): String?
|
||||
|
||||
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
|
||||
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
|
||||
}
|
||||
|
||||
fun request(index: Int) {
|
||||
val prefetchRange = if (index >= prevIndex) {
|
||||
index + 1..(index + prefetchPageCount).coerceAtMost(size - 1)
|
||||
} else {
|
||||
index - 1 downTo (index - prefetchPageCount).coerceAtLeast(0)
|
||||
}
|
||||
prevIndex = index
|
||||
val image = lock.read { cache[index] }
|
||||
if (image != null) {
|
||||
notifyPageSucceed(index, image, false)
|
||||
} else {
|
||||
notifyPageWait(index)
|
||||
onRequest(index)
|
||||
}
|
||||
|
||||
// Prefetch to disk
|
||||
val pagesAbsent = prefetchRange.filter {
|
||||
when (pages[it].status) {
|
||||
PageStatus.Queued, is PageStatus.Error -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val start = if (prefetchRange.step > 0) prefetchRange.first else prefetchRange.last
|
||||
val end = if (prefetchRange.step > 0) prefetchRange.last else prefetchRange.first
|
||||
prefetchPages(pagesAbsent, start - 5..end + 5)
|
||||
}
|
||||
|
||||
abstract fun save(index: Int, file: Path): Boolean
|
||||
|
||||
fun notifySourceReady(index: Int) {
|
||||
if (needDecode(index)) {
|
||||
scope.launch {
|
||||
mutex.withLock(index) {
|
||||
semaphore.withPermit {
|
||||
// Double check
|
||||
if (needDecode(index)) atomicallyDecodeAndUpdate(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun openSource(index: Int): ImageSource
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/*
|
||||
* 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.gallery
|
||||
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.Path
|
||||
|
||||
abstract class PageLoader2(private val gid: Long, var startPage: Int) :
|
||||
PageLoader(),
|
||||
CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private val progressJob = async(start = CoroutineStart.LAZY) {
|
||||
if (startPage == -1) {
|
||||
startPage = EhDB.getReadProgress(gid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
progressJob.start()
|
||||
}
|
||||
|
||||
override suspend fun awaitReady(): Boolean {
|
||||
progressJob.await()
|
||||
return super.awaitReady() && startPage != -1
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (gid != 0L) {
|
||||
launch {
|
||||
EhDB.putReadProgress(gid, startPage)
|
||||
this@PageLoader2.cancel()
|
||||
}
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract val title: String
|
||||
|
||||
protected abstract fun getImageExtension(index: Int): String?
|
||||
|
||||
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
|
||||
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
|
||||
}
|
||||
|
||||
abstract fun save(index: Int, file: Path): Boolean
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.hippo.ehviewer.icons.filled
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
|
||||
val EhIcons.Filled.Magnet by unsafeLazy {
|
||||
materialIcon(name = "Magnet") {
|
||||
materialPath {
|
||||
moveTo(3.0F, 7.0F)
|
||||
verticalLineTo(13.0F)
|
||||
arcTo(9.0F, 9.0F, 0.0F, false, false, 12.0F, 22.0F)
|
||||
arcTo(9.0F, 9.0F, 0.0F, false, false, 21.0F, 13.0F)
|
||||
verticalLineTo(7.0F)
|
||||
horizontalLineTo(17.0F)
|
||||
verticalLineTo(13.0F)
|
||||
arcTo(5.0F, 5.0F, 0.0F, false, true, 12.0F, 18.0F)
|
||||
arcTo(5.0F, 5.0F, 0.0F, false, true, 7.0F, 13.0F)
|
||||
verticalLineTo(7.0F)
|
||||
moveTo(17.0F, 5.0F)
|
||||
horizontalLineTo(21.0F)
|
||||
verticalLineTo(2.0F)
|
||||
horizontalLineTo(17.0F)
|
||||
moveTo(3.0F, 5.0F)
|
||||
horizontalLineTo(7.0F)
|
||||
verticalLineTo(2.0F)
|
||||
horizontalLineTo(3.0F)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@Suppress("UnusedPrivateMember")
|
||||
private fun IconMagnetPreview() {
|
||||
Image(imageVector = EhIcons.Default.Magnet, contentDescription = null)
|
||||
}
|
||||
@@ -18,10 +18,13 @@
|
||||
package com.hippo.ehviewer.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import android.hardware.HardwareBuffer
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.ExitCase
|
||||
import arrow.fx.coroutines.bracketCase
|
||||
import coil3.BitmapImage
|
||||
import coil3.DrawableImage
|
||||
import coil3.Image as CoilImage
|
||||
@@ -33,7 +36,8 @@ 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.BitmapImageWithExtraInfo
|
||||
import com.hippo.ehviewer.coil.detectQrCode
|
||||
import com.hippo.ehviewer.coil.hardwareThreshold
|
||||
import com.hippo.ehviewer.coil.maybeCropBorder
|
||||
import com.hippo.ehviewer.jni.isGif
|
||||
@@ -45,20 +49,20 @@ import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
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: ImageSource) {
|
||||
val size = image.size
|
||||
val rect = when (image) {
|
||||
is BitmapImageWithRect -> image.rect
|
||||
else -> image.run { IntRect(0, 0, width, height) }
|
||||
val intrinsicSize = with(image) { IntSize(width, height) }
|
||||
val allocationSize = image.size
|
||||
val hasQrCode = when (image) {
|
||||
is BitmapImageWithExtraInfo -> image.hasQrCode
|
||||
else -> false
|
||||
}
|
||||
|
||||
var innerImage: CoilImage? = when (image) {
|
||||
is BitmapImageWithRect -> image.image
|
||||
is BitmapImageWithExtraInfo -> image.image
|
||||
else -> image
|
||||
}
|
||||
|
||||
@@ -76,7 +80,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
companion object {
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
|
||||
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
|
||||
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(checkExtraneousAds: Boolean): CoilImage {
|
||||
val req = appCtx.imageRequest {
|
||||
onLeft { data(it.source) }
|
||||
onRight { data(it.source.toUri()) }
|
||||
@@ -85,6 +89,7 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
allowHardware(false)
|
||||
hardwareThreshold(Settings.hardwareBitmapThreshold)
|
||||
maybeCropBorder(Settings.cropBorder.value)
|
||||
detectQrCode(checkExtraneousAds)
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
}
|
||||
return when (val result = appCtx.imageLoader.execute(req)) {
|
||||
@@ -93,43 +98,34 @@ class Image private constructor(image: CoilImage, private val src: ImageSource)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun decode(src: ImageSource): Image? {
|
||||
return runCatching {
|
||||
val image = when (src) {
|
||||
is PathSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
src.source.openFileDescriptor("rw").use {
|
||||
val fd = it.fd
|
||||
if (isGif(fd)) {
|
||||
val buffer = mmap(fd)!!
|
||||
val source = object : ByteBufferSource {
|
||||
override val source = buffer
|
||||
override fun close() {
|
||||
munmap(buffer)
|
||||
src.close()
|
||||
}
|
||||
}
|
||||
return decode(source)
|
||||
}
|
||||
suspend fun decode(src: ImageSource, checkExtraneousAds: Boolean = false): Image {
|
||||
val image = when (src) {
|
||||
is PathSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
src.source.openFileDescriptor("rw").use {
|
||||
val fd = it.fd
|
||||
if (isGif(fd)) {
|
||||
return bracketCase(
|
||||
{ mmap(fd)!! },
|
||||
{ buffer -> decode(byteBufferSource(buffer) { munmap(buffer).also { src.close() } }, checkExtraneousAds) },
|
||||
{ buffer, case -> if (case !is ExitCase.Completed) munmap(buffer) },
|
||||
)
|
||||
}
|
||||
}
|
||||
src.right().decodeCoil()
|
||||
}
|
||||
src.right().decodeCoil(checkExtraneousAds)
|
||||
}
|
||||
|
||||
is ByteBufferSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
rewriteGifSource(src.source)
|
||||
}
|
||||
src.left().decodeCoil()
|
||||
is ByteBufferSource -> {
|
||||
if (isAtLeastP && !isAtLeastU) {
|
||||
rewriteGifSource(src.source)
|
||||
}
|
||||
src.left().decodeCoil(checkExtraneousAds)
|
||||
}
|
||||
Image(image, src).apply {
|
||||
if (innerImage is BitmapImage) src.close()
|
||||
}
|
||||
}.onFailure {
|
||||
src.close()
|
||||
logcat(it)
|
||||
}.getOrNull()
|
||||
}
|
||||
return Image(image, src).apply {
|
||||
if (innerImage is BitmapImage) src.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,4 +141,11 @@ interface ByteBufferSource : ImageSource {
|
||||
val source: ByteBuffer
|
||||
}
|
||||
|
||||
inline fun byteBufferSource(buffer: ByteBuffer, crossinline release: () -> Unit) = object : ByteBufferSource {
|
||||
override val source = buffer
|
||||
override fun close() = release()
|
||||
}
|
||||
|
||||
external fun detectBorder(bitmap: Bitmap): IntArray
|
||||
external fun hasQrCode(bitmap: Bitmap): Boolean
|
||||
external fun copyBitmapToAHB(src: Bitmap, dst: HardwareBuffer, x: Int, y: Int)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.Settings.userAgent
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.SaveBodyPlugin
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.HttpHeaders
|
||||
|
||||
private 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"
|
||||
private const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
|
||||
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.configureCommon(redirect: Boolean = true) = apply {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10_000
|
||||
}
|
||||
install(UserAgent) {
|
||||
agent = userAgent
|
||||
}
|
||||
defaultRequest {
|
||||
header(HttpHeaders.Accept, CHROME_ACCEPT)
|
||||
header(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
|
||||
}
|
||||
followRedirects = redirect
|
||||
install(SaveBodyPlugin) {
|
||||
disabled = true
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import io.ktor.client.engine.HttpClientEngineFactory
|
||||
import splitties.init.appCtx
|
||||
|
||||
object Cronet : HttpClientEngineFactory<CronetConfig> {
|
||||
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
|
||||
CronetEngine(CronetConfig().apply(block))
|
||||
override fun create(block: CronetConfig.() -> Unit) = CronetEngine(CronetConfig(appCtx).apply(block))
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import android.content.Context
|
||||
import android.net.http.HttpEngine
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
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()
|
||||
}
|
||||
class CronetConfig(val context: Context) : HttpClientEngineConfig() {
|
||||
var config: HttpEngine.Builder.() -> Unit = {}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import android.net.http.HttpEngine
|
||||
import android.net.http.HttpException
|
||||
import android.net.http.UploadDataProvider
|
||||
import android.net.http.UploadDataSink
|
||||
import android.net.http.UrlRequest
|
||||
import android.net.http.UrlResponseInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import com.hippo.ehviewer.Settings
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.plugins.HttpTimeoutCapability
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
@@ -15,6 +27,7 @@ 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.writeFully
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -26,17 +39,18 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
import org.chromium.net.apihelpers.UploadDataProviders
|
||||
|
||||
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)
|
||||
|
||||
override val supportedCapabilities = setOf(HttpTimeoutCapability)
|
||||
|
||||
private val executor = dispatcher.asExecutor()
|
||||
private val pool = DirectByteBufferPool(32)
|
||||
private val client = config.client ?: error("Cronet client is not configured")
|
||||
private val client by lazy {
|
||||
with(config) { HttpEngine.Builder(context).apply(config).build() }
|
||||
}
|
||||
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
|
||||
@@ -47,7 +61,7 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
|
||||
) = suspendCancellableCoroutine { continuation ->
|
||||
val requestTime = GMTDate()
|
||||
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
val callback = object : UrlRequest.Callback {
|
||||
val chunkChan = Channel<ByteBuffer>()
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
continuation.resume(
|
||||
@@ -86,16 +100,23 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
|
||||
chunkChan.close()
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: HttpException) {
|
||||
// Cronet may crash on some devices, fuck xiaomi
|
||||
// https://github.com/FooIbar/EhViewer/issues/1826
|
||||
if (error.message?.contains("ERR_FILE_NOT_FOUND") == true) {
|
||||
Settings.enableCronet = false
|
||||
}
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(error)
|
||||
} else {
|
||||
chunkChan.close(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) = Unit
|
||||
}
|
||||
|
||||
client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply {
|
||||
client.newUrlRequestBuilder(data.url.toString(), executor, callback).apply {
|
||||
setHttpMethod(data.method.value)
|
||||
data.headers.flattenForEach { key, value -> addHeader(key, value) }
|
||||
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
|
||||
@@ -116,7 +137,7 @@ private fun UrlResponseInfo.toHttpResponseData(
|
||||
statusCode = HttpStatusCode.fromValue(httpStatusCode),
|
||||
requestTime = requestTime,
|
||||
headers = headers {
|
||||
allHeaders.forEach { (key, value) ->
|
||||
headers.asMap.forEach { (key, value) ->
|
||||
appendAll(key, value)
|
||||
}
|
||||
},
|
||||
@@ -132,6 +153,17 @@ private fun UrlResponseInfo.toHttpResponseData(
|
||||
|
||||
private fun OutgoingContent.toUploadDataProvider() = when (this) {
|
||||
is OutgoingContent.NoContent -> null
|
||||
is OutgoingContent.ByteArrayContent -> UploadDataProviders.create(bytes())
|
||||
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
|
||||
val buffer = ByteBuffer.wrap(bytes()).slice()
|
||||
override fun getLength() = buffer.limit().toLong()
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
byteBuffer.put(buffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
buffer.position(0)
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
else -> error("UnsupportedContentType $this")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@file:RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
|
||||
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import android.net.http.HttpEngine
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import java.io.File
|
||||
|
||||
fun CronetConfig.configureClient() {
|
||||
config = {
|
||||
setEnableBrotli(true)
|
||||
|
||||
// Cache Quic hint only since the real cache mechanism should on Ktor layer
|
||||
val cache = File(context.cacheDir, "http_cache").apply { mkdirs() }
|
||||
setStoragePath(cache.path)
|
||||
setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
|
||||
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import io.ktor.client.engine.okhttp.OkHttpConfig
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.ExperimentalOkHttpApi
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
|
||||
@OptIn(ExperimentalOkHttpApi::class)
|
||||
fun OkHttpConfig.configureClient() {
|
||||
config {
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.hippo.ehviewer.legacy
|
||||
|
||||
import java.io.File
|
||||
import splitties.init.appCtx
|
||||
|
||||
private val OBSOLETE_CACHE_DIRS = arrayOf(
|
||||
"image",
|
||||
"thumb",
|
||||
"gallery_image",
|
||||
"spider_info",
|
||||
)
|
||||
|
||||
fun cleanObsoleteCache() {
|
||||
appCtx.deleteDatabase("hosts.db")
|
||||
val dir = appCtx.cacheDir
|
||||
for (subdir in OBSOLETE_CACHE_DIRS) {
|
||||
val file = File(dir, subdir)
|
||||
if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,15 @@ 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.ehviewer.client.data.TagNamespace.Artist
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Character
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Cosplayer
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Female
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Group
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Male
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Mixed
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Other
|
||||
import com.hippo.ehviewer.client.data.TagNamespace.Parody
|
||||
import com.hippo.files.openInputStream
|
||||
import com.hippo.files.openOutputStream
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -42,18 +51,17 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
val otherTags = mutableListOf<String>()
|
||||
with(TagNamespace) {
|
||||
when (this@getComicInfo) {
|
||||
is GalleryDetail -> tags.forEach { tagList ->
|
||||
val list = tagList.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
|
||||
when (val ns = TagNamespace(tagList.groupName)) {
|
||||
is GalleryDetail -> tagGroups.forEach { group ->
|
||||
val list = group.tags.filterNot { it == TAG_ORIGINAL || it.startsWith('_') }
|
||||
when (val ns = group.nameSpace) {
|
||||
Artist, Cosplayer -> artists.addAll(list)
|
||||
Group -> groups.addAll(list)
|
||||
Character -> characters.addAll(list)
|
||||
Parody -> parodies.addAll(list)
|
||||
Other -> otherTags.addAll(list)
|
||||
Female, Male, Mixed -> ns.toPrefix()?.let { prefix ->
|
||||
Female, Male, Mixed -> ns.prefix.let { prefix ->
|
||||
list.forEach { tag -> otherTags.add("$prefix:$tag") }
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -61,13 +69,13 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
else -> simpleTags?.forEach { tagString ->
|
||||
val (namespace, tag) = tagString.split(':', limit = 2)
|
||||
.takeIf { it.size == 2 } ?: return@forEach // Ignore temp tags that don't have namespace
|
||||
when (val ns = TagNamespace(namespace)) {
|
||||
when (val ns = from(namespace)) {
|
||||
Artist, Cosplayer -> artists.add(tag)
|
||||
Group -> groups.add(tag)
|
||||
Character -> characters.add(tag)
|
||||
Parody -> if (tag != TAG_ORIGINAL) parodies.add(tag)
|
||||
Other -> otherTags.add(tag)
|
||||
Female, Male, Mixed -> ns.toPrefix()?.let { otherTags.add("$it:$tag") }
|
||||
Female, Male, Mixed -> ns.prefix.let { otherTags.add("$it:$tag") }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.hippo.ehviewer.spider
|
||||
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
|
||||
object DownloadInfoMagics {
|
||||
private const val DOWNLOAD_INFO_DIRNAME_URL_MAGIC = "$"
|
||||
private const val DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR = "|"
|
||||
|
||||
fun encodeMagicRequestOrUrl(info: GalleryInfo): String {
|
||||
val url = info.thumbUrl
|
||||
val location = (info as? DownloadInfo)?.dirname
|
||||
return if (location.isNullOrBlank()) {
|
||||
url
|
||||
} else {
|
||||
DOWNLOAD_INFO_DIRNAME_URL_MAGIC + url + DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR + location
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -46,16 +46,21 @@ import com.hippo.files.exists
|
||||
import com.hippo.files.find
|
||||
import com.hippo.files.isDirectory
|
||||
import com.hippo.files.list
|
||||
import com.hippo.files.mkdirs
|
||||
import com.hippo.files.moveTo
|
||||
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.plugins.timeout
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import kotlin.io.path.readText
|
||||
import io.ktor.utils.io.copyTo
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okio.Path
|
||||
|
||||
@@ -67,11 +72,13 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
private var tempDownloadDir: Path? = null
|
||||
private val saveAsCbz = Settings.saveAsCbz
|
||||
private val archiveName = "$gid.cbz"
|
||||
private val downloadTimeout = Settings.downloadTimeout * 1000L
|
||||
|
||||
private val lock = ReentrantReadWriteLock()
|
||||
|
||||
// Search in both directories to maintain compatibility
|
||||
private val fileCache by lazy {
|
||||
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
|
||||
.associateBy { it.name } as MutableMap
|
||||
listOfNotNull(tempDownloadDir, downloadDir).map(Path::list).flatten().associateBy { it.name } as MutableMap
|
||||
}
|
||||
|
||||
private val imageDir
|
||||
@@ -90,22 +97,22 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
mode = value
|
||||
if (mode == SpiderQueen.MODE_DOWNLOAD) {
|
||||
if (downloadDir == null) {
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
|
||||
}
|
||||
if (saveAsCbz && tempDownloadDir == null) {
|
||||
tempDownloadDir = info.tempDownloadDir!!
|
||||
tempDownloadDir = info.tempDownloadDir!!.apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun containInCache(index: Int): Boolean {
|
||||
val key = getImageKey(gid, index)
|
||||
return sCache.read(key) { true } ?: false
|
||||
return sCache.read(key) {} != null
|
||||
}
|
||||
|
||||
private fun findImageFile(index: Int) = synchronized(fileCache) {
|
||||
private fun findImageFile(index: Int, temp: Boolean = false) = lock.read {
|
||||
val head = perFilename(index)
|
||||
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
|
||||
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) && temp == name.endsWith(TEMP_SUFFIX) }?.value
|
||||
}
|
||||
|
||||
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
|
||||
@@ -118,8 +125,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
val extension = metadata.toFile().readText()
|
||||
val file = dir.findDownloadFileForIndex(index, extension)
|
||||
data sendTo file
|
||||
true
|
||||
} ?: false
|
||||
} != null
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}.getOrDefault(false)
|
||||
@@ -144,20 +150,19 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return sCache.remove(key)
|
||||
}
|
||||
|
||||
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
|
||||
delete()
|
||||
synchronized(fileCache) {
|
||||
fileCache.remove(name)
|
||||
}
|
||||
private fun removeTempFile(index: Int) = findImageFile(index, temp = true)?.let { file ->
|
||||
file.delete()
|
||||
lock.write { fileCache.remove(file.name) }
|
||||
}
|
||||
|
||||
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
|
||||
fun removeIntermediateFiles(index: Int) {
|
||||
removeFromCache(index)
|
||||
removeTempFile(index)
|
||||
}
|
||||
|
||||
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
|
||||
private fun Path.findDownloadFileForIndex(index: Int, extension: String) = with(lock) {
|
||||
val name = perFilename(index, extension)
|
||||
return synchronized(fileCache) {
|
||||
fileCache.getOrPut(name) { resolve(name) }
|
||||
}
|
||||
read { fileCache[name] } ?: resolve(name).also { write { fileCache[name] = it } }
|
||||
}
|
||||
|
||||
suspend fun makeHttpCallAndSaveImage(
|
||||
@@ -171,6 +176,9 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
notifyProgress(total!!, done, (done - prev).toInt())
|
||||
prev = done
|
||||
}
|
||||
timeout {
|
||||
requestTimeoutMillis = downloadTimeout
|
||||
}
|
||||
}.executeSafely {
|
||||
if (it.status.isSuccess()) {
|
||||
saveFromHttpResponse(index, it)
|
||||
@@ -185,7 +193,16 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
crossinline fops: suspend (Path) -> Unit,
|
||||
): Boolean {
|
||||
imageDir?.run {
|
||||
fops(findDownloadFileForIndex(index, ext))
|
||||
val tempFile = findDownloadFileForIndex(index, "$ext$TEMP_SUFFIX")
|
||||
fops(tempFile)
|
||||
val file = resolve(tempFile.name.removeSuffix(TEMP_SUFFIX))
|
||||
file.delete()
|
||||
lock.write { fileCache.remove(file.name) }
|
||||
tempFile.moveTo(file)
|
||||
lock.write {
|
||||
fileCache.remove(tempFile.name)
|
||||
fileCache[file.name] = file
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -207,9 +224,11 @@ 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" }
|
||||
FileHashRegex.find(url)?.let {
|
||||
val expected = it.groupValues[1]
|
||||
val actual = outFile.sha1()
|
||||
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,11 +260,11 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
|
||||
fun getExtension(index: Int): String? {
|
||||
val key = getImageKey(gid, index)
|
||||
return sCache.read(key) { metadata.toNioPath().readText() }
|
||||
return sCache.read(key) { metadata.toFile().readText() }
|
||||
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
|
||||
}
|
||||
|
||||
fun getImageSource(index: Int): PathSource? {
|
||||
fun getImageSource(index: Int): PathSource {
|
||||
if (mode == SpiderQueen.MODE_READ) {
|
||||
val key = getImageKey(gid, index)
|
||||
val snapshot = sCache.openSnapshot(key)
|
||||
@@ -253,12 +272,12 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return object : PathSource, AutoCloseable by snapshot {
|
||||
override val source = snapshot.data
|
||||
override val type by lazy {
|
||||
snapshot.metadata.toNioPath().readText()
|
||||
snapshot.metadata.toFile().readText()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val source = findImageFile(index) ?: return null
|
||||
val source = requireNotNull(findImageFile(index)) { "Source $index not found!" }
|
||||
return object : PathSource {
|
||||
override val source = source
|
||||
override val type by lazy {
|
||||
@@ -300,8 +319,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
return archived
|
||||
}
|
||||
|
||||
suspend fun exportAsCbz(file: Path) =
|
||||
downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
|
||||
suspend fun exportAsCbz(file: Path) = downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
|
||||
|
||||
private suspend fun archiveTo(file: Path) = resourceScope {
|
||||
val comicInfo = closeable {
|
||||
@@ -315,7 +333,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
val pages = info.pages
|
||||
val (fdBatch, names) = (0 until pages).parMap { idx ->
|
||||
val f = autoCloseable { getImageSource(idx) ?: throw CancellationException("Image #$idx not found") }
|
||||
val f = autoCloseable { getImageSource(idx) }
|
||||
closeable { f.source.openFileDescriptor("r") }.fd to perFilename(idx, f.type)
|
||||
}.run { plus(comicInfo.fd to COMIC_INFO_FILE) }.unzip()
|
||||
val arcFd = closeable { file.openFileDescriptor("rw") }
|
||||
@@ -328,7 +346,7 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
|
||||
suspend fun initDownloadDir() {
|
||||
downloadDir = getGalleryDownloadDir(info)
|
||||
downloadDir = getGalleryDownloadDir(info).apply { mkdirs() }
|
||||
}
|
||||
|
||||
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
|
||||
@@ -353,8 +371,9 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
private const val TEMP_SUFFIX = ".tmp"
|
||||
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
|
||||
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
|
||||
private val FileHashRegex = Regex("/h/([0-9a-f]{40})")
|
||||
|
||||
fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
package com.hippo.ehviewer.spider
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import arrow.core.partially1
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -36,31 +34,26 @@ import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
|
||||
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.files.find
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TimeSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
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
|
||||
@@ -69,10 +62,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
|
||||
@Volatile
|
||||
lateinit var mPageStateArray: IntArray
|
||||
lateinit var mSpiderInfo: SpiderInfo
|
||||
lateinit var pageStates: IntArray
|
||||
lateinit var spiderInfo: SpiderInfo
|
||||
|
||||
val mSpiderDen: SpiderDen = SpiderDen(galleryInfo)
|
||||
val spiderDen: SpiderDen = SpiderDen(galleryInfo)
|
||||
private val mPageStateLock = Any()
|
||||
private val mDownloadedPages = AtomicInteger(0)
|
||||
private val mFinishedPages = AtomicInteger(0)
|
||||
@@ -121,7 +114,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
index,
|
||||
mFinishedPages.get(),
|
||||
mDownloadedPages.get(),
|
||||
mPageStateArray.size,
|
||||
pageStates.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,7 +128,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
error,
|
||||
mFinishedPages.get(),
|
||||
mDownloadedPages.get(),
|
||||
mPageStateArray.size,
|
||||
pageStates.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,50 +140,34 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
it.onFinish(
|
||||
mFinishedPages.get(),
|
||||
mDownloadedPages.get(),
|
||||
mPageStateArray.size,
|
||||
pageStates.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyGetImageSuccess(index: Int, image: Image) {
|
||||
synchronized(mSpiderListeners) {
|
||||
mSpiderListeners.forEach {
|
||||
it.onGetImageSuccess(index, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyGetImageFailure(index: Int, error: String) {
|
||||
synchronized(mSpiderListeners) {
|
||||
mSpiderListeners.forEach {
|
||||
it.onGetImageFailure(index, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var downloadMode = false
|
||||
val isReady
|
||||
get() = this::mSpiderInfo.isInitialized && this::mPageStateArray.isInitialized
|
||||
get() = this::spiderInfo.isInitialized && this::pageStates.isInitialized
|
||||
|
||||
private val updateLock = Mutex()
|
||||
|
||||
private suspend fun updateMode() {
|
||||
if (!awaitReady()) return
|
||||
private suspend fun updateMode() = runSuspendCatching {
|
||||
awaitReady()
|
||||
updateLock.withLock {
|
||||
val mode: Int = if (mDownloadReference > 0) {
|
||||
MODE_DOWNLOAD
|
||||
} else {
|
||||
MODE_READ
|
||||
}
|
||||
mSpiderDen.setMode(mode)
|
||||
spiderDen.setMode(mode)
|
||||
|
||||
// Update download page
|
||||
val intoDownloadMode = mode == MODE_DOWNLOAD
|
||||
if (intoDownloadMode && !downloadMode) {
|
||||
// Clear download state
|
||||
synchronized(mPageStateLock) {
|
||||
val temp: IntArray = mPageStateArray
|
||||
val temp: IntArray = pageStates
|
||||
var i = 0
|
||||
val n = temp.size
|
||||
while (i < n) {
|
||||
@@ -225,20 +202,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
check(!(mReadReference < 0 || mDownloadReference < 0)) { "Mode reference < 0" }
|
||||
}
|
||||
|
||||
private val prepareJob = async { doPrepare() }
|
||||
private val archiveJob = launch(start = CoroutineStart.LAZY) { mSpiderDen.archive() }
|
||||
private val prepareScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
private val prepareJob = prepareScope.async { doPrepare() }
|
||||
private val archiveJob = launch(start = CoroutineStart.LAZY) { spiderDen.archive() }
|
||||
|
||||
private suspend fun doPrepare() {
|
||||
mSpiderDen.initDownloadDirIfExist()
|
||||
mSpiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet() ?: return
|
||||
mPageStateArray = IntArray(mSpiderInfo.pages)
|
||||
notifyGetPages(mSpiderInfo.pages)
|
||||
spiderDen.initDownloadDirIfExist()
|
||||
spiderInfo = readSpiderInfoFromLocal() ?: readSpiderInfoFromInternet()
|
||||
pageStates = IntArray(spiderInfo.pages)
|
||||
check(spiderInfo.pages > 0)
|
||||
notifyGetPages(spiderInfo.pages)
|
||||
}
|
||||
|
||||
suspend fun awaitReady(): Boolean {
|
||||
prepareJob.await()
|
||||
return isReady
|
||||
}
|
||||
suspend fun awaitReady() = prepareJob.await()
|
||||
|
||||
private fun stop() {
|
||||
val queenScope = this
|
||||
@@ -246,9 +222,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (archiveJob.isActive) {
|
||||
archiveJob.join()
|
||||
}
|
||||
if (!mSpiderDen.postArchive()) {
|
||||
if (!spiderDen.postArchive()) {
|
||||
if (mWorkerScope.isDownloadMode) {
|
||||
mSpiderDen.writeComicInfo()
|
||||
spiderDen.writeComicInfo()
|
||||
}
|
||||
runCatching {
|
||||
writeSpiderInfoToLocal()
|
||||
@@ -261,38 +237,26 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
val size
|
||||
get() = mPageStateArray.size
|
||||
get() = pageStates.size
|
||||
|
||||
val error: String?
|
||||
get() = null
|
||||
|
||||
fun forceRequest(index: Int, orgImg: Boolean) {
|
||||
request(index, true, orgImg)
|
||||
}
|
||||
|
||||
fun request(index: Int) {
|
||||
request(index, false)
|
||||
}
|
||||
|
||||
private fun getPageState(index: Int): Int {
|
||||
synchronized(mPageStateLock) {
|
||||
return if (index >= 0 && index < mPageStateArray.size) {
|
||||
mPageStateArray[index]
|
||||
return if (index >= 0 && index < pageStates.size) {
|
||||
pageStates[index]
|
||||
} else {
|
||||
STATE_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest(index: Int) {
|
||||
mWorkerScope.cancelDecode(index)
|
||||
}
|
||||
|
||||
fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {
|
||||
fun preloadPages(pages: List<Int>, pair: IntRange) {
|
||||
mWorkerScope.updateRAList(pages, pair)
|
||||
}
|
||||
|
||||
private fun request(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
fun request(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
// Get page state
|
||||
val state = getPageState(index)
|
||||
|
||||
@@ -309,7 +273,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
return if (STATE_FINISHED != state) {
|
||||
false
|
||||
} else {
|
||||
mSpiderDen.saveToPath(index, file)
|
||||
spiderDen.saveToPath(index, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,11 +282,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
return if (STATE_FINISHED != state) {
|
||||
null
|
||||
} else {
|
||||
mSpiderDen.getExtension(index)
|
||||
spiderDen.getExtension(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
|
||||
private fun readSpiderInfoFromLocal(): SpiderInfo? = spiderDen.downloadDir?.run {
|
||||
find(SPIDER_INFO_FILENAME)?.let { file ->
|
||||
readCompatFromPath(file)?.takeIf {
|
||||
it.gid == galleryInfo.gid && it.token == galleryInfo.token
|
||||
@@ -349,22 +313,18 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
private suspend fun readSpiderInfoFromInternet() = 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
|
||||
}
|
||||
|
||||
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
|
||||
val spiderInfo = mSpiderInfo
|
||||
val spiderInfo = spiderInfo
|
||||
val url = getGalleryMultiPageViewerUrl(
|
||||
galleryInfo.gid,
|
||||
galleryInfo.token,
|
||||
@@ -383,7 +343,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
suspend fun getPTokenFromInternet(index: Int): String? {
|
||||
val spiderInfo = mSpiderInfo
|
||||
val spiderInfo = spiderInfo
|
||||
|
||||
// Check previewIndex
|
||||
var previewIndex: Int
|
||||
@@ -415,8 +375,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
@Synchronized
|
||||
private fun writeSpiderInfoToLocal() {
|
||||
if (!isReady) return
|
||||
mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
|
||||
mSpiderInfo.saveToCache()
|
||||
spiderDen.downloadDir?.run { spiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
|
||||
spiderInfo.saveToCache()
|
||||
}
|
||||
|
||||
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
|
||||
@@ -424,8 +384,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
|
||||
var oldState: Int
|
||||
synchronized<Unit>(mPageStateLock) {
|
||||
oldState = mPageStateArray[index]
|
||||
mPageStateArray[index] = state
|
||||
oldState = pageStates[index]
|
||||
pageStates[index] = state
|
||||
if (!isStateDone(oldState) && isStateDone(state)) {
|
||||
mDownloadedPages.incrementAndGet()
|
||||
} else if (isStateDone(oldState) && !isStateDone(state)) {
|
||||
@@ -458,14 +418,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class State
|
||||
interface OnSpiderListener {
|
||||
fun onGetPages(pages: Int)
|
||||
fun onGet509(index: Int)
|
||||
fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int)
|
||||
fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int)
|
||||
fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int)
|
||||
fun onFinish(finished: Int, downloaded: Int, total: Int)
|
||||
fun onGetImageSuccess(index: Int, image: Image?)
|
||||
fun onGetImageFailure(index: Int, error: String?)
|
||||
fun onGetPages(pages: Int) {}
|
||||
fun onGet509(index: Int) {}
|
||||
fun onPageDownload(index: Int, contentLength: Long, receivedSize: Long, bytesRead: Int) {}
|
||||
fun onPageSuccess(index: Int, finished: Int, downloaded: Int, total: Int) {}
|
||||
fun onPageFailure(index: Int, error: String?, finished: Int, downloaded: Int, total: Int) {}
|
||||
fun onFinish(finished: Int, downloaded: Int, total: Int) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -476,11 +434,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
const val STATE_FINISHED = 2
|
||||
const val STATE_FAILED = 3
|
||||
const val SPIDER_INFO_FILENAME = ".ehviewer"
|
||||
private val sQueenMap = LongSparseArray<SpiderQueen>()
|
||||
private val sQueenMap = mutableMapOf<Long, SpiderQueen>()
|
||||
|
||||
fun obtainSpiderQueen(galleryInfo: GalleryInfo, @Mode mode: Int): SpiderQueen {
|
||||
val gid = galleryInfo.gid
|
||||
return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply {
|
||||
return (sQueenMap.getOrPut(gid) { SpiderQueen(galleryInfo) }).apply {
|
||||
setMode(mode)
|
||||
launch { updateMode() }
|
||||
}
|
||||
@@ -500,22 +458,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
private val mWorkerScope = object {
|
||||
private val mFetcherJobMap = hashMapOf<Int, Job>()
|
||||
private val mSemaphore = Semaphore(Settings.multiThreadDownload)
|
||||
private val jobs = hashMapOf<Int, Job>()
|
||||
private val semaphore = Semaphore(Settings.multiThreadDownload)
|
||||
private val pTokenLock = Mutex()
|
||||
private var showKey: String? = null
|
||||
private val showKeyLock = Mutex()
|
||||
private val mDownloadDelay = Settings.downloadDelay.milliseconds
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private val downloadDelay = Settings.downloadDelay.milliseconds
|
||||
private var lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
var isDownloadMode = false
|
||||
private set
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
decoder.cancel(index)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun enterDownloadMode() {
|
||||
if (isDownloadMode) return
|
||||
@@ -523,16 +475,21 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
isDownloadMode = true
|
||||
}
|
||||
|
||||
fun updateRAList(list: List<Int>, cancelBounds: Pair<Int, Int> = 0 to Int.MAX_VALUE) {
|
||||
fun updateRAList(list: List<Int>, aliveBound: IntRange = 0..Int.MAX_VALUE) {
|
||||
val absent = list.filterNot { index ->
|
||||
(pageStates[index] == STATE_FINISHED).also { finished ->
|
||||
if (finished) notifyPageSuccess(index)
|
||||
}
|
||||
}
|
||||
if (isDownloadMode) return
|
||||
synchronized(mFetcherJobMap) {
|
||||
mFetcherJobMap.forEach { (i, job) ->
|
||||
if (i < cancelBounds.first || i > cancelBounds.second) {
|
||||
synchronized(jobs) {
|
||||
jobs.forEach { (i, job) ->
|
||||
if (i !in aliveBound) {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
list.forEach {
|
||||
if (mFetcherJobMap[it]?.isActive != true) {
|
||||
absent.forEach {
|
||||
if (jobs[it]?.isActive != true) {
|
||||
doLaunchDownloadJob(it, false)
|
||||
}
|
||||
}
|
||||
@@ -540,22 +497,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
private fun doLaunchDownloadJob(index: Int, force: Boolean, orgImg: Boolean = false) {
|
||||
val state = mPageStateArray[index]
|
||||
if (!force && state == STATE_FINISHED) return
|
||||
val currentJob = mFetcherJobMap[index]
|
||||
val currentJob = jobs[index]
|
||||
val skipHath = force && !orgImg && currentJob?.isActive == true
|
||||
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
jobs[index] = launch {
|
||||
runCatching {
|
||||
if (!force && index in mSpiderDen) {
|
||||
return@runCatching updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
delayLock.withLock {
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
mSemaphore.withPermit {
|
||||
semaphore.withPermit {
|
||||
doInJob(index, force, orgImg, skipHath)
|
||||
}
|
||||
}.onFailure {
|
||||
@@ -576,36 +524,42 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
fun launch(index: Int, force: Boolean = false, orgImg: Boolean) {
|
||||
check(index in 0 until size)
|
||||
val state = pageStates[index]
|
||||
if (!force && state == STATE_FINISHED) return notifyPageSuccess(index)
|
||||
if (!isDownloadMode) {
|
||||
synchronized(mFetcherJobMap) { doLaunchDownloadJob(index, force, orgImg) }
|
||||
synchronized(jobs) { doLaunchDownloadJob(index, force, orgImg) }
|
||||
}
|
||||
if (force) decoder.cancel(index)
|
||||
decoder.launch(index)
|
||||
}
|
||||
|
||||
private suspend fun doInJob(index: Int, force: Boolean, orgImg: Boolean, skipHath: Boolean) {
|
||||
suspend fun getPToken(index: Int): String? {
|
||||
if (index !in 0 until size) return null
|
||||
return mSpiderInfo.pTokenMap[index].takeIf { it != TOKEN_FAILED }
|
||||
return spiderInfo.pTokenMap[index].takeIf { it != TOKEN_FAILED }
|
||||
?: getPTokenFromInternet(index)
|
||||
?: getPTokenFromInternet(index)
|
||||
?: getPTokenFromMultiPageViewer(index)
|
||||
}
|
||||
updatePageState(index, STATE_DOWNLOADING)
|
||||
val previousPToken: String?
|
||||
val pToken: String
|
||||
pTokenLock.withLock {
|
||||
if (force) {
|
||||
if (mSpiderInfo.pTokenMap[index] == TOKEN_FAILED) {
|
||||
mSpiderInfo.pTokenMap.remove(index)
|
||||
if (spiderInfo.pTokenMap[index] == TOKEN_FAILED) {
|
||||
spiderInfo.pTokenMap.remove(index)
|
||||
}
|
||||
} else if (index in spiderDen) {
|
||||
return updatePageState(index, STATE_FINISHED)
|
||||
}
|
||||
pToken = getPToken(index)
|
||||
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
|
||||
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
|
||||
spiderInfo.pTokenMap[index] = TOKEN_FAILED
|
||||
}
|
||||
previousPToken = getPToken(index - 1)
|
||||
|
||||
// The lock for delay should be acquired before anything else to maintain FIFO order
|
||||
delay(downloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
updatePageState(index, STATE_DOWNLOADING)
|
||||
|
||||
var skipHathKey: String? = null
|
||||
var originImageUrl: String? = null
|
||||
@@ -637,7 +591,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (imageUrl == null) {
|
||||
runSuspendCatching {
|
||||
EhEngine.getGalleryPageApi(
|
||||
mSpiderInfo.gid,
|
||||
spiderInfo.gid,
|
||||
index,
|
||||
pToken,
|
||||
localShowKey,
|
||||
@@ -659,48 +613,38 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
return@repeat
|
||||
}
|
||||
|
||||
val targetImageUrl: String?
|
||||
val referer: String?
|
||||
|
||||
if (original && originImageUrl != null) {
|
||||
val (targetImageUrl, referer) = if (original && originImageUrl != null) {
|
||||
if (retries == 1 && skipHathKey != null) {
|
||||
originImageUrl += "?nl=$skipHathKey"
|
||||
}
|
||||
val pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken)
|
||||
targetImageUrl = EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl)
|
||||
referer = EhUrl.referer
|
||||
val pageUrl = EhUrl.getPageUrl(spiderInfo.gid, index, pToken)
|
||||
EhEngine.getOriginalImageUrl(originImageUrl!!, pageUrl) to EhUrl.referer
|
||||
} else {
|
||||
// Original image url won't change, so only set forceHtml in this case
|
||||
forceHtml = true
|
||||
targetImageUrl = imageUrl
|
||||
referer = null
|
||||
imageUrl to null
|
||||
}
|
||||
checkNotNull(targetImageUrl)
|
||||
|
||||
repeat(2) { times ->
|
||||
runCatching {
|
||||
logcat(WORKER_DEBUG_TAG) { "Start download image $index attempt #$times" }
|
||||
val success = withTimeout(downloadTimeout) {
|
||||
mSpiderDen.makeHttpCallAndSaveImage(
|
||||
index,
|
||||
targetImageUrl,
|
||||
referer,
|
||||
this@SpiderQueen::notifyPageDownload.partially1(index),
|
||||
)
|
||||
}
|
||||
val success = spiderDen.makeHttpCallAndSaveImage(
|
||||
index,
|
||||
targetImageUrl,
|
||||
referer,
|
||||
this@SpiderQueen::notifyPageDownload.partially1(index),
|
||||
)
|
||||
|
||||
check(success)
|
||||
logcat(WORKER_DEBUG_TAG) { "Download image $index succeed" }
|
||||
updatePageState(index, STATE_FINISHED)
|
||||
return
|
||||
}.onFailure {
|
||||
mSpiderDen.remove(index)
|
||||
spiderDen.removeIntermediateFiles(index)
|
||||
logcat(WORKER_DEBUG_TAG) { "Download image $index attempt #$times failed" }
|
||||
error = when (it) {
|
||||
is TimeoutCancellationException -> ERROR_TIMEOUT
|
||||
is CancellationException -> throw it
|
||||
else -> it.displayString()
|
||||
}
|
||||
if (it is CancellationException) throw it
|
||||
error = it.displayString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,61 +657,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
updatePageState(index, STATE_FAILED, error)
|
||||
}
|
||||
|
||||
private val decoder = object {
|
||||
private val mSemaphore = Semaphore(4)
|
||||
private val mDecodeJobMap = hashMapOf<Int, Job>()
|
||||
|
||||
fun cancel(index: Int) {
|
||||
synchronized(mDecodeJobMap) {
|
||||
mDecodeJobMap.remove(index)?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun launch(index: Int) {
|
||||
synchronized(mDecodeJobMap) {
|
||||
val currentJob = mDecodeJobMap[index]
|
||||
if (currentJob?.isActive != true) {
|
||||
mDecodeJobMap[index] = launch {
|
||||
runCatching {
|
||||
mSemaphore.withPermit {
|
||||
mFetcherJobMap[index]?.join()
|
||||
if (mPageStateArray[index] == STATE_FINISHED) {
|
||||
doInJob(index)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (mPageStateArray[index] == STATE_FINISHED) {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
}
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doInJob(index: Int) {
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = Image.decode(src)
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyGetImageSuccess(index, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PTOKEN_FAILED_MESSAGE = appCtx.getString(R.string.error_get_ptoken_error)
|
||||
private val ERROR_TIMEOUT = appCtx.getString(R.string.error_timeout)
|
||||
private val DECODE_ERROR = appCtx.getString(R.string.error_decoding_failed)
|
||||
private val URL_509_PATTERN = Regex("\\.org/.+/509s?\\.gif")
|
||||
private const val FORCE_RETRY = "Force retry"
|
||||
private const val WORKER_DEBUG_TAG = "SpiderQueenWorker"
|
||||
|
||||
@@ -106,8 +106,7 @@ suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getFavoriteIcon(favorited: Boolean) =
|
||||
if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
|
||||
fun getFavoriteIcon(favorited: Boolean) = if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
|
||||
|
||||
suspend fun DialogState.startDownload(
|
||||
context: Context,
|
||||
@@ -281,14 +280,11 @@ suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorite
|
||||
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
|
||||
)
|
||||
|
||||
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) =
|
||||
navToReader(ReaderScreenArgs.Gallery(info, page))
|
||||
fun DestinationsNavigator.navToReader(info: BaseGalleryInfo, page: Int = -1) = navToReader(ReaderScreenArgs.Gallery(info, page))
|
||||
|
||||
fun DestinationsNavigator.navToReader(uri: Uri) =
|
||||
navToReader(ReaderScreenArgs.Archive(uri))
|
||||
fun DestinationsNavigator.navToReader(uri: Uri) = navToReader(ReaderScreenArgs.Archive(uri))
|
||||
|
||||
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) =
|
||||
navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
|
||||
private fun DestinationsNavigator.navToReader(args: ReaderScreenArgs) = navigate(ReaderScreenDestination(args)) { launchSingleTop = true }
|
||||
|
||||
context(DialogState, Context, DestinationsNavigator)
|
||||
suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -62,10 +62,10 @@ private fun GalleryDetail.generateContent() = arrayOf(
|
||||
R.string.favorite_name to favoriteName,
|
||||
)
|
||||
|
||||
context(Context, DestinationsNavigator)
|
||||
@Composable
|
||||
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
|
||||
fun GalleryInfoBottomSheet(detail: GalleryDetail) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
text = stringResource(id = R.string.gallery_info),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
@@ -79,10 +79,10 @@ fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigat
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
|
||||
if (index == INDEX_PARENT) {
|
||||
if (content != null) {
|
||||
navigator.navWithUrl(content)
|
||||
navWithUrl(content)
|
||||
}
|
||||
} else {
|
||||
context.addTextToClipboard(content, true)
|
||||
addTextToClipboard(content, true)
|
||||
if (index == INDEX_URL) {
|
||||
// Save it to avoid detect the gallery
|
||||
Settings.clipboardTextHashCode = data[index].hashCode()
|
||||
|
||||
@@ -26,7 +26,6 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -138,6 +137,7 @@ 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.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.NoopSharedTransitionScope
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
@@ -234,7 +234,7 @@ class MainActivity : EhActivity() {
|
||||
suspend fun DialogState.checkDownloadLocation() {
|
||||
val valid = withIOContext { downloadLocation.isDirectory }
|
||||
if (!valid) {
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.open_settings,
|
||||
title = R.string.waring,
|
||||
showCancelButton = false,
|
||||
@@ -248,18 +248,24 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val hasNetwork = remember { connectivityManager.activeNetwork != null }
|
||||
if (!AppConfig.isBenchmark) {
|
||||
val noNetwork = stringResource(R.string.no_network)
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { dialogState.checkDownloadLocation() }
|
||||
runCatching { dialogState.checkAppLinkVerify() }
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
if (hasNetwork) {
|
||||
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()))
|
||||
} else {
|
||||
snackbarState.showSnackbar(noNetwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +376,7 @@ class MainActivity : EhActivity() {
|
||||
val drawerEnabled = drawerHandle.isNotEmpty()
|
||||
val density = LocalDensity.current
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val needSignIn by Settings.needSignIn.collectAsState()
|
||||
CompositionLocalProvider(
|
||||
LocalNavDrawerState provides navDrawerState,
|
||||
LocalSideSheetState provides sideSheetState,
|
||||
@@ -463,16 +470,22 @@ class MainActivity : EhActivity() {
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && drawerEnabled,
|
||||
) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalSharedTransitionScope provides this) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
start = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
// https://issuetracker.google.com/336140982
|
||||
// SharedTransitionLayout {
|
||||
CompositionLocalProvider(LocalSharedTransitionScope provides NoopSharedTransitionScope) {
|
||||
val start = when {
|
||||
needSignIn -> SignInScreenDestination
|
||||
hasNetwork -> StartDestination
|
||||
else -> DownloadsScreenDestination
|
||||
}
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
start = start,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,7 +507,7 @@ class MainActivity : EhActivity() {
|
||||
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
|
||||
if (hasUnverified) {
|
||||
var checked by mutableStateOf(false)
|
||||
awaitPermissionOrCancel(
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.open_settings,
|
||||
title = R.string.app_link_not_verified_title,
|
||||
onCancelButtonClick = {
|
||||
@@ -525,7 +538,7 @@ class MainActivity : EhActivity() {
|
||||
Uri.parse("package:$packageName"),
|
||||
)
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
} catch (_: Throwable) {
|
||||
val intent = Intent(
|
||||
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:$packageName"),
|
||||
|
||||
@@ -94,8 +94,7 @@ class SecurityActivity : AppCompatActivity() {
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) = onAuthenticationSucceeded(activity, result)
|
||||
|
||||
override fun onAuthenticationFailed(activity: FragmentActivity?) =
|
||||
onAuthenticationFailed(activity)
|
||||
override fun onAuthenticationFailed(activity: FragmentActivity?) = onAuthenticationFailed(activity)
|
||||
}
|
||||
|
||||
private fun onSuccess() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -19,12 +20,10 @@ import soup.compose.material.motion.animation.materialSharedAxisXOut
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
|
||||
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
EhTheme(useDarkTheme = isSystemInDarkTheme()) {
|
||||
ProvideVectorPainterCache {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalDialogState provides remember { DialogState() }.apply { Intercept() },
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -36,11 +36,7 @@ fun Context.openBrowser(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
context(Context, DestinationsNavigator)
|
||||
fun Context.jumpToReaderByPage(
|
||||
url: String,
|
||||
detail: GalleryDetail,
|
||||
): Boolean {
|
||||
fun DestinationsNavigator.jumpToReaderByPage(url: String, detail: GalleryDetail): Boolean {
|
||||
GalleryPageUrlParser.parse(url)?.let {
|
||||
if (it.gid == detail.gid) {
|
||||
navToReader(detail.galleryInfo, it.page)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.legacy
|
||||
|
||||
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 coil3.size.Size
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
class CoilImageGetter(
|
||||
private val textView: TextView,
|
||||
) : Html.ImageGetter {
|
||||
override fun getDrawable(source: String) = object : DrawableWrapper(null) {}.apply {
|
||||
with(textView.context) {
|
||||
imageLoader.enqueue(
|
||||
imageRequest {
|
||||
data(source)
|
||||
crossfade(false)
|
||||
size(Size.ORIGINAL)
|
||||
target { drawable ->
|
||||
setDrawable(drawable.asDrawable(resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2015 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import android.text.style.ClickableSpan
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
|
||||
class LinkifyTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
private val onCurrentSpanUpdate: (ClickableSpan?) -> Unit,
|
||||
) : AppCompatTextView(context, attrs) {
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
// Let the parent or grandparent of TextView to handles click aciton.
|
||||
// Otherwise click effect like ripple will not work, and if touch area
|
||||
// do not contain a url, the TextView will still get MotionEvent.
|
||||
// onTouchEven must be called with MotionEvent.ACTION_DOWN for each touch
|
||||
// action on it, so we analyze touched url here.
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
onCurrentSpanUpdate(null)
|
||||
if (text is Spanned) {
|
||||
var x = event.x.toInt()
|
||||
var y = event.y.toInt()
|
||||
x -= totalPaddingLeft
|
||||
y -= totalPaddingTop
|
||||
x += scrollX
|
||||
y += scrollY
|
||||
val layout = layout
|
||||
if (null != layout) {
|
||||
val line = layout.getLineForVertical(y)
|
||||
val off = layout.getOffsetForHorizontal(line, x.toFloat())
|
||||
val spans = (text as Spanned).getSpans(off, off, ClickableSpan::class.java)
|
||||
if (spans.isNotEmpty()) {
|
||||
onCurrentSpanUpdate(spans[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,26 @@ import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
suspend fun postLogin() = coroutineScope {
|
||||
launch {
|
||||
runCatching {
|
||||
EhEngine.getProfile().run {
|
||||
Settings.displayName.value = displayName
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
suspend fun CoroutineScope.refreshAccountInfo() = runSuspendCatching {
|
||||
with(EhEngine.getProfile()) {
|
||||
Settings.displayName.value = displayName
|
||||
Settings.avatar.value = avatar
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun postLogin() = GlobalScope.async(Dispatchers.IO) {
|
||||
launch { refreshAccountInfo() }
|
||||
runCatching {
|
||||
// For the `star` cookie
|
||||
EhEngine.getNews(false)
|
||||
@@ -33,13 +40,13 @@ suspend fun postLogin() = coroutineScope {
|
||||
}
|
||||
|
||||
// Sad panda check
|
||||
Settings.gallerySite = EhUrl.SITE_EX
|
||||
// Explicitly use ex url since https://github.com/Ehviewer-Overhauled/Ehviewer/issues/1239#issuecomment-1632584525
|
||||
EhEngine.getUConfig(EhUrl.URL_UCONFIG_EX)
|
||||
EhCookieStore.flush()
|
||||
Settings.gallerySite.value = EhUrl.SITE_EX
|
||||
}.onFailure {
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
Settings.gallerySite.value = EhUrl.SITE_E
|
||||
}
|
||||
|
||||
Settings.hasSignedIn.value = true
|
||||
Settings.needSignIn = false
|
||||
Settings.needSignIn.value = false
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextObfuscationMode
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
@@ -27,6 +30,7 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedSecureTextField
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -49,8 +53,6 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -60,10 +62,8 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.ui.StartDestination
|
||||
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.autofill
|
||||
@@ -72,7 +72,6 @@ import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
@@ -85,8 +84,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
var isProgressIndicatorVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var showUsernameError by rememberSaveable { mutableStateOf(false) }
|
||||
var showPasswordError by rememberSaveable { mutableStateOf(false) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
val username = rememberTextFieldState()
|
||||
val password = rememberTextFieldState()
|
||||
var passwordHidden by rememberSaveable { mutableStateOf(true) }
|
||||
val context = LocalContext.current
|
||||
var signInJob by remember { mutableStateOf<Job?>(null) }
|
||||
@@ -94,13 +93,13 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
|
||||
fun signIn() {
|
||||
if (signInJob?.isActive == true) return
|
||||
if (username.isEmpty()) {
|
||||
if (username.text.isEmpty()) {
|
||||
showUsernameError = true
|
||||
return
|
||||
} else {
|
||||
showUsernameError = false
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
if (password.text.isEmpty()) {
|
||||
showPasswordError = true
|
||||
return
|
||||
} else {
|
||||
@@ -112,11 +111,12 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
EhUtils.signOut()
|
||||
signInJob = coroutineScope.launchIO {
|
||||
runCatching {
|
||||
EhEngine.signIn(username, password)
|
||||
EhEngine.signIn(username.text.toString(), password.text.toString())
|
||||
}.onFailure {
|
||||
withUIContext {
|
||||
focusManager.clearFocus()
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
isProgressIndicatorVisible = false
|
||||
dialogState.awaitConfirmationOrCancel(
|
||||
confirmText = R.string.get_it,
|
||||
title = R.string.sign_in_failed,
|
||||
showCancelButton = false,
|
||||
@@ -129,11 +129,9 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
)
|
||||
},
|
||||
)
|
||||
isProgressIndicatorVisible = false
|
||||
}
|
||||
}.onSuccess {
|
||||
withNonCancellableContext { postLogin() }
|
||||
withUIContext { navigator.popNavigate(StartDestination) }
|
||||
postLogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,31 +139,28 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
@Composable
|
||||
fun UsernameAndPasswordTextField() {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
state = username,
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
|
||||
autofillTypes = listOf(AutofillType.Username),
|
||||
onFill = { username = it },
|
||||
onFill = { username.setTextAndPlaceCursorAtEnd(it) },
|
||||
),
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
|
||||
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
singleLine = true,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
isError = showUsernameError,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
OutlinedSecureTextField(
|
||||
state = password,
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { password = it },
|
||||
onFill = { password.setTextAndPlaceCursorAtEnd(it) },
|
||||
),
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardActions = KeyboardActions(onDone = { signIn() }),
|
||||
onKeyboardAction = { signIn() },
|
||||
trailingIcon = {
|
||||
if (showPasswordError) {
|
||||
Icon(imageVector = Icons.Filled.Info, contentDescription = null)
|
||||
@@ -176,8 +171,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
isError = showPasswordError,
|
||||
textObfuscationMode = if (passwordHidden) TextObfuscationMode.RevealLastTyped else TextObfuscationMode.Visible,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -236,9 +231,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
Settings.gallerySite.value = EhUrl.SITE_E
|
||||
Settings.needSignIn.value = false
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
@@ -319,9 +313,8 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
Settings.gallerySite.value = EhUrl.SITE_E
|
||||
Settings.needSignIn.value = false
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
|
||||
) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.hippo.ehviewer.ui.login
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.google.accompanist.web.AccompanistWebViewClient
|
||||
import com.google.accompanist.web.WebView
|
||||
@@ -13,35 +13,26 @@ import com.google.accompanist.web.rememberWebViewState
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.ui.StartDestination
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.util.bgWork
|
||||
import com.hippo.ehviewer.util.setDefaultSettings
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun AnimatedVisibilityScope.WebViewSignInScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
|
||||
val client = remember {
|
||||
object : AccompanistWebViewClient() {
|
||||
private var present = false
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (present) {
|
||||
view.destroy()
|
||||
return
|
||||
}
|
||||
if (EhCookieStore.hasSignedIn()) {
|
||||
present = true
|
||||
coroutineScope.launchIO {
|
||||
withNonCancellableContext { postLogin() }
|
||||
withUIContext { navigator.popNavigate(StartDestination) }
|
||||
}
|
||||
postLogin()
|
||||
view.destroy()
|
||||
launch { bgWork { awaitCancellation() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,9 +43,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onCreated = {
|
||||
it.setDefaultSettings()
|
||||
},
|
||||
onCreated = { it.setDefaultSettings() },
|
||||
client = client,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NoAccounts
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import arrow.core.Either
|
||||
import arrow.core.Either.Companion.catch
|
||||
import arrow.core.Option
|
||||
import arrow.core.none
|
||||
import arrow.core.some
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSwallowingWithUI
|
||||
|
||||
private val limitScope = CoroutineScope(Dispatchers.IO)
|
||||
private val refreshEvent = MutableSharedFlow<Unit>()
|
||||
private val invalidateEvent = MutableSharedFlow<Unit>()
|
||||
|
||||
typealias Result = Option<Either<String, HomeParser.Result>>
|
||||
|
||||
private val limitFlow: StateFlow<Result> = refreshEvent.conflate()
|
||||
.onEach {
|
||||
// If cached value is error, drop it when refresh
|
||||
val isError = limitFlow.value.isSome { it.isLeft() }
|
||||
if (isError) invalidateEvent.emit(Unit)
|
||||
}
|
||||
.map { catch { EhEngine.getImageLimits() }.mapLeft { e -> e.displayString() }.some() }
|
||||
.let { src -> merge(src, invalidateEvent.map { none() }) }
|
||||
.stateIn(limitScope, SharingStarted.Eagerly, none())
|
||||
|
||||
context(CoroutineScope, DialogState, SnackbarHostState, DestinationsNavigator)
|
||||
@Composable
|
||||
fun AvatarIcon() {
|
||||
val hasSignedIn by Settings.hasSignedIn.collectAsState()
|
||||
if (hasSignedIn) {
|
||||
val placeholder = stringResource(id = R.string.please_wait)
|
||||
val result by limitFlow.collectAsState()
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
refreshEvent.emit(Unit)
|
||||
awaitConfirmationOrCancel(
|
||||
title = R.string.image_limits,
|
||||
showConfirmButton = false,
|
||||
showCancelButton = false,
|
||||
) {
|
||||
val animatedAlpha by animateFloatAsState(if (remember { result } == result) 0.5f else 1f)
|
||||
result.onNone {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Text(text = placeholder)
|
||||
}
|
||||
}
|
||||
result.onSome { current ->
|
||||
Box(modifier = Modifier.graphicsLayer { alpha = animatedAlpha }) {
|
||||
when (current) {
|
||||
is Either.Left -> Text(text = current.value)
|
||||
is Either.Right -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val (limits, funds) = current.value
|
||||
if (limits.maximum > 0) {
|
||||
val value by animateFloatAsState(limits.current.toFloat() / limits.maximum)
|
||||
LinearProgressIndicator(
|
||||
progress = { value },
|
||||
modifier = Modifier.height(12.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
when (limits.maximum) {
|
||||
-1 -> Text(text = stringResource(id = R.string.image_limits_restricted))
|
||||
0 -> Text(text = stringResource(id = R.string.image_limits_normal))
|
||||
else -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = stringResource(id = R.string.image_limits_summary))
|
||||
RollingNumber(number = limits.current)
|
||||
Text(text = " / ")
|
||||
RollingNumberPlaceholder(number = limits.maximum)
|
||||
}
|
||||
}
|
||||
Text(text = stringResource(id = R.string.current_funds))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
FundsItem(
|
||||
type = "GP",
|
||||
amount = funds.gp,
|
||||
)
|
||||
FundsItem(
|
||||
type = "C",
|
||||
amount = funds.credit,
|
||||
)
|
||||
}
|
||||
if (limits.resetCost > 0) {
|
||||
Button(
|
||||
onClick = {
|
||||
launchIO {
|
||||
runSwallowingWithUI {
|
||||
invalidateEvent.emit(Unit)
|
||||
EhEngine.resetImageLimits()
|
||||
refreshEvent.emit(Unit)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RestartAlt,
|
||||
contentDescription = stringResource(id = R.string.reset),
|
||||
)
|
||||
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
val avatar by Settings.avatar.collectAsState()
|
||||
AnimatedContent(targetState = avatar == null) { noAvatar ->
|
||||
if (noAvatar) {
|
||||
Icon(imageVector = Icons.Default.Person, contentDescription = null)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
awaitConfirmationOrCancel(
|
||||
confirmText = R.string.sign_in,
|
||||
showCancelButton = false,
|
||||
text = { Text(text = stringResource(id = R.string.settings_eh_identity_cookies_guest)) },
|
||||
)
|
||||
EhUtils.signOut()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.NoAccounts, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.listThumbGenerator
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
|
||||
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
@@ -108,10 +109,12 @@ fun DownloadCard(
|
||||
Row {
|
||||
val thumb = remember {
|
||||
movableContentOf<DownloadInfo> {
|
||||
EhAsyncCropThumb(
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectMode) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.SETNodeGenerator
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
|
||||
@@ -27,7 +28,7 @@ fun requestOf(model: GalleryInfo): ImageRequest {
|
||||
return remember(model) { context.imageRequest(model) }
|
||||
}
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun EhAsyncThumb(
|
||||
@@ -39,7 +40,6 @@ fun EhAsyncThumb(
|
||||
model = requestOf(model),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${model.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = onSuccess?.let { callback ->
|
||||
@@ -48,7 +48,7 @@ fun EhAsyncThumb(
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope)
|
||||
context(SharedTransitionScope, TransitionsVisibilityScope, SETNodeGenerator)
|
||||
@Composable
|
||||
fun EhAsyncCropThumb(
|
||||
key: GalleryInfo,
|
||||
@@ -59,7 +59,6 @@ fun EhAsyncCropThumb(
|
||||
model = requestOf(key),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
// https://github.com/FooIbar/EhViewer/issues/1527
|
||||
// .sharedBounds(key = "${key.gid}")
|
||||
.clip(ShapeDefaults.Medium),
|
||||
onSuccess = {
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
fun FundsItem(
|
||||
type: String,
|
||||
amount: String,
|
||||
amount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
@@ -27,6 +27,9 @@ fun FundsItem(
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Text(text = type)
|
||||
}
|
||||
Text(text = amount, style = textStyle)
|
||||
RollingNumber(number = amount, style = textStyle, separator = true)
|
||||
if (type == "GP") {
|
||||
Text(text = "k")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,36 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.URLSpan
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import arrow.core.Either
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.ui.legacy.LinkifyTextView
|
||||
import com.hippo.ehviewer.ui.screen.breakToTextAndUrl
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
|
||||
typealias TextOrUrl = Either<String, AnnotatedString>
|
||||
typealias TextOrUrlList = List<TextOrUrl>
|
||||
|
||||
@Composable
|
||||
fun GalleryCommentCard(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -34,22 +38,14 @@ fun GalleryCommentCard(
|
||||
onCardClick: () -> Unit,
|
||||
onUserClick: () -> Unit,
|
||||
onUrlClick: (String) -> Unit,
|
||||
update: LinkifyTextView.() -> Unit,
|
||||
) = comment.run {
|
||||
var currentSpan by remember(comment) { mutableStateOf<ClickableSpan?>(null) }
|
||||
Card(
|
||||
onClick = {
|
||||
val span = currentSpan.apply { currentSpan = null }
|
||||
if (span is URLSpan) {
|
||||
onUrlClick(span.url)
|
||||
} else {
|
||||
onCardClick()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
|
||||
ConstraintLayout(modifier = Modifier.padding(horizontal = keylineMargin, vertical = 8.dp).fillMaxWidth()) {
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
showImage: Boolean = false,
|
||||
processComment: @Composable (GalleryComment, TextLinkStyles, LinkInteractionListener) -> AnnotatedString = { c, s, l -> AnnotatedString.fromHtml(c.comment, s, l) },
|
||||
) = with(comment) {
|
||||
Card(onClick = onCardClick, modifier = modifier) {
|
||||
val margin = dimensionResource(id = R.dimen.keyline_margin)
|
||||
ConstraintLayout(modifier = Modifier.padding(horizontal = margin, vertical = 8.dp).fillMaxWidth()) {
|
||||
val (userRef, timeRef, commentRef) = createRefs()
|
||||
ProvideTextStyle(value = MaterialTheme.typography.titleSmall) {
|
||||
val userText = if (uploader) stringResource(id = R.string.comment_user_uploader, user.orEmpty()) else user.orEmpty()
|
||||
@@ -68,21 +64,41 @@ fun GalleryCommentCard(
|
||||
},
|
||||
)
|
||||
}
|
||||
val textColor = LocalContentColor.current.toArgb()
|
||||
val linkTextColor = MaterialTheme.colorScheme.primary.toArgb()
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
LinkifyTextView(context) { currentSpan = it }.apply {
|
||||
setTextColor(textColor)
|
||||
setLinkTextColor(linkTextColor)
|
||||
}
|
||||
},
|
||||
Column(
|
||||
modifier = Modifier.constrainAs(commentRef) {
|
||||
start.linkTo(parent.start)
|
||||
top.linkTo(userRef.bottom, margin = 8.dp)
|
||||
},
|
||||
update = update,
|
||||
)
|
||||
) {
|
||||
ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) {
|
||||
val linkColor = MaterialTheme.colorScheme.primary
|
||||
val processed = processComment(
|
||||
comment,
|
||||
TextLinkStyles(style = SpanStyle(color = linkColor)),
|
||||
) { link ->
|
||||
check(link is LinkAnnotation.Url)
|
||||
onUrlClick(link.url)
|
||||
}
|
||||
if (showImage) {
|
||||
val list = breakToTextAndUrl(comment.comment, processed)
|
||||
list.forEach {
|
||||
when (it) {
|
||||
is Either.Left -> AsyncImage(
|
||||
model = it.value,
|
||||
contentDescription = null,
|
||||
)
|
||||
is Either.Right -> Text(text = it.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = processed,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.detailThumbGenerator
|
||||
|
||||
@Composable
|
||||
fun GalleryDetailHeaderInfoCard(
|
||||
@@ -109,13 +110,15 @@ fun GalleryDetailHeaderCard(
|
||||
) = ElevatedCard(modifier = modifier) {
|
||||
Row {
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.size(
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
|
||||
),
|
||||
)
|
||||
with(detailThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = remember(info.gid) { info },
|
||||
modifier = Modifier.size(
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_width),
|
||||
dimensionResource(id = R.dimen.gallery_detail_thumb_height),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(0.5F))
|
||||
Column(
|
||||
|
||||
@@ -53,6 +53,7 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.ElevatedCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.ui.tools.TransitionsVisibilityScope
|
||||
import com.hippo.ehviewer.ui.tools.listThumbGenerator
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
|
||||
private val ids = Tuple7(1, 2, 3, 4, 5, 6, 7)
|
||||
@@ -108,10 +109,12 @@ fun GalleryInfoListItem(
|
||||
) {
|
||||
Row {
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
@@ -227,13 +230,15 @@ fun GalleryInfoGridItem(
|
||||
}
|
||||
mutableFloatStateOf(ratio)
|
||||
}
|
||||
EhAsyncThumb(
|
||||
model = info,
|
||||
modifier = Modifier.aspectRatio(ratio),
|
||||
onSuccess = {
|
||||
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
},
|
||||
)
|
||||
with(listThumbGenerator) {
|
||||
EhAsyncThumb(
|
||||
model = info,
|
||||
modifier = Modifier.aspectRatio(ratio),
|
||||
onSuccess = {
|
||||
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
Badge(
|
||||
modifier = Modifier.align(Alignment.TopEnd).widthIn(min = 32.dp).height(24.dp),
|
||||
|
||||
@@ -21,10 +21,10 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import coil3.BitmapImage
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.AsyncImagePainter.State
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.data.V2GalleryPreview
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.shouldCrop
|
||||
@@ -47,8 +47,8 @@ fun EhAsyncPreview(
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
transform = {
|
||||
model.run {
|
||||
if (it is AsyncImagePainter.State.Success && this is NormalGalleryPreview) {
|
||||
if (it is State.Success && model is V2GalleryPreview) {
|
||||
with(model) {
|
||||
it.copy(
|
||||
painter = BitmapPainter(
|
||||
(it.result.image as BitmapImage).bitmap.asImageBitmap(),
|
||||
@@ -56,22 +56,20 @@ fun EhAsyncPreview(
|
||||
IntSize(clipWidth - 1, clipHeight - 1),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
onState = {
|
||||
if (it is AsyncImagePainter.State.Success) {
|
||||
model.run {
|
||||
if (this is NormalGalleryPreview) {
|
||||
if (shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
} else {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
if (it is State.Success) {
|
||||
if (model is V2GalleryPreview) {
|
||||
if (model.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
} else {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,22 +33,22 @@ fun GalleryTags(
|
||||
val context = LocalContext.current
|
||||
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(context) && EhTagDatabase.initialized
|
||||
val ehTags = EhTagDatabase.takeIf { canTranslate }
|
||||
fun String.translate() = ehTags?.getTranslation(tag = this) ?: this
|
||||
fun String.translate(prefix: String?) = ehTags?.getTranslation(prefix = prefix, tag = this) ?: this
|
||||
fun TagNamespace.translate() = ehTags?.getTranslation(tag = value) ?: value
|
||||
fun String.translate(ns: TagNamespace) = ehTags?.getTranslation(prefix = ns.prefix, tag = this) ?: this
|
||||
Column(modifier) {
|
||||
tagGroups.forEach { tagGroup ->
|
||||
Row {
|
||||
val ns = tagGroup.nameSpace
|
||||
BaseRoundText(
|
||||
text = tagGroup.groupName.translate(),
|
||||
text = ns.translate(),
|
||||
isGroup = true,
|
||||
)
|
||||
val prefix = TagNamespace(tagGroup.groupName).toPrefix()
|
||||
FlowRow {
|
||||
tagGroup.forEach {
|
||||
tagGroup.tags.forEach {
|
||||
val weak = it.startsWith('_')
|
||||
val real = it.removePrefix("_")
|
||||
val translated = real.translate(prefix)
|
||||
val tag = tagGroup.groupName + ":" + real
|
||||
val translated = real.translate(ns)
|
||||
val tag = tagGroup.nameSpace.value + ":" + real
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
BaseRoundText(
|
||||
text = translated,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.animation.core.animateOffset
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun rememberTextStyleNumberMaxSize(textStyle: TextStyle): DpSize {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
return remember(textStyle, textMeasurer, density) {
|
||||
with(density) {
|
||||
"0123456789".maxOfWith({ (aw, _), (bw, _) -> aw - bw }) { char ->
|
||||
textMeasurer.measure("$char", textStyle).size
|
||||
}.toSize().toDpSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RollingNumberPlaceholder(number: Int, style: TextStyle = LocalTextStyle.current) {
|
||||
val styleNoSpacing = style.copy(letterSpacing = TextUnit.Unspecified)
|
||||
val size = rememberTextStyleNumberMaxSize(style)
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
"$number".forEach { char ->
|
||||
Text(
|
||||
text = "$char",
|
||||
style = styleNoSpacing,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.size(size),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have three space to make animation looks continuous
|
||||
// 1. Concept space, where we have elements 1 2 3 4 5 6 7 8 9 0 null
|
||||
// 0, 1, null is connected and the have same norm(distance) to each other
|
||||
// 2. Intermediate, which is 2d euclidean space
|
||||
// 0 ~ 9 in Concept space is mapped to a circle rigidly
|
||||
// null is mapped to a special point(0, - 1.528f * radius)
|
||||
// keeping null - 0, null - 1, 0 - 1 have same distance
|
||||
// 3. Number Offset Space
|
||||
// value is from -1 ~ 10, where -1 and 10 shows nothing
|
||||
// radian of point in Intermediate space is mapped to here discontinuously
|
||||
|
||||
// This controls animation speed
|
||||
private const val RADIUS = 100f
|
||||
|
||||
// Gaps between number in radian
|
||||
private const val GAP = (2 * PI / 10).toFloat()
|
||||
private const val ZERO_RADIAN = -(PI / 2 - GAP / 2).toFloat()
|
||||
|
||||
// null is mapped to (0, - 1.528f * radius)
|
||||
private const val NULL_NODE_DISTANCE = 1.528f
|
||||
|
||||
// Input: Concept space: 1 2 3 4 5 6 7 8 9 0 null
|
||||
// Output: Position in 2d euclidean space partial circle
|
||||
private fun conceptSpaceToIntermediate(value: Int?): Offset {
|
||||
if (value != null) {
|
||||
val theta = ZERO_RADIAN - value * GAP
|
||||
return Offset(cos(theta), sin(theta)) * RADIUS
|
||||
} else {
|
||||
return Offset(0f, -NULL_NODE_DISTANCE) * RADIUS
|
||||
}
|
||||
}
|
||||
|
||||
// Output: -1 ~ 10, where -1 and 10 shows nothing
|
||||
private fun intermediateToNumberOffset(circleOffset: Offset): Float {
|
||||
val degree = atan2(circleOffset.y, circleOffset.x)
|
||||
val normalized = normalize(ZERO_RADIAN - degree)
|
||||
return if (normalized > 9 * GAP) {
|
||||
val reNormalized = normalized - 9 * GAP - GAP / 2
|
||||
if (reNormalized > 0) {
|
||||
// 0 side
|
||||
reNormalized / GAP * 2 - 1
|
||||
} else {
|
||||
// 9 side
|
||||
10 + reNormalized / GAP * 2
|
||||
}
|
||||
} else {
|
||||
normalized / GAP
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize radian to [0, 2 * PI]
|
||||
private fun normalize(degree: Float) = (degree - 2 * PI * floor(degree / (2 * PI))).toFloat()
|
||||
|
||||
// Convert 2d euclidean space partial circle to UI Offset
|
||||
private fun numberOffsetToUIOffset(heightPx: Int, number: Float) = IntOffset(0, -(heightPx * 2 * number).toInt())
|
||||
|
||||
@Composable
|
||||
fun RollingNumber(
|
||||
number: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
separator: Boolean = false,
|
||||
length: Int? = null,
|
||||
) {
|
||||
val isNegative = number < 0
|
||||
val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current } }
|
||||
val string = remember(number) { "${abs(number)}" }
|
||||
val transition = updateTransition(string)
|
||||
val styleNoSpacing = style.merge(color = textColor).copy(letterSpacing = TextUnit.Unspecified)
|
||||
val size = rememberTextStyleNumberMaxSize(style)
|
||||
LazyRow(
|
||||
modifier = modifier.clipToBounds().layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints.copy(maxHeight = Int.MAX_VALUE))
|
||||
layout(width = placeable.width, height = size.height.roundToPx()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
},
|
||||
reverseLayout = true,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val max = length ?: with(transition) { max(currentState.length, targetState.length) }
|
||||
items(max, key = { it }) { reversed ->
|
||||
val rotate by transition.animateOffset { str ->
|
||||
val len = str.length
|
||||
val absent = max - len
|
||||
val where = max - reversed - 1
|
||||
val v = if (where < absent) null else str[where - absent].digitToInt()
|
||||
conceptSpaceToIntermediate(v)
|
||||
}
|
||||
if (reversed != 0 && reversed % 3 == 0 && separator) {
|
||||
Text(
|
||||
text = ",",
|
||||
modifier = Modifier.size(size),
|
||||
style = styleNoSpacing,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.animateItem().offset {
|
||||
val number = intermediateToNumberOffset(rotate)
|
||||
numberOffsetToUIOffset(size.height.roundToPx(), number)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
repeat(10) { i ->
|
||||
Text(
|
||||
text = "$i",
|
||||
modifier = Modifier.size(size),
|
||||
style = styleNoSpacing,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
modifier = Modifier.size(size),
|
||||
style = styleNoSpacing,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNegative) {
|
||||
item(key = -1) {
|
||||
Text(
|
||||
text = "-",
|
||||
modifier = Modifier.size(size).animateItem(),
|
||||
style = styleNoSpacing,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toAndroidRectF
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
|
||||
class BitmapPainter(private val bitmap: Bitmap, override val intrinsicSize: Size) : Painter() {
|
||||
private val srcRect = intrinsicSize.toRect().toAndroidRectF()
|
||||
private val dstRect = RectF()
|
||||
private val matrix = Matrix()
|
||||
|
||||
// Use the overload that takes a `Matrix` to bypass the 100 MB size limit
|
||||
override fun DrawScope.onDraw() = drawIntoCanvas { canvas ->
|
||||
dstRect.right = size.width.fastRoundToInt().toFloat()
|
||||
dstRect.bottom = size.height.fastRoundToInt().toFloat()
|
||||
matrix.setRectToRect(srcRect, dstRect, Matrix.ScaleToFit.FILL)
|
||||
canvas.nativeCanvas.drawBitmap(bitmap, matrix, paint)
|
||||
}
|
||||
}
|
||||
|
||||
private val paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
isFilterBitmap = true
|
||||
isDither = true
|
||||
}
|
||||
@@ -1,40 +1,48 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastFold
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import com.hippo.ehviewer.gallery.Page
|
||||
import com.hippo.ehviewer.gallery.PageLoader
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.TappingInvertMode
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
|
||||
@Composable
|
||||
fun GalleryPager(
|
||||
type: ReadingModeType,
|
||||
pagerState: PagerState,
|
||||
lazyListState: LazyListState,
|
||||
pageLoader: PageLoader2,
|
||||
pageLoader: PageLoader,
|
||||
showNavigationOverlay: Boolean,
|
||||
onNavigationModeChange: () -> Unit,
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onSelectPage: (Page) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val isPagerType = !ReadingModeType.isWebtoon(type)
|
||||
val pagerNavigation by Settings.readerPagerNav.collectAsState()
|
||||
@@ -53,11 +61,18 @@ fun GalleryPager(
|
||||
} else {
|
||||
onNavigationModeChange()
|
||||
}
|
||||
navigation.invertMode = TappingInvertMode.entries[invertMode]
|
||||
navigation.regions
|
||||
navigation.regions(TappingInvertMode.entries[invertMode])
|
||||
}
|
||||
val navigator by rememberUpdatedState(regions)
|
||||
if (isPagerType) {
|
||||
val channel = remember { Channel<Float>(Channel.CONFLATED) }
|
||||
LaunchedEffect(channel) {
|
||||
channel.receiveAsFlow().collectLatest { delta ->
|
||||
if (delta != 0f) {
|
||||
if (delta < 0) pagerState.moveToNext() else pagerState.moveToPrevious()
|
||||
}
|
||||
}
|
||||
}
|
||||
PagerViewer(
|
||||
pagerState = pagerState,
|
||||
isRtl = type == RIGHT_TO_LEFT,
|
||||
@@ -66,8 +81,15 @@ fun GalleryPager(
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
modifier = modifier.pointerInput(channel) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitScrollEvent()
|
||||
val delta = calculateMouseWheelScroll(event)
|
||||
channel.trySend(delta)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
WebtoonViewer(
|
||||
@@ -77,9 +99,21 @@ fun GalleryPager(
|
||||
navigator = { navigator },
|
||||
onSelectPage = onSelectPage,
|
||||
onMenuRegionClick = onMenuRegionClick,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
NavigationOverlay(showNavigationOverlay, regions, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
private suspend fun AwaitPointerEventScope.awaitScrollEvent(): PointerEvent {
|
||||
var event: PointerEvent
|
||||
do {
|
||||
event = awaitPointerEvent()
|
||||
} while (event.type != PointerEventType.Scroll)
|
||||
return event
|
||||
}
|
||||
|
||||
private fun Density.calculateMouseWheelScroll(event: PointerEvent): Float {
|
||||
// 64 dp value is taken from ViewConfiguration.java, replace with better solution
|
||||
return event.changes.fastFold(0f) { acc, c -> acc + c.scrollDelta.y } * -64.dp.toPx()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hippo.ehviewer.ui.reader
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
|
||||
fun Modifier.keyEventHandler(
|
||||
enabled: () -> Boolean,
|
||||
movePrevious: () -> Unit,
|
||||
moveNext: () -> Unit,
|
||||
) = onPreviewKeyEvent {
|
||||
if (enabled() && it.type == KeyEventType.KeyDown) {
|
||||
when (it.key) {
|
||||
Key.DirectionUp, Key.PageUp, Key.VolumeUp -> movePrevious()
|
||||
Key.DirectionDown, Key.PageDown, Key.VolumeDown -> moveNext()
|
||||
else -> return@onPreviewKeyEvent false
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user