Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37eb2f1aed | ||
|
|
c70ec5f354 | ||
|
|
7139af4629 | ||
|
|
a2bac029f5 | ||
|
|
6c68074e8b | ||
|
|
991caf73f4 | ||
|
|
0ba11641fa | ||
|
|
b51120e914 | ||
|
|
b02fb2d1d7 | ||
|
|
c6247230da | ||
|
|
d64be78b4e | ||
|
|
e75f633165 | ||
|
|
27c5598133 | ||
|
|
a9449bfbc7 | ||
|
|
4fecbe259b | ||
|
|
81d72edbf0 | ||
|
|
ea7e6e4624 | ||
|
|
6335d5bca4 | ||
|
|
227b430dbb | ||
|
|
0bba56a29f | ||
|
|
c07d9da7b0 | ||
|
|
900c5ed007 | ||
|
|
ba0788f6df | ||
|
|
c517856e89 | ||
|
|
791ee0ddbf | ||
|
|
0746938cb9 | ||
|
|
bbae24aba9 | ||
|
|
677edb0e02 | ||
|
|
a4328c5111 | ||
|
|
27cf341fef | ||
|
|
bc654e98b8 | ||
|
|
588f78e598 | ||
|
|
70f952285a | ||
|
|
a6f5e4f98d | ||
|
|
6fe5c1d666 | ||
|
|
1dba12ec94 | ||
|
|
aeb3f689d1 | ||
|
|
0a2268d4de | ||
|
|
9a32b020cf | ||
|
|
c5b01a7089 | ||
|
|
0634fbb724 | ||
|
|
445e9511a0 | ||
|
|
ac532acb5b | ||
|
|
7a8e21fbef | ||
|
|
58b0f3a41e | ||
|
|
ab373990cd | ||
|
|
272c157fe0 | ||
|
|
e5e047ee10 | ||
|
|
bf0b529018 | ||
|
|
acf2392406 | ||
|
|
cecd6fb359 | ||
|
|
7b67210876 | ||
|
|
e40c4f2c71 | ||
|
|
b0717b38b1 | ||
|
|
18c9b95b9b | ||
|
|
fc63c41ab0 | ||
|
|
d1c7434993 | ||
|
|
da15ba8b9a | ||
|
|
1d2a2a3608 | ||
|
|
145cd7a7c0 | ||
|
|
048415f3e0 | ||
|
|
95ccabc756 | ||
|
|
1f08c0e08c | ||
|
|
b036aeef23 | ||
|
|
d469a8404c | ||
|
|
5b16b6aed9 | ||
|
|
151949aecf | ||
|
|
de2c4ca3f5 | ||
|
|
e5bebcfe80 | ||
|
|
0080a482e4 | ||
|
|
756867416b | ||
|
|
124ff2f190 | ||
|
|
828a3d09b6 | ||
|
|
fc3b9d73ec | ||
|
|
ba943a31d4 | ||
|
|
8ee1ac7617 | ||
|
|
113f2a1794 | ||
|
|
f6028813e2 | ||
|
|
b094a32d12 | ||
|
|
fd3e4bdc60 | ||
|
|
142092d930 | ||
|
|
75e3e78ecb | ||
|
|
053832681f | ||
|
|
29e33d6e69 | ||
|
|
2d878e2f1c | ||
|
|
0fdbfbb1ae | ||
|
|
52f314f6b0 | ||
|
|
ff8d8916aa | ||
|
|
b2368b312f | ||
|
|
3cade906d6 | ||
|
|
e7cb33ef6f | ||
|
|
8c331c9577 | ||
|
|
8560a050ba | ||
|
|
583637b692 | ||
|
|
791359e949 | ||
|
|
e238b6aec0 | ||
|
|
17a143c4de | ||
|
|
dfb0aa823e | ||
|
|
b5cad36967 | ||
|
|
86378630bb | ||
|
|
730035aff7 | ||
|
|
64ac770c56 | ||
|
|
557a5599e2 | ||
|
|
ffd24015ac | ||
|
|
5eb1f12113 | ||
|
|
8425fed5a7 | ||
|
|
1e34aa4d9a | ||
|
|
f383594050 | ||
|
|
086db7572d | ||
|
|
082e05584a | ||
|
|
fb20f899e2 | ||
|
|
c0c07ff2cf | ||
|
|
2f67b741ff | ||
|
|
5a96048051 | ||
|
|
5d8b38ecf9 | ||
|
|
2941ac8285 | ||
|
|
07e316d9a9 | ||
|
|
5bd9fa3a62 | ||
|
|
0b5da2b2dd | ||
|
|
714a8ccf65 | ||
|
|
5c731cc6b2 | ||
|
|
eda8115fe5 | ||
|
|
cf0a408b90 | ||
|
|
f9786377a5 | ||
|
|
93f234b16d | ||
|
|
afb986937b | ||
|
|
61fecaffe1 | ||
|
|
6f87afc1bf | ||
|
|
2e5950a1a8 | ||
|
|
4bf0c4ecec | ||
|
|
3a8dbcdb32 | ||
|
|
1f3546f3cc | ||
|
|
f864d65d98 | ||
|
|
3479498c6c | ||
|
|
ac319330be | ||
|
|
9b3595e45f | ||
|
|
d855858543 | ||
|
|
ada52f1687 | ||
|
|
ef68374f52 | ||
|
|
0bf8049021 | ||
|
|
3cff3f837a | ||
|
|
ca94a63762 | ||
|
|
81b8bc9029 | ||
|
|
9d38520b85 | ||
|
|
800e09adad | ||
|
|
30d6c11220 | ||
|
|
e465356df8 | ||
|
|
ff8e1b7d28 | ||
|
|
175e1d7df9 | ||
|
|
271c1b1390 | ||
|
|
a128356fc4 | ||
|
|
2ed957b5f2 | ||
|
|
85bb9b9f97 | ||
|
|
050b5c0451 | ||
|
|
a14df3fc0c | ||
|
|
939136476e | ||
|
|
7e2af653ee | ||
|
|
d357a55307 | ||
|
|
23509ac2c9 | ||
|
|
118a4f75c0 | ||
|
|
2b8a7fafe6 | ||
|
|
10eda7e5f3 | ||
|
|
c23cadec8a | ||
|
|
0114081a2b | ||
|
|
2611601fb9 | ||
|
|
4194087f0b | ||
|
|
673adc41ba | ||
|
|
b195ae25a8 | ||
|
|
556c49fbfd | ||
|
|
358a5249a4 | ||
|
|
d5b837e6af | ||
|
|
61100d0c83 | ||
|
|
8473ca9fff | ||
|
|
37a2227c5c | ||
|
|
d6a4ab7471 | ||
|
|
6291288c42 | ||
|
|
6c926419e9 | ||
|
|
f292f31ac1 | ||
|
|
86a49f764e | ||
|
|
5dad21f4ea | ||
|
|
a4e9291bf9 | ||
|
|
2a667e62f4 | ||
|
|
6bd8063976 | ||
|
|
0aba5907b0 | ||
|
|
32182a7791 | ||
|
|
198960f93c | ||
|
|
618f14a732 | ||
|
|
fec2c39364 | ||
|
|
dd06d82cd9 | ||
|
|
4c98ee5350 | ||
|
|
2800a7bc15 | ||
|
|
04818e5afb | ||
|
|
122089eb1e | ||
|
|
b991e5d70a | ||
|
|
0d00ae207a | ||
|
|
9e451c18d9 | ||
|
|
71ebd99115 | ||
|
|
116dbead4b | ||
|
|
ef8adb00f4 | ||
|
|
7d74781f96 | ||
|
|
b7627dc576 | ||
|
|
cf42acd65c | ||
|
|
4163c895df | ||
|
|
63ddd5ea89 | ||
|
|
216dfded4b | ||
|
|
08b5eac6bd | ||
|
|
6e541be6ca | ||
|
|
17a9c02f96 | ||
|
|
ed640ab00e | ||
|
|
d69df5f7ab | ||
|
|
6f6461e855 | ||
|
|
682d959688 | ||
|
|
179686de16 | ||
|
|
32e35622bd | ||
|
|
c9c06202bb | ||
|
|
42d17621f9 | ||
|
|
629044c241 | ||
|
|
9be19489a4 | ||
|
|
cde4f9e405 | ||
|
|
16fddcb377 | ||
|
|
35cff4647a | ||
|
|
80ad749fc2 | ||
|
|
1a1d444b20 | ||
|
|
bb2d500067 | ||
|
|
c3c7e88c15 | ||
|
|
8dc3b1f376 | ||
|
|
b49a80bb35 | ||
|
|
21bfd4dd2d | ||
|
|
3551ed7c7a | ||
|
|
5f8ab98e9e | ||
|
|
8c6b2f5366 | ||
|
|
bac60fbec8 | ||
|
|
4eed03ebed | ||
|
|
e33295d0a6 | ||
|
|
07a495c499 | ||
|
|
e69eba6023 | ||
|
|
8be6ad84fc | ||
|
|
59e46aeeea | ||
|
|
4404531171 | ||
|
|
99db84fdd0 | ||
|
|
aa50b69754 | ||
|
|
fa2933df7c | ||
|
|
c51a26b9b7 | ||
|
|
9859a1eaac | ||
|
|
cd3858ace8 | ||
|
|
0c54e8b0b3 | ||
|
|
e32a7b61b6 | ||
|
|
ea2977268b | ||
|
|
6b529bcb18 | ||
|
|
941e7d18f3 | ||
|
|
d0fe9fac8c | ||
|
|
8f37c5ba25 | ||
|
|
b63b36e6d2 | ||
|
|
081357cbb4 | ||
|
|
0f7aa5f9ea | ||
|
|
a565b428e8 | ||
|
|
71f4df6f05 | ||
|
|
5da2f91e6a | ||
|
|
a6e9867596 | ||
|
|
9e1cff014d | ||
|
|
9c06f10d43 | ||
|
|
3f11df1dc6 | ||
|
|
6dd4505cec | ||
|
|
8faabd7759 | ||
|
|
288e56d7e0 | ||
|
|
7bcddb5f51 | ||
|
|
7b0bd309e4 | ||
|
|
237938b1f0 | ||
|
|
07009f8bd6 | ||
|
|
f7ef768c8f | ||
|
|
d1c3c9a37a | ||
|
|
78f15860e0 | ||
|
|
6b11773da9 | ||
|
|
ed26cb8f35 | ||
|
|
337f27dc38 | ||
|
|
97fab45877 | ||
|
|
8e8749ed6e | ||
|
|
a0e999576e | ||
|
|
b29802c260 | ||
|
|
665e5a3d39 | ||
|
|
c2a2dafb47 | ||
|
|
1d12a16e3d | ||
|
|
f3fe5f0a75 | ||
|
|
3bad1d06f4 | ||
|
|
70f2bfb760 | ||
|
|
9e86d42972 | ||
|
|
c8ade4d750 | ||
|
|
11b7bbd7ef | ||
|
|
513bce51ea | ||
|
|
f2c8c5d66d | ||
|
|
4e08f4f962 | ||
|
|
a9bd55dff5 | ||
|
|
5cec825dbe | ||
|
|
14c5b28cdc | ||
|
|
7693b11e0a | ||
|
|
f9cb49e4d3 | ||
|
|
892a614246 | ||
|
|
4a482d996e | ||
|
|
af9baa8ce4 | ||
|
|
39a11525a9 | ||
|
|
2337dda4eb | ||
|
|
c0a610a02b | ||
|
|
85f7b54719 | ||
|
|
e0aea2fedf | ||
|
|
ee83dbb877 | ||
|
|
1de4bc11eb | ||
|
|
04a7656afa | ||
|
|
77ec7e76e9 | ||
|
|
9ca7be081a | ||
|
|
fb36346f0b | ||
|
|
95679f3fb9 | ||
|
|
addd4124c1 | ||
|
|
74b79d9faa | ||
|
|
9daa05ef02 | ||
|
|
5ee884e0a2 | ||
|
|
dba7f4e6c0 | ||
|
|
8221514805 | ||
|
|
2ae3effc0e | ||
|
|
5b450ff18f | ||
|
|
92be6e9340 | ||
|
|
8884aeec11 | ||
|
|
8e15eda4e3 | ||
|
|
0dbf220655 | ||
|
|
fe69d59ef1 | ||
|
|
1e1f8350b5 | ||
|
|
afa2d03b56 | ||
|
|
1293d7fe28 | ||
|
|
62e879f2de | ||
|
|
a771994f6f | ||
|
|
8c2ba051a0 | ||
|
|
ae2f289122 | ||
|
|
703d63d106 | ||
|
|
2d7b024293 | ||
|
|
26906f6709 | ||
|
|
e9ec97403f | ||
|
|
34fb72707b | ||
|
|
fb7eb64c3a | ||
|
|
ef65720999 | ||
|
|
3bc71d6798 | ||
|
|
c4be269fb7 | ||
|
|
1238edfe2b | ||
|
|
747fd23b96 | ||
|
|
022f7a3ef1 | ||
|
|
e0cad8600c | ||
|
|
9510ebbc31 | ||
|
|
b63f2fd393 | ||
|
|
95a0e9f03b | ||
|
|
8265dd7f02 | ||
|
|
67feb19cab | ||
|
|
614e847de0 | ||
|
|
c2ff4b6a0b | ||
|
|
b273f1039c | ||
|
|
bea0c6a1f0 | ||
|
|
dcfaa361ec | ||
|
|
9904e166d2 | ||
|
|
5f02769012 | ||
|
|
19e5636dd2 | ||
|
|
9a220f89d9 | ||
|
|
1c76ec813c | ||
|
|
d36c189430 | ||
|
|
61e2ace5c8 | ||
|
|
d6a3f21754 | ||
|
|
4e54d7f66f | ||
|
|
f2dff038cd | ||
|
|
a7b30640e0 | ||
|
|
45048b1f46 | ||
|
|
498129314a | ||
|
|
3ee4b4a61e | ||
|
|
ee59c0f22e | ||
|
|
c56134cd92 | ||
|
|
b3d1b66a06 | ||
|
|
c9bb81bf63 | ||
|
|
d176810dae | ||
|
|
17bdca71b1 | ||
|
|
22fd3730df | ||
|
|
23d1271949 |
No files matched your search
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'renovate/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -19,7 +18,7 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -48,9 +47,6 @@ jobs:
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
- name: Lint Check
|
||||
run: ./gradlew lintMarshmallowGmsRelease
|
||||
|
||||
- name: CMake Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: '1'
|
||||
include-any-issue-labels: 'duplicate, wontfix'
|
||||
process-only: 'issues'
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 变更日志
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 后备 HTTP 引擎变更为 Apache HttpClient 5
|
||||
* 类别和高级搜索选项移至搜索栏下方
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持在搜索时指定画廊语言
|
||||
|
||||
### 改进
|
||||
|
||||
* 在下载中阅读画廊时会更新历史记录
|
||||
* 多处性能改进
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复无法由外部应用进入画廊页面 [#436](https://github.com/FooIbar/EhViewer/issues/436)
|
||||
* 修复点击精确到页的画廊链接闪退 [#430](https://github.com/FooIbar/EhViewer/issues/430)
|
||||
* 修复无法搜索本地收藏 [#491](https://github.com/FooIbar/EhViewer/issues/491)
|
||||
* 修复 CI 版本不显示变更日志
|
||||
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 除阅读器外全部迁移至 Compose
|
||||
|
||||
### 新功能
|
||||
|
||||
* 设置-高级 中增加实验性滑动手势敏感度设置
|
||||
* 支持通过滑动手势打开右侧抽屉
|
||||
* 支持预见式返回动画,相关设置位于 设置-高级
|
||||
|
||||
### 改进
|
||||
|
||||
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
|
||||
* 优化原图下载重试策略
|
||||
* 优化小屏设备上的搜索类别布局
|
||||
* 优化过渡动画
|
||||
* 优化历史记录删除动画
|
||||
* 设置-EH 中的缩略图大小设置改为缩略图列数
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复点击通知栏的下载通知闪退 [#175](https://github.com/FooIbar/EhViewer/issues/175)
|
||||
* 修复收藏状态显示错误 [#189](https://github.com/FooIbar/EhViewer/issues/189)
|
||||
* 修复双击导航按钮后无法打开导航抽屉
|
||||
* 修复某些情况下闪退 [#142](https://github.com/FooIbar/EhViewer/issues/142) [#208](https://github.com/FooIbar/EhViewer/issues/208)
|
||||
* 修复收藏界面点击重试崩溃 [#193](https://github.com/FooIbar/EhViewer/issues/193)
|
||||
* 修复本地收藏滚动位置丢失 [#192](https://github.com/FooIbar/EhViewer/issues/192)
|
||||
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
|
||||
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
|
||||
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
|
||||
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
|
||||
* 修复已舍弃画廊上传者解析
|
||||
|
||||
### 已知问题
|
||||
|
||||
* 画廊信息对话框无法通过返回关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
|
||||
@@ -1,15 +1,11 @@
|
||||
# Maintenance Mode
|
||||
|
||||
The project is in maintenance mode, will only receive bugfixes.
|
||||
|
||||
<p align="right">
|
||||
<strong>English</strong>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-cn.md">
|
||||
<a href="/docs/README/zh-cn.md">
|
||||
简体中文
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-tw.md">
|
||||
<a href="/docs/README/zh-tw.md">
|
||||
正體中文
|
||||
</a>
|
||||
</p>
|
||||
@@ -23,15 +19,12 @@ The project is in maintenance mode, will only receive bugfixes.
|
||||
<a href="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml/badge.svg" alt="Github Actions">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/LICENSE">
|
||||
<a href="/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/FooIbar/EhViewer" alt="LICENSE">
|
||||
</a>
|
||||
<a href="https://www.codefactor.io/repository/github/FooIbar/EhViewer">
|
||||
<img src="https://www.codefactor.io/repository/github/FooIbar/EhViewer/badge" alt="CodeFactor">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/downloads/FooIbar/EhViewer/total.svg" alt="Downloads">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/v/release/FooIbar/EhViewer" alt="Release">
|
||||
</a>
|
||||
@@ -42,23 +35,23 @@ The project is in maintenance mode, will only receive bugfixes.
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="https://github.com/FooIbar/EhViewer#description">
|
||||
<a href="#description">
|
||||
Description
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#download">
|
||||
<a href="#download">
|
||||
Download
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#screenshot">
|
||||
<a href="#screenshot">
|
||||
Screenshot
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#thanks">
|
||||
<a href="#thanks">
|
||||
Thanks
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#license">
|
||||
<a href="#license">
|
||||
License
|
||||
</a>
|
||||
</h3>
|
||||
@@ -73,16 +66,16 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
|
||||
# Download
|
||||
|
||||
| Flavor | Feature |
|
||||
|-|-|
|
||||
| Default | Android 9+, full support |
|
||||
| Flavor | Feature |
|
||||
|-------------|-----------------------------------|
|
||||
| Default | Android 9+, full support |
|
||||
| Marshmallow | Android 6.0-8.1, limited support* |
|
||||
| OSS | FOSS dependencies** |
|
||||
| GMS | Proprietary GMS component |
|
||||
| OSS | FOSS dependencies** |
|
||||
| GMS | Proprietary GMS component |
|
||||
|
||||
\* Archives and GIFs are not supported
|
||||
|
||||
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
** Fall back to Apache5 on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
@@ -100,14 +93,13 @@ Here is the libraries
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [AOSP & AndroidX](http://source.android.com/)
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [FullDraggableDrawer](https://github.com/PureWriter/FullDraggableDrawer)
|
||||
- [MCA](https://github.com/material-components/material-components-android)
|
||||
- [material-design-icons](https://github.com/google/material-design-icons)
|
||||
- [Okhttp](https://square.github.io/okhttp/)
|
||||
- [RikkaX](https://github.com/RikkaApps/RikkaX)
|
||||
- [Libarchive](http://www.libarchive.org/)
|
||||
- [MDC-Android](https://github.com/material-components/material-components-android)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Jsoup](https://jsoup.org/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
|
||||
+25
-27
@@ -64,8 +64,8 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 180045
|
||||
versionName = "1.8.11.0"
|
||||
versionCode = 180047
|
||||
versionName = "1.10.0"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -108,10 +108,6 @@ android {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
lint {
|
||||
checkOnly += setOf("InlinedApi", "NewApi", "UnusedAttribute")
|
||||
error += setOf("InlinedApi", "UnusedAttribute")
|
||||
}
|
||||
}
|
||||
create("oss") {
|
||||
dimension = "oss"
|
||||
@@ -135,7 +131,7 @@ android {
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
|
||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
@@ -149,13 +145,14 @@ android {
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
checkReleaseBuilds = false
|
||||
disable.add("MissingTranslation")
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
fatal += setOf("NewApi", "InlineApi")
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -202,53 +199,52 @@ dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.collection)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
api(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
|
||||
implementation(libs.compose.destinations.core)
|
||||
ksp(libs.compose.destinations.compiler)
|
||||
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.coordinatorlayout)
|
||||
implementation(libs.androidx.fragment)
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/navigation
|
||||
implementation(libs.bundles.androidx.navigation)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.bundles.androidx.paging)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.bundles.androidx.recyclerview)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
// https://github.com/google/accompanist/releases
|
||||
implementation(libs.bundles.accompanist)
|
||||
implementation(libs.material)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.bundles.okhttp)
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation(libs.diff)
|
||||
|
||||
implementation(libs.aboutlibraries.core)
|
||||
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
|
||||
@@ -261,18 +257,20 @@ dependencies {
|
||||
implementation(libs.bundles.kotlinx.serialization)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
debugImplementation(libs.chucker)
|
||||
releaseImplementation(libs.chucker.nop)
|
||||
|
||||
debugImplementation(libs.leakcanary.android)
|
||||
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
|
||||
"gmsImplementation"(libs.bundles.cronet)
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
exclude("androidx.navigation", "navigation-compose")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
Vendored
+16
-18
@@ -1,30 +1,28 @@
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/issues/190382641
|
||||
-keepclassmembers class kotlin.SafePublicationLazyImpl {
|
||||
java.lang.Object _value;
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keep class androidx.viewpager.widget.ViewPager$LayoutParams { int position; }
|
||||
|
||||
-keep class com.hippo.ehviewer.client.parser.Torrent { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.GalleryListResult { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.Limits { *; }
|
||||
-keep class com.hippo.ehviewer.client.data.BaseGalleryInfo { *; }
|
||||
-keep class com.hippo.ehviewer.ui.scene.SearchBarScene { getBinding(); }
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
-keepattributes LineNumberTable,SourceFile
|
||||
-renamesourcefileattribute SourceFile
|
||||
# Apache5 Http Client
|
||||
-dontwarn org.ietf.jgss.GSSContext
|
||||
-dontwarn org.ietf.jgss.GSSCredential
|
||||
-dontwarn org.ietf.jgss.GSSException
|
||||
-dontwarn org.ietf.jgss.GSSManager
|
||||
-dontwarn org.ietf.jgss.GSSName
|
||||
-dontwarn org.ietf.jgss.Oid
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
|
||||
-repackageclasses
|
||||
-allowaccessmodification
|
||||
-overloadaggressively
|
||||
-repackageclasses
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c801ee62729ba20a203b150211234d5c",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "OK_HTTP_3_COOKIE",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "VALUE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expiresAt",
|
||||
"columnName": "EXPIRES_AT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domain",
|
||||
"columnName": "DOMAIN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "PATH",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "secure",
|
||||
"columnName": "SECURE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "httpOnly",
|
||||
"columnName": "HTTP_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "persistent",
|
||||
"columnName": "PERSISTENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostOnly",
|
||||
"columnName": "HOST_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "1b80cd29939b0f43934721f1289ba94d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "OK_HTTP_3_COOKIE",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "VALUE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "expiresAt",
|
||||
"columnName": "EXPIRES_AT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domain",
|
||||
"columnName": "DOMAIN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "PATH",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "secure",
|
||||
"columnName": "SECURE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "httpOnly",
|
||||
"columnName": "HTTP_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "persistent",
|
||||
"columnName": "PERSISTENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostOnly",
|
||||
"columnName": "HOST_ONLY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,20 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
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.EhCookieStore
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
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 = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
|
||||
setStoragePath(cache.absolutePath)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
|
||||
setUserAgent(Settings.userAgent)
|
||||
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()
|
||||
|
||||
class CronetRequest {
|
||||
lateinit var consumer: (ByteBuffer) -> Unit
|
||||
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
|
||||
lateinit var request: UrlRequest
|
||||
lateinit var onError: (Throwable) -> Unit
|
||||
lateinit var readerCont: Continuation<Unit>
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
|
||||
req.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
|
||||
onResponse(info)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
|
||||
data.flip()
|
||||
consumer(data)
|
||||
}
|
||||
|
||||
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
|
||||
readerCont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun cronetRequest(url: String, referer: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
|
||||
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
|
||||
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
}.apply(conf).build()
|
||||
}
|
||||
|
||||
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
|
||||
contract {
|
||||
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return coroutineScope {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
onResponse = { launch { cont.resume(callback(it)) } }
|
||||
cont.invokeOnCancellation { request.cancel() }
|
||||
onError = { cont.resumeWithException(it) }
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
|
||||
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.cronet.cronetDispatcher
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
|
||||
import com.hippo.ehviewer.cronet.pool
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpProtocolVersion
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.content.OutgoingContent
|
||||
import io.ktor.http.headers
|
||||
import io.ktor.util.date.GMTDate
|
||||
import io.ktor.util.flattenForEach
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UploadDataProvider
|
||||
import org.chromium.net.UploadDataSink
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
|
||||
object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
override val config = HttpClientEngineConfig()
|
||||
override val dispatcher = cronetDispatcher
|
||||
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData): HttpResponseData {
|
||||
val callContext = callContext()
|
||||
|
||||
return executeHttpRequest(callContext, data)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private suspend fun executeHttpRequest(
|
||||
callContext: CoroutineContext,
|
||||
data: HttpRequestData,
|
||||
): HttpResponseData = suspendCancellableCoroutine { continuation ->
|
||||
val requestTime = GMTDate()
|
||||
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
lateinit var chunkChan: Channel<ByteBuffer>
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||
val channel = GlobalScope.writer(callContext) {
|
||||
pool.useInstance {
|
||||
chunkChan = Channel(1)
|
||||
request.read(it)
|
||||
chunkChan.consumeEach { buffer ->
|
||||
buffer.flip()
|
||||
channel.writeFully(buffer)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
}.channel
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
responseBody = channel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
|
||||
chunkChan.trySend(byteBuffer).getOrThrow()
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
chunkChan.close()
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
|
||||
if (::chunkChan.isInitialized) {
|
||||
chunkChan.close(error)
|
||||
} else {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
|
||||
setHttpMethod(data.method.value)
|
||||
data.headers.flattenForEach { key, value -> addHeader(key, value) }
|
||||
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
|
||||
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
|
||||
data.body.toUploadDataProvider(callContext)?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
|
||||
}.build()
|
||||
request.start()
|
||||
callContext[Job]!!.invokeOnCompletion { request.cancel() }
|
||||
continuation.invokeOnCancellation { request.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UrlResponseInfo.toHttpResponseData(
|
||||
requestTime: GMTDate,
|
||||
callContext: CoroutineContext,
|
||||
responseBody: ByteReadChannel = ByteReadChannel.Empty,
|
||||
): HttpResponseData {
|
||||
return HttpResponseData(
|
||||
statusCode = HttpStatusCode.fromValue(httpStatusCode),
|
||||
requestTime = requestTime,
|
||||
headers = headers {
|
||||
allHeaders.forEach { (key, value) ->
|
||||
appendAll(key, value)
|
||||
}
|
||||
},
|
||||
version = when (negotiatedProtocol) {
|
||||
"h2" -> HttpProtocolVersion.HTTP_2_0
|
||||
"h3" -> HttpProtocolVersion.QUIC
|
||||
"quic/1+spdy/3" -> HttpProtocolVersion.SPDY_3
|
||||
else -> HttpProtocolVersion.HTTP_1_1
|
||||
},
|
||||
body = responseBody,
|
||||
callContext = callContext,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun OutgoingContent.toUploadDataProvider(callContext: CoroutineContext): UploadDataProvider? = when (this) {
|
||||
is OutgoingContent.NoContent -> null
|
||||
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()
|
||||
}
|
||||
}
|
||||
is OutgoingContent.WriteChannelContent -> object : UploadDataProvider() {
|
||||
fun launch() = GlobalScope.writer(callContext) { writeTo(channel) }.channel
|
||||
var chan = launch()
|
||||
override fun getLength() = contentLength!!
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
GlobalScope.launch(callContext) {
|
||||
chan.readAvailable(byteBuffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
chan = launch()
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
else -> error("UnsupportedContentType $this")
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import com.google.android.gms.net.CronetProviderInstaller
|
||||
import com.hippo.ehviewer.Settings
|
||||
import org.chromium.net.CronetProvider
|
||||
import splitties.init.appCtx
|
||||
|
||||
val isCronetSupported: Boolean
|
||||
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
val isCronetAvailable: Boolean
|
||||
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
if (!it) {
|
||||
CronetProviderInstaller.installProvider(appCtx)
|
||||
}
|
||||
|
||||
@@ -23,16 +23,20 @@
|
||||
android:allowBackup="true"
|
||||
android:appCategory="image"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_scheme"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="@bool/large_heap"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConfigureActivity"
|
||||
android:exported="false"
|
||||
@@ -135,8 +139,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<activity
|
||||
android:name=".ui.WebViewActivity" />
|
||||
<activity android:name=".ui.WebViewActivity" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
||||
@@ -24,7 +24,7 @@ set(BUILD_TESTING 0)
|
||||
|
||||
FetchContent_Declare(liblzma
|
||||
GIT_REPOSITORY https://github.com/FooIbar/xz.git
|
||||
GIT_TAG v5.4.4
|
||||
GIT_TAG v5.4.5
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCompositionContext
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.ViewRootForInspector
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.popup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
internal object AnchoredDraggableDefaults2 {
|
||||
/**
|
||||
* The default animation used by [AnchoredDraggableState].
|
||||
*/
|
||||
@get:ExperimentalMaterial3Api
|
||||
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
|
||||
@ExperimentalMaterial3Api
|
||||
val AnimationSpec = SpringSpec<Float>()
|
||||
}
|
||||
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheetFix]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
class SheetState2
|
||||
@Deprecated(
|
||||
message = "This constructor is deprecated. " +
|
||||
"Please use the constructor that provides a [Density]",
|
||||
replaceWith = ReplaceWith(
|
||||
"SheetState(" +
|
||||
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
|
||||
"confirmValueChange, skipHiddenState)",
|
||||
),
|
||||
)
|
||||
constructor(
|
||||
internal val skipPartiallyExpanded: Boolean,
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
val confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
internal val skipHiddenState: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheetFix]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Suppress("Deprecation")
|
||||
constructor(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
density: Density,
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = false,
|
||||
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
|
||||
this.density = density
|
||||
}
|
||||
init {
|
||||
if (skipPartiallyExpanded) {
|
||||
require(initialValue != SheetValue.PartiallyExpanded) {
|
||||
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
|
||||
"is set to true."
|
||||
}
|
||||
}
|
||||
if (skipHiddenState) {
|
||||
require(initialValue != SheetValue.Hidden) {
|
||||
"The initial value must not be set to Hidden if skipHiddenState is set to true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
|
||||
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target value of the bottom sheet state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the sheet would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet is visible.
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
|
||||
|
||||
/**
|
||||
* Require the current offset (in pixels) of the bottom sheet.
|
||||
*
|
||||
* The offset will be initialized during the first measurement phase of the provided sheet
|
||||
* content.
|
||||
*
|
||||
* These are the phases:
|
||||
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
|
||||
*
|
||||
* During the first composition, an [IllegalStateException] is thrown. In subsequent
|
||||
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
|
||||
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
|
||||
* frame, after layout.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* Whether the sheet has an expanded state defined.
|
||||
*/
|
||||
|
||||
val hasExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet has a partially expanded state defined.
|
||||
*/
|
||||
val hasPartiallyExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
|
||||
|
||||
/**
|
||||
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
|
||||
* animation has been cancelled.
|
||||
* *
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun expand() {
|
||||
anchoredDraggableState.animateTo(SheetValue.Expanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
|
||||
* cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
|
||||
*/
|
||||
suspend fun partialExpand() {
|
||||
check(!skipPartiallyExpanded) {
|
||||
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
|
||||
" skipPartiallyExpanded to false to use this function."
|
||||
}
|
||||
animateTo(SheetValue.PartiallyExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
|
||||
* else [Expanded].
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun show() {
|
||||
val targetValue = when {
|
||||
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
|
||||
else -> SheetValue.Expanded
|
||||
}
|
||||
animateTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
|
||||
* been cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun hide() {
|
||||
check(!skipHiddenState) {
|
||||
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
|
||||
" to false to use this function."
|
||||
}
|
||||
animateTo(SheetValue.Hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate to a [targetValue].
|
||||
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
|
||||
* [targetValue] without updating the offset.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
internal suspend fun animateTo(
|
||||
targetValue: SheetValue,
|
||||
velocity: Float = anchoredDraggableState.lastVelocity,
|
||||
) {
|
||||
anchoredDraggableState.animateTo(targetValue, velocity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap to a [targetValue] without any animation.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
internal suspend fun snapTo(targetValue: SheetValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest anchor taking into account the velocity and settle at it with an animation.
|
||||
*/
|
||||
internal suspend fun settle(velocity: Float) {
|
||||
anchoredDraggableState.settle(velocity)
|
||||
}
|
||||
|
||||
internal var anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
|
||||
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? = null
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"SheetState did not have a density attached. Are you using SheetState with " +
|
||||
"BottomSheetScaffold or ModalBottomSheet component?"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState2].
|
||||
*/
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
density: Density,
|
||||
) = Saver<SheetState2, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState2].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This function is deprecated. Please use the overload where Density is" +
|
||||
" provided.",
|
||||
replaceWith = ReplaceWith(
|
||||
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
|
||||
),
|
||||
)
|
||||
@Suppress("Deprecation")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
) = Saver<SheetState2, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
|
||||
*
|
||||
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
|
||||
* especially when offering a long list of action items, or when items require longer descriptions
|
||||
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
|
||||
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
|
||||
* required action has been taken.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* A simple example of a modal bottom sheet looks like this:
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
|
||||
*
|
||||
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
|
||||
* animates to [Hidden].
|
||||
* @param modifier Optional [Modifier] for the bottom sheet.
|
||||
* @param sheetState The state of the bottom sheet.
|
||||
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
|
||||
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
|
||||
* @param shape The shape of the bottom sheet.
|
||||
* @param containerColor The color used for the background of this bottom sheet
|
||||
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
|
||||
* the matching content color for [containerColor], or to the current [LocalContentColor] if
|
||||
* [containerColor] is not a color from the theme.
|
||||
* @param tonalElevation The tonal elevation of this bottom sheet.
|
||||
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
|
||||
* @param dragHandle Optional visual marker to swipe the bottom sheet.
|
||||
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
|
||||
* params.
|
||||
* @param properties [ModalBottomSheetProperties2] for further customization of this
|
||||
* modal bottom sheet's behavior.
|
||||
* @param content The content to be displayed inside the bottom sheet.
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun ModalBottomSheetFix(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState2 = rememberModalBottomSheetState(),
|
||||
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
|
||||
shape: Shape = BottomSheetDefaults.ExpandedShape,
|
||||
containerColor: Color = BottomSheetDefaults.ContainerColor,
|
||||
contentColor: Color = contentColorFor(containerColor),
|
||||
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
||||
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
|
||||
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
// b/291735717 Remove this once deprecated methods without density are removed
|
||||
val density = LocalDensity.current
|
||||
SideEffect {
|
||||
sheetState.density = density
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val animateToDismiss: () -> Unit = {
|
||||
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sheetState) {
|
||||
snapshotFlow {
|
||||
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
|
||||
}.drop(1).collectLatest {
|
||||
if (it) onDismissRequest()
|
||||
}
|
||||
}
|
||||
val settleToDismiss: (velocity: Float) -> Unit = {
|
||||
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
val popupOnDismissRequest: () -> Unit = {
|
||||
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
|
||||
scope.launch { sheetState.partialExpand() }
|
||||
} else { // Is expanded without collapsed state or is collapsed.
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetPopup(
|
||||
properties = properties,
|
||||
onDismissRequest = popupOnDismissRequest,
|
||||
windowInsets = windowInsets,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val widthDp = configuration.screenWidthDp.dp
|
||||
val height = configuration.screenHeightDp.dp
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
|
||||
sheetState.hide()
|
||||
onDismissRequest()
|
||||
}
|
||||
val predictiveModifier = Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
|
||||
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
|
||||
IntOffset(ofsX, ofsY)
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val fullHeight = constraints.maxHeight
|
||||
Scrim(
|
||||
color = scrimColor,
|
||||
onDismissRequest = animateToDismiss,
|
||||
visible = sheetState.targetValue != SheetValue.Hidden,
|
||||
)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.widthIn(max = sheetMaxWidth)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
sheetState
|
||||
.requireOffset()
|
||||
.toInt(),
|
||||
)
|
||||
} then predictiveModifier
|
||||
.nestedScroll(
|
||||
remember(sheetState) {
|
||||
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState = sheetState,
|
||||
orientation = Orientation.Vertical,
|
||||
onFling = settleToDismiss,
|
||||
)
|
||||
},
|
||||
)
|
||||
.anchoredDraggable(
|
||||
state = sheetState.anchoredDraggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = sheetState.isVisible,
|
||||
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
|
||||
)
|
||||
.modalBottomSheetAnchors(
|
||||
sheetState = sheetState,
|
||||
fullHeight = fullHeight.toFloat(),
|
||||
),
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (dragHandle != null) {
|
||||
Box(Modifier.align(Alignment.CenterHorizontally)) {
|
||||
dragHandle()
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sheetState.hasExpandedState) {
|
||||
LaunchedEffect(sheetState) {
|
||||
sheetState.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState: SheetState2,
|
||||
orientation: Orientation,
|
||||
onFling: (velocity: Float) -> Unit,
|
||||
): NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.toFloat()
|
||||
return if (delta < 0 && source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
return if (source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val toFling = available.toFloat()
|
||||
val currentOffset = sheetState.requireOffset()
|
||||
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
|
||||
return if (toFling < 0 && currentOffset > minAnchor) {
|
||||
onFling(toFling)
|
||||
// since we go to the anchor with tween settling, consume all for the best UX
|
||||
available
|
||||
} else {
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
onFling(available.toFloat())
|
||||
return available
|
||||
}
|
||||
|
||||
private fun Float.toOffset(): Offset = Offset(
|
||||
x = if (orientation == Orientation.Horizontal) this else 0f,
|
||||
y = if (orientation == Orientation.Vertical) this else 0f,
|
||||
)
|
||||
|
||||
@JvmName("velocityToFloat")
|
||||
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
|
||||
|
||||
@JvmName("offsetToFloat")
|
||||
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties used to customize the behavior of a [ModalBottomSheetFix].
|
||||
*
|
||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
||||
* sheet's window.
|
||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
||||
* the back button is pressed.
|
||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
class ModalBottomSheetProperties2(
|
||||
val securePolicy: SecureFlagPolicy,
|
||||
val isFocusable: Boolean,
|
||||
val shouldDismissOnBackPress: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ModalBottomSheetProperties2) return false
|
||||
|
||||
if (securePolicy != other.securePolicy) return false
|
||||
if (isFocusable != other.isFocusable) return false
|
||||
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = securePolicy.hashCode()
|
||||
result = 31 * result + isFocusable.hashCode()
|
||||
result = 31 * result + shouldDismissOnBackPress.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for [ModalBottomSheetFix]
|
||||
*/
|
||||
@Immutable
|
||||
@ExperimentalMaterial3Api
|
||||
object ModalBottomSheetDefaults2 {
|
||||
/**
|
||||
* Properties used to customize the behavior of a [ModalBottomSheetFix].
|
||||
*
|
||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
||||
* sheet's window.
|
||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
||||
* the back button is pressed.
|
||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
||||
*/
|
||||
fun properties(
|
||||
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
|
||||
isFocusable: Boolean = true,
|
||||
shouldDismissOnBackPress: Boolean = true,
|
||||
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
|
||||
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
|
||||
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
internal fun rememberSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
): SheetState2 {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
skipPartiallyExpanded,
|
||||
confirmValueChange,
|
||||
saver = SheetState2.Saver(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
),
|
||||
) {
|
||||
SheetState2(
|
||||
skipPartiallyExpanded,
|
||||
density,
|
||||
initialValue,
|
||||
confirmValueChange,
|
||||
skipHiddenState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
color: Color,
|
||||
onDismissRequest: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
if (color.isSpecified) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec(),
|
||||
label = "scrim",
|
||||
)
|
||||
val dismissSheet = if (visible) {
|
||||
Modifier
|
||||
.pointerInput(onDismissRequest) {
|
||||
detectTapGestures {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics {}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissSheet),
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
private fun Modifier.modalBottomSheetAnchors(
|
||||
sheetState: SheetState2,
|
||||
fullHeight: Float,
|
||||
) = onSizeChanged { sheetSize ->
|
||||
|
||||
val newAnchors = DraggableAnchors {
|
||||
SheetValue.Hidden at fullHeight
|
||||
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
|
||||
SheetValue.PartiallyExpanded at fullHeight / 2f
|
||||
}
|
||||
if (sheetSize.height != 0) {
|
||||
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
|
||||
SheetValue.Hidden -> SheetValue.Hidden
|
||||
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
|
||||
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
|
||||
val newTarget = if (hasPartiallyExpandedState) {
|
||||
SheetValue.PartiallyExpanded
|
||||
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
|
||||
newTarget
|
||||
}
|
||||
}
|
||||
|
||||
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup specific for modal bottom sheet.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ModalBottomSheetPopup(
|
||||
properties: ModalBottomSheetProperties2,
|
||||
onDismissRequest: () -> Unit,
|
||||
windowInsets: WindowInsets,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val id = rememberSaveable { UUID.randomUUID() }
|
||||
val parentComposition = rememberCompositionContext()
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val modalBottomSheetWindow = remember(configuration) {
|
||||
ModalBottomSheetWindow2(
|
||||
properties = properties,
|
||||
onDismissRequest = onDismissRequest,
|
||||
composeView = view,
|
||||
saveId = id,
|
||||
).apply {
|
||||
setCustomContent(
|
||||
parent = parentComposition,
|
||||
content = {
|
||||
Box(
|
||||
Modifier
|
||||
.semantics { this.popup() }
|
||||
.windowInsetsPadding(windowInsets)
|
||||
.then(
|
||||
// TODO(b/290893168): Figure out a solution for APIs < 30.
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Modifier.imePadding()
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
currentContent()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(modalBottomSheetWindow) {
|
||||
modalBottomSheetWindow.show()
|
||||
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
|
||||
onDispose {
|
||||
modalBottomSheetWindow.disposeComposition()
|
||||
modalBottomSheetWindow.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom compose view for [ModalBottomSheetFix] */
|
||||
@SuppressLint("ViewConstructor")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private class ModalBottomSheetWindow2(
|
||||
private val properties: ModalBottomSheetProperties2,
|
||||
private var onDismissRequest: () -> Unit,
|
||||
private val composeView: View,
|
||||
saveId: UUID,
|
||||
) : AbstractComposeView(composeView.context),
|
||||
ViewTreeObserver.OnGlobalLayoutListener,
|
||||
ViewRootForInspector,
|
||||
OnBackPressedDispatcherOwner {
|
||||
|
||||
init {
|
||||
id = android.R.id.content
|
||||
// Set up view owners
|
||||
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
|
||||
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
|
||||
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
|
||||
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
|
||||
// Enable children to draw their shadow by not clipping them
|
||||
clipChildren = false
|
||||
}
|
||||
|
||||
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
|
||||
override val onBackPressedDispatcher = OnBackPressedDispatcher {
|
||||
onDismissRequest()
|
||||
}
|
||||
|
||||
private val windowManager =
|
||||
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
private val displayWidth: Int
|
||||
get() = context.resources.displayMetrics.widthPixels
|
||||
|
||||
private val params: WindowManager.LayoutParams =
|
||||
WindowManager.LayoutParams().apply {
|
||||
// Position bottom sheet from the bottom of the screen
|
||||
gravity = Gravity.BOTTOM or Gravity.START
|
||||
// Application panel window
|
||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
|
||||
// Fill up the entire app view
|
||||
width = displayWidth
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
|
||||
// Format of screen pixels
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
// Title used as fallback for a11y services
|
||||
// TODO: Provide bottom sheet window resource
|
||||
title = composeView.context.resources.getString(
|
||||
androidx.compose.ui.R.string.default_popup_window_title,
|
||||
)
|
||||
// Get the Window token from the parent view
|
||||
token = composeView.applicationWindowToken
|
||||
|
||||
// Flags specific to modal bottom sheet.
|
||||
flags = flags and (
|
||||
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
|
||||
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
||||
).inv()
|
||||
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
||||
|
||||
// Security flag
|
||||
val secureFlagEnabled =
|
||||
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
|
||||
if (secureFlagEnabled) {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
|
||||
} else {
|
||||
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
|
||||
}
|
||||
|
||||
// Focusable
|
||||
if (!properties.isFocusable) {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
} else {
|
||||
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
|
||||
}
|
||||
}
|
||||
|
||||
private var content: @Composable () -> Unit by mutableStateOf({})
|
||||
|
||||
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
|
||||
private set
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomContent(
|
||||
parent: CompositionContext? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
parent?.let { setParentCompositionContext(it) }
|
||||
this.content = content
|
||||
shouldCreateCompositionOnAttachedToWindow = true
|
||||
}
|
||||
|
||||
fun show() {
|
||||
windowManager.addView(this, params)
|
||||
if (isAtLeastT) {
|
||||
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
setViewTreeLifecycleOwner(null)
|
||||
setViewTreeSavedStateRegistryOwner(null)
|
||||
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
windowManager.removeViewImmediate(this)
|
||||
}
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
override fun setLayoutDirection(layoutDirection: Int) {
|
||||
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
|
||||
// from the context's locale, but we have one already from the parent composition.
|
||||
}
|
||||
|
||||
// Sets the "real" layout direction for our content that we obtain from the parent composition.
|
||||
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
|
||||
val direction = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
||||
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
|
||||
}
|
||||
super.setLayoutDirection(direction)
|
||||
}
|
||||
}
|
||||
|
||||
// Taken from AndroidPopup.android.kt
|
||||
private fun View.isFlagSecureEnabled(): Boolean {
|
||||
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
|
||||
if (windowParams != null) {
|
||||
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Taken from AndroidPopup.android.kt
|
||||
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
|
||||
return when (this) {
|
||||
SecureFlagPolicy.SecureOff -> false
|
||||
SecureFlagPolicy.SecureOn -> true
|
||||
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
|
||||
private const val DrawerPositionalThreshold = 0.5f
|
||||
private val DrawerVelocityThreshold = 400.dp
|
||||
private val ContainerWidth = 360.0.dp
|
||||
|
||||
@Suppress("NotCloseable")
|
||||
@Stable
|
||||
class DrawerState2(
|
||||
initialValue: DrawerValue,
|
||||
val confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
) {
|
||||
|
||||
val anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = AnimationSpec,
|
||||
confirmValueChange = confirmStateChange,
|
||||
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
|
||||
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the drawer is open.
|
||||
*/
|
||||
val isOpen: Boolean
|
||||
get() = currentValue == DrawerValue.Open
|
||||
|
||||
/**
|
||||
* Whether the drawer is closed.
|
||||
*/
|
||||
val isClosed: Boolean
|
||||
get() = currentValue == DrawerValue.Closed
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the start the drawer
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state drawer
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
val currentValue: DrawerValue
|
||||
get() {
|
||||
return anchoredDraggableState.currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the state is currently animating.
|
||||
*/
|
||||
val isAnimationRunning: Boolean
|
||||
get() {
|
||||
return anchoredDraggableState.isAnimationRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the drawer with animation and suspend until it if fully opened or animation has been
|
||||
* cancelled. This method will throw [CancellationException] if the animation is
|
||||
* interrupted
|
||||
*
|
||||
* @return the reason the open animation ended
|
||||
*/
|
||||
suspend fun open() = animateTo(DrawerValue.Open)
|
||||
|
||||
/**
|
||||
* Close the drawer with animation and suspend until it if fully closed or animation has been
|
||||
* cancelled. This method will throw [CancellationException] if the animation is
|
||||
* interrupted
|
||||
*
|
||||
* @return the reason the close animation ended
|
||||
*/
|
||||
suspend fun close() = animateTo(DrawerValue.Closed)
|
||||
|
||||
/**
|
||||
* Set the state of the drawer with specific animation
|
||||
*
|
||||
* @param targetValue The new value to animate to.
|
||||
* @param anim The animation that will be used to animate to the new value.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This method has been replaced by the open and close methods. The animation " +
|
||||
"spec is now an implementation detail of ModalDrawer.",
|
||||
)
|
||||
suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec<Float>) {
|
||||
animateTo(targetValue = targetValue, animationSpec = anim)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: DrawerValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* The target value of the drawer state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the Drawer would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: DrawerValue
|
||||
get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
|
||||
* initialized.
|
||||
*
|
||||
* @see [AnchoredDraggableState.offset] for more information.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Please access the offset through currentOffset, which returns the value " +
|
||||
"directly instead of wrapping it in a state object.",
|
||||
replaceWith = ReplaceWith("currentOffset"),
|
||||
)
|
||||
val offset: State<Float> = object : State<Float> {
|
||||
override val value: Float get() = anchoredDraggableState.offset
|
||||
}
|
||||
|
||||
/**
|
||||
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
|
||||
* initialized.
|
||||
*
|
||||
* @see [AnchoredDraggableState.offset] for more information.
|
||||
*/
|
||||
val currentOffset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? by mutableStateOf(null)
|
||||
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"The density on BottomDrawerState ($this) was not set. Did you use BottomDrawer" +
|
||||
" with the BottomDrawer composable?"
|
||||
}
|
||||
|
||||
internal fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
private suspend fun animateTo(
|
||||
targetValue: DrawerValue,
|
||||
animationSpec: AnimationSpec<Float> = AnimationSpec,
|
||||
velocity: Float = anchoredDraggableState.lastVelocity,
|
||||
) {
|
||||
anchoredDraggableState.anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
|
||||
val targetOffset = anchors.positionOf(latestTarget)
|
||||
if (!targetOffset.isNaN()) {
|
||||
var prev = if (currentOffset.isNaN()) 0f else currentOffset
|
||||
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
|
||||
// Our onDrag coerces the value within the bounds, but an animation may
|
||||
// overshoot, for example a spring animation or an overshooting interpolator
|
||||
// We respect the user's intention and allow the overshoot, but still use
|
||||
// DraggableState's drag for its mutex.
|
||||
dragTo(value, velocity)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [DrawerState2].
|
||||
*/
|
||||
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
|
||||
Saver<DrawerState2, DrawerValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { DrawerState2(it, confirmStateChange) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [DrawerState2].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDrawerState(
|
||||
initialValue: DrawerValue,
|
||||
confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
): DrawerState2 {
|
||||
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
|
||||
DrawerState2(initialValue, confirmStateChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalNavigationDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Box(modifier = blurModifier) {
|
||||
content()
|
||||
}
|
||||
Scrim(
|
||||
open = drawerState.isOpen,
|
||||
onClose = {
|
||||
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
|
||||
drawerState.close()
|
||||
}
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
|
||||
val predictiveModifier = if (drawerState.isOpen) {
|
||||
if (predictiveState > 0) {
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val multiplierX = lerp(1f, 1.05f, predictiveState)
|
||||
val multiplierY = lerp(1f, 0.95f, predictiveState)
|
||||
val scaledWidth = (multiplierX * placeable.width).roundToInt()
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
shape = DrawerDefaults.shape,
|
||||
)
|
||||
} else {
|
||||
Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.95f, -predictiveState)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = -it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(drawerState.requireOffset().roundToInt(), 0)
|
||||
} then predictiveModifier.align(Alignment.CenterStart),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SideDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drag.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Box(modifier = blurModifier) {
|
||||
content()
|
||||
}
|
||||
Scrim(
|
||||
open = drawerState.isOpen,
|
||||
onClose = {
|
||||
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
|
||||
drawerState.close()
|
||||
}
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
|
||||
val predictiveModifier = if (drawerState.isOpen) {
|
||||
if (predictiveState < 0) {
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val multiplierX = lerp(1f, 1.05f, -predictiveState)
|
||||
val multiplierY = lerp(1f, 0.95f, -predictiveState)
|
||||
val scaledWidth = (multiplierX * placeable.width).roundToInt()
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
shape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
)
|
||||
} else {
|
||||
Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.95f, predictiveState)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(drawerState.requireOffset().roundToInt(), 0)
|
||||
}.align(Alignment.CenterEnd) then predictiveModifier,
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f)
|
||||
|
||||
@Composable
|
||||
private fun Scrim(open: Boolean, onClose: () -> Unit, fraction: () -> Float, color: Color) {
|
||||
val dismissDrawer = if (open) {
|
||||
Modifier.pointerInput(onClose) { detectTapGestures { onClose() } }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Canvas(Modifier.fillMaxSize().then(dismissDrawer)) {
|
||||
drawRect(color, alpha = fraction())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.MutableWindowInsets
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.GenericShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text2.BasicTextField2
|
||||
import androidx.compose.foundation.text2.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text2.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
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.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.offset
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
|
||||
*
|
||||
* A search bar represents a floating search field that allows users to enter a keyword or phrase
|
||||
* and get relevant information. It can be used as a way to navigate through an app via search
|
||||
* queries.
|
||||
*
|
||||
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
|
||||
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
|
||||
* any [Constraints] that limit its size, and the host activity should set
|
||||
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
|
||||
*
|
||||
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
|
||||
* can be used instead.
|
||||
*
|
||||
* @param state the query text to be shown in the search bar's input field
|
||||
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
|
||||
* action.
|
||||
* @param active whether this search bar is active
|
||||
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
|
||||
* @param modifier the [Modifier] to be applied to this search bar
|
||||
* @param enabled controls the enabled state of this search bar. When `false`, this component will
|
||||
* not respond to user input, and it will appear visually disabled and disabled to accessibility
|
||||
* services.
|
||||
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
|
||||
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
|
||||
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
|
||||
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
|
||||
* always be [SearchBarDefaults.fullScreenShape].
|
||||
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
|
||||
* in different states. See [SearchBarDefaults.colors].
|
||||
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
|
||||
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
|
||||
* value will result in a darker color in light theme and lighter color in dark theme. See also:
|
||||
* [Surface].
|
||||
* @param shadowElevation the elevation for the shadow below the search bar
|
||||
* @param windowInsets the window insets that the search bar will respect
|
||||
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
|
||||
* for this search bar. You can create and pass in your own `remember`ed instance to observe
|
||||
* [Interaction]s and customize the appearance / behavior of this search bar in different states.
|
||||
* @param content the content of this search bar that will be displayed below the input field
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
active: Boolean,
|
||||
onActiveChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
shape: Shape = SearchBarDefaults.inputFieldShape,
|
||||
colors: SearchBarColors = SearchBarDefaults.colors(),
|
||||
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
|
||||
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
|
||||
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
|
||||
var firstBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
var currentBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
|
||||
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
|
||||
val useFullScreenShape by remember {
|
||||
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
|
||||
}
|
||||
val animatedShape = remember(useFullScreenShape, shape) {
|
||||
when {
|
||||
shape == defaultInputFieldShape ->
|
||||
// The shape can only be animated if it's the default spec value
|
||||
GenericShape { size, _ ->
|
||||
val radius = with(density) {
|
||||
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
|
||||
}
|
||||
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
|
||||
}
|
||||
useFullScreenShape -> defaultFullScreenShape
|
||||
else -> shape
|
||||
}
|
||||
}
|
||||
|
||||
// The main animation complexity is allowing the component to smoothly expand while keeping the
|
||||
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
|
||||
// support animation and thus is not suitable. Instead, we convert the insets to a padding
|
||||
// applied to the Surface, which gradually becomes padding applied to the input field as the
|
||||
// animation proceeds.
|
||||
val unconsumedInsets = remember { MutableWindowInsets() }
|
||||
val topPadding = remember(density) {
|
||||
derivedStateOf {
|
||||
SearchBarVerticalPadding +
|
||||
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = animatedShape,
|
||||
color = colors.containerColor,
|
||||
contentColor = contentColorFor(colors.containerColor),
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
modifier = modifier
|
||||
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
|
||||
.zIndex(1f)
|
||||
.onConsumedWindowInsetsChanged { consumedInsets ->
|
||||
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
|
||||
}
|
||||
.consumeWindowInsets(unconsumedInsets)
|
||||
.layout { measurable, constraints ->
|
||||
val animatedTopPadding =
|
||||
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
|
||||
|
||||
val startWidth: Int
|
||||
val startHeight: Int
|
||||
if (currentBackEvent == null) {
|
||||
startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
|
||||
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
|
||||
startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
|
||||
.coerceAtMost(constraints.maxHeight)
|
||||
} else {
|
||||
startWidth =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
startHeight =
|
||||
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
}
|
||||
val endWidth = constraints.maxWidth
|
||||
val endHeight = constraints.maxHeight
|
||||
|
||||
val width = lerp(startWidth, endWidth, animationProgress.value)
|
||||
val height =
|
||||
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
|
||||
|
||||
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
|
||||
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent,
|
||||
animationProgress.value,
|
||||
)
|
||||
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent,
|
||||
firstBackEvent,
|
||||
height,
|
||||
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
|
||||
)
|
||||
val placeable = measurable.measure(
|
||||
Constraints
|
||||
.fixed(width, height)
|
||||
.offset(
|
||||
vertical = -animatedTopPadding,
|
||||
),
|
||||
)
|
||||
layout(width, height) {
|
||||
placeable.placeRelative(
|
||||
predictiveBackOffsetX,
|
||||
animatedTopPadding + predictiveBackOffsetY,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
val animatedInputFieldPadding = remember {
|
||||
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
|
||||
}
|
||||
SearchBarInputField(
|
||||
state = state,
|
||||
onSearch = onSearch,
|
||||
onActiveChange = onActiveChange,
|
||||
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
|
||||
enabled = enabled,
|
||||
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
colors = colors.inputFieldColors,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
val showResults by remember {
|
||||
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
|
||||
}
|
||||
if (showResults) {
|
||||
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
|
||||
HorizontalDivider(color = colors.dividerColor)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
val shouldClearFocus = !active && isFocused
|
||||
if (shouldClearFocus) {
|
||||
LaunchedEffect(true) {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
|
||||
val animationSpec =
|
||||
if (animationInProgress) {
|
||||
AnimationPredictiveBackExitFloatSpec
|
||||
} else if (active) {
|
||||
AnimationEnterFloatSpec
|
||||
} else {
|
||||
AnimationExitFloatSpec
|
||||
}
|
||||
animationProgress.animateTo(
|
||||
targetValue = if (active) 1f else 0f,
|
||||
animationSpec = animationSpec,
|
||||
)
|
||||
}
|
||||
val mutatorMutex = remember { MutatorMutex() }
|
||||
if (active) {
|
||||
PredictiveBackHandler(true) { progress ->
|
||||
mutatorMutex.mutate {
|
||||
var canceled = false
|
||||
try {
|
||||
progress.collect { backEvent ->
|
||||
if (firstBackEvent == null) {
|
||||
firstBackEvent = backEvent
|
||||
}
|
||||
currentBackEvent = backEvent
|
||||
val interpolatedProgress = EaseOut.transform(backEvent.progress)
|
||||
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
canceled = true
|
||||
} finally {
|
||||
firstBackEvent = null
|
||||
currentBackEvent = null
|
||||
onActiveChange(canceled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetX(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
progress: Float,
|
||||
): Int {
|
||||
if (currentBackEvent == null) {
|
||||
return 0
|
||||
}
|
||||
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
|
||||
val maxOffsetX =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
|
||||
return (maxOffsetX * (1 - progress)).toInt() * directionMultiplier
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetY(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
firstBackEvent: BackEventCompat?,
|
||||
height: Int,
|
||||
maxOffsetY: Int,
|
||||
): Int {
|
||||
if (firstBackEvent == null || currentBackEvent == null) {
|
||||
return 0
|
||||
}
|
||||
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
|
||||
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
|
||||
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
|
||||
val yProgress = abs(yDelta) / constraints.maxHeight
|
||||
val directionMultiplier = sign(yDelta)
|
||||
return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
onActiveChange: (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 focused by interactionSource.collectIsFocusedAsState()
|
||||
val textColor = LocalTextStyle.current.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
|
||||
BasicTextField2(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
.height(InputFieldHeight)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
|
||||
.semantics {
|
||||
onClick {
|
||||
focusRequester.requestFocus()
|
||||
true
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
|
||||
interactionSource = interactionSource,
|
||||
decorator = @Composable { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading ->
|
||||
{
|
||||
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
|
||||
}
|
||||
},
|
||||
trailingIcon = trailingIcon?.let { trailing ->
|
||||
{
|
||||
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
|
||||
}
|
||||
},
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class AnimatedPaddingValues2(
|
||||
val animationProgress: State<Float>,
|
||||
val topPadding: State<Dp>,
|
||||
) : PaddingValues {
|
||||
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
|
||||
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
|
||||
|
||||
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
|
||||
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
|
||||
}
|
||||
|
||||
// Measurement specs
|
||||
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
|
||||
internal val SearchBarMinWidth: Dp = 360.dp
|
||||
private val SearchBarMaxWidth: Dp = 720.dp
|
||||
internal val SearchBarVerticalPadding: Dp = 8.dp
|
||||
internal val SearchBarHorizontalPadding: Dp = 16.dp
|
||||
|
||||
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
|
||||
private val SearchBarIconOffsetX: Dp = 4.dp
|
||||
|
||||
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
|
||||
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
|
||||
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
|
||||
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.dp
|
||||
|
||||
// Animation specs
|
||||
private const val AnimationEnterDurationMillis: Int = 600
|
||||
private const val AnimationExitDurationMillis: Int = 350
|
||||
private const val AnimationDelayMillis: Int = 100
|
||||
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
|
||||
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
|
||||
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationEnterDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationEnterEasing,
|
||||
)
|
||||
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphNavigator
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.NavigatorProvider
|
||||
|
||||
/**
|
||||
* Custom subclass of [NavGraphNavigator] that adds support for defining
|
||||
* transitions at the navigation graph level.
|
||||
*/
|
||||
@Navigator.Name("navigation")
|
||||
internal class ComposeNavGraphNavigator(
|
||||
navigatorProvider: NavigatorProvider,
|
||||
) : NavGraphNavigator(navigatorProvider) {
|
||||
override fun createDestination(): NavGraph {
|
||||
return ComposeNavGraph(this)
|
||||
}
|
||||
|
||||
internal class ComposeNavGraph(
|
||||
navGraphNavigator: Navigator<out NavGraph>,
|
||||
) : NavGraph(navGraphNavigator) {
|
||||
internal var enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
|
||||
internal var popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.ComposeNavigator.Destination
|
||||
|
||||
/**
|
||||
* Navigator that navigates through [Composable]s. Every destination using this Navigator must
|
||||
* set a valid [Composable] by setting it directly on an instantiated [Destination] or calling
|
||||
* [composable].
|
||||
*/
|
||||
@Navigator.Name("composable")
|
||||
public class ComposeNavigator : Navigator<Destination>() {
|
||||
|
||||
/**
|
||||
* Get the map of transitions currently in progress from the [state].
|
||||
*/
|
||||
internal val transitionsInProgress get() = state.transitionsInProgress
|
||||
|
||||
/**
|
||||
* Get the back stack from the [state].
|
||||
*/
|
||||
public val backStack get() = state.backStack
|
||||
|
||||
internal val isPop = mutableStateOf(false)
|
||||
|
||||
override fun navigate(
|
||||
entries: List<NavBackStackEntry>,
|
||||
navOptions: NavOptions?,
|
||||
navigatorExtras: Extras?,
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
state.pushWithTransition(entry)
|
||||
}
|
||||
isPop.value = false
|
||||
}
|
||||
|
||||
override fun createDestination(): Destination {
|
||||
return Destination(this) { }
|
||||
}
|
||||
|
||||
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
|
||||
state.popWithTransition(popUpTo, savedState)
|
||||
isPop.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to mark a navigation in transition as complete.
|
||||
*
|
||||
* This should be called in conjunction with [navigate] and [popBackStack] as those
|
||||
* calls merely start a transition to the target destination, and requires manually marking
|
||||
* the transition as complete by calling this method.
|
||||
*
|
||||
* Failing to call this method could result in entries being prevented from reaching their
|
||||
* final [Lifecycle.State].
|
||||
*/
|
||||
public fun onTransitionComplete(entry: NavBackStackEntry) {
|
||||
state.markTransitionComplete(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* NavDestination specific to [ComposeNavigator]
|
||||
*/
|
||||
@NavDestination.ClassType(Composable::class)
|
||||
public class Destination(
|
||||
navigator: ComposeNavigator,
|
||||
internal val content:
|
||||
@Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit,
|
||||
) : NavDestination(navigator) {
|
||||
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of Destination that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
constructor(
|
||||
navigator: ComposeNavigator,
|
||||
content: @Composable (NavBackStackEntry) -> @JvmSuppressWildcards Unit,
|
||||
) : this(navigator, content = { entry -> content(entry) })
|
||||
|
||||
internal var enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
|
||||
internal var popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
internal const val NAME = "composable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.DialogNavigator.Destination
|
||||
|
||||
/**
|
||||
* Show each [Destination] on the [DialogNavigator]'s back stack as a [Dialog].
|
||||
*
|
||||
* Note that [NavHost] will call this for you; you do not need to call it manually.
|
||||
*/
|
||||
@Composable
|
||||
public fun DialogHost(dialogNavigator: DialogNavigator) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
val dialogBackStack by dialogNavigator.backStack.collectAsState()
|
||||
val visibleBackStack = rememberVisibleList(dialogBackStack)
|
||||
visibleBackStack.PopulateVisibleList(dialogBackStack)
|
||||
|
||||
val transitionInProgress by dialogNavigator.transitionInProgress.collectAsState()
|
||||
val dialogsToDispose = remember { mutableStateListOf<NavBackStackEntry>() }
|
||||
|
||||
visibleBackStack.forEach { backStackEntry ->
|
||||
val destination = backStackEntry.destination as Destination
|
||||
Dialog(
|
||||
onDismissRequest = { dialogNavigator.dismiss(backStackEntry) },
|
||||
properties = destination.dialogProperties,
|
||||
) {
|
||||
DisposableEffect(backStackEntry) {
|
||||
dialogsToDispose.add(backStackEntry)
|
||||
onDispose {
|
||||
dialogNavigator.onTransitionComplete(backStackEntry)
|
||||
dialogsToDispose.remove(backStackEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// while in the scope of the composable, we provide the navBackStackEntry as the
|
||||
// ViewModelStoreOwner and LifecycleOwner
|
||||
backStackEntry.LocalOwnersProvider(saveableStateHolder) {
|
||||
destination.content(backStackEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dialogs may have been popped before it was composed. To prevent leakage, we need to
|
||||
// mark popped entries as complete here. Check that we don't accidentally complete popped
|
||||
// entries that were composed, unless they were disposed of already.
|
||||
LaunchedEffect(transitionInProgress, dialogsToDispose) {
|
||||
transitionInProgress.forEach { entry ->
|
||||
if (!dialogNavigator.backStack.value.contains(entry) &&
|
||||
!dialogsToDispose.contains(entry)
|
||||
) {
|
||||
dialogNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MutableList<NavBackStackEntry>.PopulateVisibleList(
|
||||
backStack: Collection<NavBackStackEntry>,
|
||||
) {
|
||||
val isInspecting = LocalInspectionMode.current
|
||||
backStack.forEach { entry ->
|
||||
DisposableEffect(entry.lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
// show dialog in preview
|
||||
if (isInspecting && !contains(entry)) {
|
||||
add(entry)
|
||||
}
|
||||
// ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
// We want to treat the visible lists as Sets but we want to keep
|
||||
// the functionality of mutableStateListOf() so that we recompose in response
|
||||
// to adds and removes.
|
||||
if (!contains(entry)) {
|
||||
add(entry)
|
||||
}
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
remove(entry)
|
||||
}
|
||||
}
|
||||
entry.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
entry.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberVisibleList(
|
||||
backStack: Collection<NavBackStackEntry>,
|
||||
): SnapshotStateList<NavBackStackEntry> {
|
||||
// show dialog in preview
|
||||
val isInspecting = LocalInspectionMode.current
|
||||
return remember(backStack) {
|
||||
mutableStateListOf<NavBackStackEntry>().also {
|
||||
it.addAll(
|
||||
backStack.filter { entry ->
|
||||
if (isInspecting) {
|
||||
true
|
||||
} else {
|
||||
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.FloatingWindow
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.DialogNavigator.Destination
|
||||
|
||||
/**
|
||||
* Navigator that navigates through [Composable]s that will be hosted within a
|
||||
* [Dialog]. Every destination using this Navigator must set a valid [Composable] by setting it
|
||||
* directly on an instantiated [Destination] or calling [dialog].
|
||||
*/
|
||||
@Navigator.Name("dialog")
|
||||
public class DialogNavigator : Navigator<Destination>() {
|
||||
|
||||
/**
|
||||
* Get the back stack from the [state].
|
||||
*/
|
||||
internal val backStack get() = state.backStack
|
||||
|
||||
/**
|
||||
* Get the transitioning dialogs from the [state].
|
||||
*/
|
||||
internal val transitionInProgress get() = state.transitionsInProgress
|
||||
|
||||
/**
|
||||
* Dismiss the dialog destination associated with the given [backStackEntry].
|
||||
*/
|
||||
internal fun dismiss(backStackEntry: NavBackStackEntry) {
|
||||
popBackStack(backStackEntry, false)
|
||||
}
|
||||
|
||||
override fun navigate(
|
||||
entries: List<NavBackStackEntry>,
|
||||
navOptions: NavOptions?,
|
||||
navigatorExtras: Extras?,
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
state.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createDestination(): Destination {
|
||||
return Destination(this) { }
|
||||
}
|
||||
|
||||
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
|
||||
state.popWithTransition(popUpTo, savedState)
|
||||
// When popping, the incoming dialog is marked transitioning to hold it in
|
||||
// STARTED. With pop complete, we can remove it from transition so it can move to RESUMED.
|
||||
val popIndex = state.transitionsInProgress.value.indexOf(popUpTo)
|
||||
// do not mark complete for entries up to and including popUpTo
|
||||
state.transitionsInProgress.value.forEachIndexed { index, entry ->
|
||||
if (index > popIndex) onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onTransitionComplete(entry: NavBackStackEntry) {
|
||||
state.markTransitionComplete(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* NavDestination specific to [DialogNavigator]
|
||||
*/
|
||||
@NavDestination.ClassType(Composable::class)
|
||||
public class Destination(
|
||||
navigator: DialogNavigator,
|
||||
internal val dialogProperties: DialogProperties = DialogProperties(),
|
||||
internal val content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) : NavDestination(navigator), FloatingWindow
|
||||
|
||||
internal companion object {
|
||||
internal const val NAME = "dialog"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.saveable.SaveableStateHolder
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Provides [this] [NavBackStackEntry] as [LocalViewModelStoreOwner], [LocalLifecycleOwner] and
|
||||
* [LocalSavedStateRegistryOwner] to the [content] and saves the [content]'s saveable states with
|
||||
* the given [saveableStateHolder].
|
||||
*
|
||||
* @param saveableStateHolder The [SaveableStateHolder] that holds the saved states. The same
|
||||
* holder should be used for all [NavBackStackEntry]s in the encapsulating [Composable] and the
|
||||
* holder should be hoisted.
|
||||
* @param content The content [Composable]
|
||||
*/
|
||||
@Composable
|
||||
public fun NavBackStackEntry.LocalOwnersProvider(
|
||||
saveableStateHolder: SaveableStateHolder,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides this,
|
||||
LocalLifecycleOwner provides this,
|
||||
LocalSavedStateRegistryOwner provides this,
|
||||
) {
|
||||
saveableStateHolder.SaveableStateProvider(content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) {
|
||||
val viewModel = viewModel<BackStackEntryIdViewModel>()
|
||||
// Stash a reference to the SaveableStateHolder in the ViewModel so that
|
||||
// it is available when the ViewModel is cleared, marking the permanent removal of this
|
||||
// NavBackStackEntry from the back stack. Which, because of animations,
|
||||
// only happens after this leaves composition. Which means we can't rely on
|
||||
// DisposableEffect to clean up this reference (as it'll be cleaned up too early)
|
||||
viewModel.saveableStateHolderRef = WeakReference(this)
|
||||
SaveableStateProvider(viewModel.id, content)
|
||||
}
|
||||
|
||||
internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() {
|
||||
|
||||
private val IdKey = "SaveableStateHolder_BackStackEntryKey"
|
||||
|
||||
// we create our own id for each back stack entry to support multiple entries of the same
|
||||
// destination. this id will be restored by SavedStateHandle
|
||||
val id: UUID = handle.get<UUID>(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) }
|
||||
|
||||
lateinit var saveableStateHolderRef: WeakReference<SaveableStateHolder>
|
||||
|
||||
// onCleared will be called on the entries removed from the back stack. here we notify
|
||||
// SaveableStateProvider that we should remove any state is had associated with this
|
||||
// destination as it is no longer needed.
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
saveableStateHolderRef.get()?.removeState(id)
|
||||
saveableStateHolderRef.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.get
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder]
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param content composable for the destination
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of composable builder that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
public fun NavGraphBuilder.composable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
ComposeNavigator.Destination(provider[ComposeNavigator::class]) {
|
||||
entry ->
|
||||
content(entry)
|
||||
}.apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder]
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param enterTransition callback to determine the destination's enter transition
|
||||
* @param exitTransition callback to determine the destination's exit transition
|
||||
* @param popEnterTransition callback to determine the destination's popEnter transition
|
||||
* @param popExitTransition callback to determine the destination's popExit transition
|
||||
* @param content composable for the destination
|
||||
*/
|
||||
public fun NavGraphBuilder.composable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null,
|
||||
exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null,
|
||||
popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? =
|
||||
enterTransition,
|
||||
popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? =
|
||||
exitTransition,
|
||||
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
ComposeNavigator.Destination(
|
||||
provider[ComposeNavigator::class],
|
||||
content,
|
||||
).apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
this.enterTransition = enterTransition
|
||||
this.exitTransition = exitTransition
|
||||
this.popEnterTransition = popEnterTransition
|
||||
this.popExitTransition = popExitTransition
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a nested [NavGraph]
|
||||
*
|
||||
* @sample androidx.navigation.compose.samples.NestedNavInGraphWithArgs
|
||||
*
|
||||
* @param startDestination the starting destination's route for this NavGraph
|
||||
* @param route the destination's unique route
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of navigation builder that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
public fun NavGraphBuilder.navigation(
|
||||
startDestination: String,
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a nested [NavGraph]
|
||||
*
|
||||
* @param startDestination the starting destination's route for this NavGraph
|
||||
* @param route the destination's unique route
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param enterTransition callback to define enter transitions for destination in this NavGraph
|
||||
* @param exitTransition callback to define exit transitions for destination in this NavGraph
|
||||
* @param popEnterTransition callback to define pop enter transitions for destination in this
|
||||
* NavGraph
|
||||
* @param popExitTransition callback to define pop exit transitions for destination in this NavGraph
|
||||
* @param builder the builder used to construct the graph
|
||||
*
|
||||
* @return the newly constructed nested NavGraph
|
||||
*/
|
||||
public fun NavGraphBuilder.navigation(
|
||||
startDestination: String,
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
|
||||
null,
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
|
||||
null,
|
||||
popEnterTransition: (
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = enterTransition,
|
||||
popExitTransition: (
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = exitTransition,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
if (this is ComposeNavGraphNavigator.ComposeNavGraph) {
|
||||
this.enterTransition = enterTransition
|
||||
this.exitTransition = exitTransition
|
||||
this.popEnterTransition = popEnterTransition
|
||||
this.popExitTransition = popExitTransition
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder] that will be hosted within a
|
||||
* [androidx.compose.ui.window.Dialog]. This is suitable only when this dialog represents
|
||||
* a separate screen in your app that needs its own lifecycle and saved state, independent
|
||||
* of any other destination in your navigation graph. For use cases such as `AlertDialog`,
|
||||
* you should use those APIs directly in the [composable] destination that wants to show that
|
||||
* dialog.
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param dialogProperties properties that should be passed to [androidx.compose.ui.window.Dialog].
|
||||
* @param content composable content for the destination that will be hosted within the Dialog
|
||||
*/
|
||||
public fun NavGraphBuilder.dialog(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
dialogProperties: DialogProperties = DialogProperties(),
|
||||
content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
DialogNavigator.Destination(
|
||||
provider[DialogNavigator::class],
|
||||
dialogProperties,
|
||||
content,
|
||||
).apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.createGraph
|
||||
import androidx.navigation.get
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import kotlin.math.roundToLong
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
|
||||
* contents of the builder cannot be changed.
|
||||
*
|
||||
* @sample androidx.navigation.compose.samples.NavScaffold
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param startDestination the route for the start destination
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param route the route for the graph
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of NavHost that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
route: String? = null,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController,
|
||||
remember(route, startDestination, builder) {
|
||||
navController.createGraph(startDestination, route, builder)
|
||||
},
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
|
||||
* contents of the builder cannot be changed.
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param startDestination the route for the start destination
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param contentAlignment The [Alignment] of the [AnimatedContent]
|
||||
* @param route the route for the graph
|
||||
* @param enterTransition callback to define enter transitions for destination in this host
|
||||
* @param exitTransition callback to define exit transitions for destination in this host
|
||||
* @param popEnterTransition callback to define popEnter transitions for destination in this host
|
||||
* @param popExitTransition callback to define popExit transitions for destination in this host
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
route: String? = null,
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
{ fadeIn(animationSpec = tween(700)) },
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
{ fadeOut(animationSpec = tween(700)) },
|
||||
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
enterTransition,
|
||||
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
exitTransition,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController,
|
||||
remember(route, startDestination, builder) {
|
||||
navController.createGraph(startDestination, route, builder)
|
||||
},
|
||||
modifier,
|
||||
contentAlignment,
|
||||
enterTransition,
|
||||
exitTransition,
|
||||
popEnterTransition,
|
||||
popExitTransition,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The graph passed into this method is [remember]ed. This means that for this NavHost, the graph
|
||||
* cannot be changed.
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param graph the graph for this host
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of NavHost that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
graph: NavGraph,
|
||||
modifier: Modifier = Modifier,
|
||||
) = NavHost(navController, graph, modifier)
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param graph the graph for this host
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param contentAlignment The [Alignment] of the [AnimatedContent]
|
||||
* @param enterTransition callback to define enter transitions for destination in this host
|
||||
* @param exitTransition callback to define exit transitions for destination in this host
|
||||
* @param popEnterTransition callback to define popEnter transitions for destination in this host
|
||||
* @param popExitTransition callback to define popExit transitions for destination in this host
|
||||
*/
|
||||
@SuppressLint("StateFlowValueCalledInComposition")
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
graph: NavGraph,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
{ fadeIn(animationSpec = tween(700)) },
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
{ fadeOut(animationSpec = tween(700)) },
|
||||
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
enterTransition,
|
||||
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
exitTransition,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
|
||||
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
|
||||
}
|
||||
|
||||
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
|
||||
|
||||
// Then set the graph
|
||||
navController.graph = graph
|
||||
|
||||
// Find the ComposeNavigator, returning early if it isn't found
|
||||
// (such as is the case when using TestNavHostController)
|
||||
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
|
||||
ComposeNavigator.NAME,
|
||||
) as? ComposeNavigator ?: return
|
||||
|
||||
val currentBackStack by composeNavigator.backStack.collectAsState()
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
// Setup the navController with proper owners
|
||||
navController.setLifecycleOwner(lifecycleOwner)
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
val allVisibleEntries by navController.visibleEntries.collectAsState()
|
||||
|
||||
// Intercept back only when there's a destination to pop
|
||||
val visibleEntries by remember {
|
||||
derivedStateOf {
|
||||
allVisibleEntries.filter { entry ->
|
||||
entry.destination.navigatorName == ComposeNavigator.NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backStackEntry: NavBackStackEntry? = visibleEntries.lastOrNull()
|
||||
|
||||
val zIndices = remember { mutableMapOf<String, Float>() }
|
||||
|
||||
if (backStackEntry != null) {
|
||||
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
val targetDestination = targetState.destination as ComposeNavigator.Destination
|
||||
|
||||
if (composeNavigator.isPop.value) {
|
||||
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createPopEnterTransition(this)
|
||||
} ?: popEnterTransition.invoke(this)
|
||||
} else {
|
||||
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createEnterTransition(this)
|
||||
} ?: enterTransition.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
||||
val initialDestination = initialState.destination as ComposeNavigator.Destination
|
||||
|
||||
if (composeNavigator.isPop.value) {
|
||||
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createPopExitTransition(this)
|
||||
} ?: popExitTransition.invoke(this)
|
||||
} else {
|
||||
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createExitTransition(this)
|
||||
} ?: exitTransition.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(true) {
|
||||
onDispose {
|
||||
visibleEntries.forEach { entry ->
|
||||
composeNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
|
||||
val transition = if (enablePredictiveBack) {
|
||||
val duration = 500000000L
|
||||
val transitionState = remember { MutableTransitionState(backStackEntry) }
|
||||
val transition = updateTransition(transitionState = transitionState, label = "entry")
|
||||
val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } }
|
||||
|
||||
fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) {
|
||||
val fraction = animatedFraction.value
|
||||
val playTimeNanos = (fraction * duration).roundToLong()
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos)
|
||||
}
|
||||
|
||||
LaunchedEffect(backStackEntry) {
|
||||
try {
|
||||
animatedFraction.animateTo(1f, animationSpec = tween(500, easing = LinearEasing)) {
|
||||
seekToFraction(transitionState.currentState, backStackEntry)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
animatedFraction.snapTo(0f)
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
|
||||
backStackEntry,
|
||||
backStackEntry,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(currentBackStack.size > 1) { flow ->
|
||||
// Prev can never be null, i.e. BackHandler should not enabled when prev is null
|
||||
val prev = currentBackStack.last { it != backStackEntry }
|
||||
try {
|
||||
flow.collect {
|
||||
val interpolated = EaseOut.transform(it.progress)
|
||||
animatedFraction.snapTo(interpolated)
|
||||
seekToFraction(backStackEntry, prev)
|
||||
}
|
||||
navController.popBackStack()
|
||||
} catch (e: CancellationException) {
|
||||
withContext(NonCancellable) {
|
||||
animatedFraction.snapTo(0f)
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
|
||||
backStackEntry,
|
||||
backStackEntry,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
transition
|
||||
} else {
|
||||
BackHandler(currentBackStack.size > 1) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
updateTransition(backStackEntry, label = "entry")
|
||||
}
|
||||
|
||||
transition.AnimatedContent(
|
||||
modifier,
|
||||
transitionSpec = {
|
||||
// If the initialState of the AnimatedContent is not in visibleEntries, we are in
|
||||
// a case where visible has cleared the old state for some reason, so instead of
|
||||
// attempting to animate away from the initialState, we skip the animation.
|
||||
if (initialState in visibleEntries) {
|
||||
val initialZIndex = zIndices[initialState.id]
|
||||
?: 0f.also { zIndices[initialState.id] = 0f }
|
||||
val targetZIndex = when {
|
||||
targetState.id == initialState.id -> initialZIndex
|
||||
composeNavigator.isPop.value -> initialZIndex - 1f
|
||||
else -> initialZIndex + 1f
|
||||
}.also { zIndices[targetState.id] = it }
|
||||
|
||||
ContentTransform(finalEnter(this), finalExit(this), targetZIndex)
|
||||
} else {
|
||||
EnterTransition.None togetherWith ExitTransition.None
|
||||
}
|
||||
},
|
||||
contentAlignment,
|
||||
contentKey = { it.id },
|
||||
) {
|
||||
// while in the scope of the composable, we provide the navBackStackEntry as the
|
||||
// ViewModelStoreOwner and LifecycleOwner
|
||||
it.LocalOwnersProvider(saveableStateHolder) {
|
||||
(it.destination as ComposeNavigator.Destination)
|
||||
.content(this, it)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(transition.currentState, transition.targetState) {
|
||||
if (transition.currentState == transition.targetState) {
|
||||
visibleEntries.forEach { entry ->
|
||||
composeNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
zIndices
|
||||
.filter { it.key != transition.targetState.id }
|
||||
.forEach { zIndices.remove(it.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
|
||||
DialogNavigator.NAME,
|
||||
) as? DialogNavigator ?: return
|
||||
|
||||
// Show any dialog destinations
|
||||
DialogHost(dialogNavigator)
|
||||
}
|
||||
|
||||
private fun NavDestination.createEnterTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): EnterTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.enterTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.enterTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun NavDestination.createExitTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): ExitTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.exitTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.exitTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun NavDestination.createPopEnterTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): EnterTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.popEnterTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popEnterTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
private fun NavDestination.createPopExitTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): ExitTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.popExitTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popExitTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
|
||||
/**
|
||||
* Gets the current navigation back stack entry as a [MutableState]. When the given navController
|
||||
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
|
||||
* will trigger a recompose and return the top entry on the back stack.
|
||||
*
|
||||
* @return a mutable state of the current back stack entry
|
||||
*/
|
||||
@Composable
|
||||
public fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?> {
|
||||
return currentBackStackEntryFlow.collectAsState(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a NavHostController that handles the adding of the [ComposeNavigator] and
|
||||
* [DialogNavigator]. Additional [Navigator] instances can be passed through [navigators] to
|
||||
* be applied to the returned NavController. Note that each [Navigator] must be separately
|
||||
* remembered before being passed in here: any changes to those inputs will cause the
|
||||
* NavController to be recreated.
|
||||
*
|
||||
* @see NavHost
|
||||
*/
|
||||
@Composable
|
||||
public fun rememberNavController(
|
||||
vararg navigators: Navigator<out NavDestination>,
|
||||
): NavHostController {
|
||||
val context = LocalContext.current
|
||||
return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) {
|
||||
createNavController(context)
|
||||
}.apply {
|
||||
for (navigator in navigators) {
|
||||
navigatorProvider.addNavigator(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNavController(context: Context) =
|
||||
NavHostController(context).apply {
|
||||
navigatorProvider.addNavigator(ComposeNavGraphNavigator(navigatorProvider))
|
||||
navigatorProvider.addNavigator(ComposeNavigator())
|
||||
navigatorProvider.addNavigator(DialogNavigator())
|
||||
}
|
||||
|
||||
/**
|
||||
* Saver to save and restore the NavController across config change and process death.
|
||||
*/
|
||||
private fun NavControllerSaver(
|
||||
context: Context,
|
||||
): Saver<NavHostController, *> = Saver<NavHostController, Bundle>(
|
||||
save = { it.saveState() },
|
||||
restore = { createNavController(context).apply { restoreState(it) } },
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
package androidx.navigation.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavOptions
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
fun onNavDestinationSelected2(@IdRes id: Int, navController: NavController): Boolean {
|
||||
if (navController.currentDestination?.matchDestination(id) == true) return true
|
||||
val builder = NavOptions.Builder().setLaunchSingleTop(true)
|
||||
if (navController.currentDestination!!.parent!!.findNode(id) is ActivityNavigator.Destination) {
|
||||
builder.setEnterAnim(R.anim.nav_default_enter_anim)
|
||||
.setExitAnim(R.anim.nav_default_exit_anim)
|
||||
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
|
||||
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
|
||||
} else {
|
||||
builder.setEnterAnim(androidx.fragment.R.animator.fragment_open_enter)
|
||||
.setExitAnim(androidx.fragment.R.animator.fragment_open_exit)
|
||||
.setPopEnterAnim(androidx.fragment.R.animator.fragment_close_enter)
|
||||
.setPopExitAnim(androidx.fragment.R.animator.fragment_close_exit)
|
||||
}
|
||||
val options = builder.build()
|
||||
return try {
|
||||
// TODO provide proper API instead of using Exceptions as Control-Flow.
|
||||
navController.navigate(id, null, options)
|
||||
// Return true only if the destination we've navigated to matches the MenuItem
|
||||
navController.currentDestination?.matchDestination(id) == true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
|
||||
hierarchy.any { it.id == destId }
|
||||
@@ -1,25 +0,0 @@
|
||||
package androidx.swiperefreshlayout.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import rikka.core.res.resolveColor
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
class ThemedSwipeRefreshLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : SwipeRefreshLayout(context, attrs) {
|
||||
|
||||
init {
|
||||
val surfaceColor =
|
||||
context.theme.resolveColor(com.google.android.material.R.attr.colorSurface)
|
||||
val overlayColor =
|
||||
context.getColor(com.google.android.material.R.color.m3_popupmenu_overlay_color)
|
||||
val backgroundColor = ColorUtils.compositeColors(overlayColor, surfaceColor)
|
||||
(mCircleView.background as ShapeDrawable).paint.color = backgroundColor
|
||||
setColorSchemeColors(context.theme.resolveColor(com.google.android.material.R.attr.colorAccent))
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Helper class used to set the source and additional attributes from a variety of sources. Supports
|
||||
* use of a bitmap, asset, resource, external file or any other URI.
|
||||
*
|
||||
*
|
||||
* When you are using a preview image, you must set the dimensions of the full size image on the
|
||||
* ImageSource object for the full size image using the [.dimensions] method.
|
||||
*/
|
||||
class ImageSource {
|
||||
val bitmap: Bitmap?
|
||||
val provider: InputProvider?
|
||||
|
||||
private var sWidth = 0
|
||||
private var sHeight = 0
|
||||
|
||||
var sRegion = Rect(0, 0, 0, 0)
|
||||
private set
|
||||
var isCached = false
|
||||
private set
|
||||
|
||||
private constructor(bitmap: Bitmap, cached: Boolean) {
|
||||
this.bitmap = bitmap
|
||||
provider = null
|
||||
sWidth = bitmap.width
|
||||
sHeight = bitmap.height
|
||||
isCached = cached
|
||||
}
|
||||
|
||||
private constructor(provider: InputProvider) {
|
||||
bitmap = null
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a region of the source image. Region must be set independently for the full size image and the preview if
|
||||
* you are using one.
|
||||
*
|
||||
* @param sRegion the region of the source image to be displayed.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun region(sRegion: Rect): ImageSource {
|
||||
this.sRegion = sRegion
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the dimensions of the image. This is only required for a full size image, when you are specifying a URI
|
||||
* and also a preview image. When displaying a bitmap object, or not using a preview, you do not need to declare
|
||||
* the image dimensions. Note if the declared dimensions are found to be incorrect, the view will reset.
|
||||
*
|
||||
* @param sWidth width of the source image.
|
||||
* @param sHeight height of the source image.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun dimensions(sWidth: Int, sHeight: Int): ImageSource {
|
||||
if (bitmap == null) {
|
||||
this.sWidth = sWidth
|
||||
this.sHeight = sHeight
|
||||
}
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
private fun setInvariants() {
|
||||
if (!sRegion.isEmpty) {
|
||||
sWidth = sRegion.width()
|
||||
sHeight = sRegion.height()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create an instance from an input provider.
|
||||
*
|
||||
* @param provider input stream provider.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun provider(provider: InputProvider): ImageSource {
|
||||
return ImageSource(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded bitmap for display.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun bitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded and cached bitmap for display. This bitmap will not be recycled when it is no
|
||||
* longer needed. Use this method if you loaded the bitmap with an image loader such as Picasso
|
||||
* or Volley.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun cachedBitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.PointF
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate.
|
||||
*/
|
||||
class ImageViewState(val scale: Float, center: PointF) : Serializable {
|
||||
private val centerX: Float = center.x
|
||||
private val centerY: Float = center.y
|
||||
|
||||
val center: PointF
|
||||
get() = PointF(centerX, centerY)
|
||||
}
|
||||
+25
-1071
File diff suppressed because it is too large.
Load diff
@@ -1,50 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Interface for image decoding classes.
|
||||
*/
|
||||
interface Decoder {
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
fun init(context: Context, provider: InputProvider): Point
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
val isReady: Boolean
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
fun recycle()
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
class ImageDecoder(private val cropBorders: Boolean, bitmapConfig: Bitmap.Config? = null) : Decoder {
|
||||
init {
|
||||
val globalBitmapConfig = SubsamplingScaleImageView.preferredBitmapConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
override fun init(context: Context, provider: InputProvider): Point {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
override fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
override val isReady: Boolean = TODO()
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
override fun recycle() {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.provider
|
||||
|
||||
interface InputProvider
|
||||
@@ -0,0 +1,653 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.accompanist.web
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.accompanist.web.LoadingState.Finished
|
||||
import com.google.accompanist.web.LoadingState.Loading
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreated: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
// WebView changes it's layout strategy based on
|
||||
// it's layoutParams. We convert from Compose Modifier to
|
||||
// layout params here.
|
||||
val width =
|
||||
if (constraints.hasFixedWidth) {
|
||||
LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
val height =
|
||||
if (constraints.hasFixedHeight) {
|
||||
LayoutParams.MATCH_PARENT
|
||||
} else {
|
||||
LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
|
||||
val layoutParams = FrameLayout.LayoutParams(
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
WebView(
|
||||
state,
|
||||
layoutParams,
|
||||
Modifier,
|
||||
captureBackPresses,
|
||||
navigator,
|
||||
onCreated,
|
||||
onDispose,
|
||||
client,
|
||||
chromeClient,
|
||||
factory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreated Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
layoutParams: FrameLayout.LayoutParams,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreated: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
val webView = state.webView
|
||||
|
||||
BackHandler(captureBackPresses && navigator.canGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
webView?.let { wv ->
|
||||
LaunchedEffect(wv, navigator) {
|
||||
with(navigator) {
|
||||
wv.handleNavigationEvents()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wv, state) {
|
||||
snapshotFlow { state.content }.collect { content ->
|
||||
when (content) {
|
||||
is WebContent.Url -> {
|
||||
wv.loadUrl(content.url, content.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is WebContent.Data -> {
|
||||
wv.loadDataWithBaseURL(
|
||||
content.baseUrl,
|
||||
content.data,
|
||||
content.mimeType,
|
||||
content.encoding,
|
||||
content.historyUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.Post -> {
|
||||
wv.postUrl(
|
||||
content.url,
|
||||
content.postData,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.NavigatorOnly -> {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the state of the client and chrome client
|
||||
// This is done internally to ensure they always are the same instance as the
|
||||
// parent Web composable
|
||||
client.state = state
|
||||
client.navigator = navigator
|
||||
chromeClient.state = state
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
(factory?.invoke(context) ?: WebView(context)).apply {
|
||||
onCreated(this)
|
||||
|
||||
this.layoutParams = layoutParams
|
||||
|
||||
state.viewState?.let {
|
||||
this.restoreState(it)
|
||||
}
|
||||
|
||||
webChromeClient = chromeClient
|
||||
webViewClient = client
|
||||
}.also { state.webView = it }
|
||||
},
|
||||
modifier = modifier,
|
||||
onRelease = {
|
||||
onDispose(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebViewClient
|
||||
*
|
||||
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebViewClient : WebViewClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
open lateinit var navigator: WebViewNavigator
|
||||
internal set
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
state.loadingState = Loading(0.0f)
|
||||
state.errorsForCurrentRequest.clear()
|
||||
state.pageTitle = null
|
||||
state.pageIcon = null
|
||||
|
||||
state.lastLoadedUrl = url
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
state.loadingState = Finished
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
|
||||
navigator.canGoBack = view.canGoBack()
|
||||
navigator.canGoForward = view.canGoForward()
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
|
||||
if (error != null) {
|
||||
state.errorsForCurrentRequest.add(WebViewError(request, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebChromeClient
|
||||
*
|
||||
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebChromeClient : WebChromeClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
|
||||
override fun onReceivedTitle(view: WebView, title: String?) {
|
||||
super.onReceivedTitle(view, title)
|
||||
state.pageTitle = title
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
|
||||
super.onReceivedIcon(view, icon)
|
||||
state.pageIcon = icon
|
||||
}
|
||||
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
super.onProgressChanged(view, newProgress)
|
||||
if (state.loadingState is Finished) return
|
||||
state.loadingState = Loading(newProgress / 100.0f)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WebContent {
|
||||
data class Url(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : WebContent()
|
||||
|
||||
data class Data(
|
||||
val data: String,
|
||||
val baseUrl: String? = null,
|
||||
val encoding: String = "utf-8",
|
||||
val mimeType: String? = null,
|
||||
val historyUrl: String? = null,
|
||||
) : WebContent()
|
||||
|
||||
data class Post(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : WebContent() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Post
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data object NavigatorOnly : WebContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class for constraining possible loading states.
|
||||
* See [Loading] and [Finished].
|
||||
*/
|
||||
sealed class LoadingState {
|
||||
/**
|
||||
* Describes a WebView that has not yet loaded for the first time.
|
||||
*/
|
||||
data object Initializing : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
|
||||
* [progress] property which is updated by the webview.
|
||||
*/
|
||||
data class Loading(val progress: Float) : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview that has finished loading content.
|
||||
*/
|
||||
data object Finished : LoadingState()
|
||||
}
|
||||
|
||||
/**
|
||||
* A state holder to hold the state for the WebView. In most cases this will be remembered
|
||||
* using the rememberWebViewState(uri) function.
|
||||
*/
|
||||
@Stable
|
||||
class WebViewState(webContent: WebContent) {
|
||||
var lastLoadedUrl: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* The content being loaded by the WebView
|
||||
*/
|
||||
var content: WebContent by mutableStateOf(webContent)
|
||||
|
||||
/**
|
||||
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
|
||||
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
|
||||
*/
|
||||
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Whether the webview is currently loading data in its main frame
|
||||
*/
|
||||
val isLoading: Boolean
|
||||
get() = loadingState !is Finished
|
||||
|
||||
/**
|
||||
* The title received from the loaded content of the current page
|
||||
*/
|
||||
var pageTitle: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* the favicon received from the loaded content of the current page
|
||||
*/
|
||||
var pageIcon: Bitmap? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* A list for errors captured in the last load. Reset when a new page is loaded.
|
||||
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
|
||||
* For more fine grained control use the OnError callback of the WebView.
|
||||
*/
|
||||
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
|
||||
|
||||
/**
|
||||
* The saved view state from when the view was destroyed last. To restore state,
|
||||
* use the navigator and only call loadUrl if the bundle is null.
|
||||
* See WebViewSaveStateSample.
|
||||
*/
|
||||
var viewState: Bundle? = null
|
||||
internal set
|
||||
|
||||
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
|
||||
// onDestroy is called after the state saver and so can't be used.
|
||||
internal var webView by mutableStateOf<WebView?>(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
|
||||
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
|
||||
*
|
||||
* @see [rememberWebViewNavigator]
|
||||
*/
|
||||
@Stable
|
||||
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
|
||||
private sealed interface NavigationEvent {
|
||||
data object Back : NavigationEvent
|
||||
data object Forward : NavigationEvent
|
||||
data object Reload : NavigationEvent
|
||||
data object StopLoading : NavigationEvent
|
||||
|
||||
data class LoadUrl(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : NavigationEvent
|
||||
|
||||
data class LoadHtml(
|
||||
val html: String,
|
||||
val baseUrl: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val encoding: String? = "utf-8",
|
||||
val historyUrl: String? = null,
|
||||
) : NavigationEvent
|
||||
|
||||
data class PostUrl(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : NavigationEvent {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PostUrl
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
|
||||
|
||||
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
|
||||
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
|
||||
navigationEvents.collect { event ->
|
||||
when (event) {
|
||||
is NavigationEvent.Back -> goBack()
|
||||
is NavigationEvent.Forward -> goForward()
|
||||
is NavigationEvent.Reload -> reload()
|
||||
is NavigationEvent.StopLoading -> stopLoading()
|
||||
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
|
||||
event.baseUrl,
|
||||
event.html,
|
||||
event.mimeType,
|
||||
event.encoding,
|
||||
event.historyUrl,
|
||||
)
|
||||
|
||||
is NavigationEvent.LoadUrl -> {
|
||||
loadUrl(event.url, event.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is NavigationEvent.PostUrl -> {
|
||||
postUrl(event.url, event.postData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate backwards, false otherwise.
|
||||
*/
|
||||
var canGoBack: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate forwards, false otherwise.
|
||||
*/
|
||||
var canGoForward: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadUrl(
|
||||
url,
|
||||
additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadHtml(
|
||||
html: String,
|
||||
baseUrl: String? = null,
|
||||
mimeType: String? = null,
|
||||
encoding: String? = "utf-8",
|
||||
historyUrl: String? = null,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadHtml(
|
||||
html,
|
||||
baseUrl,
|
||||
mimeType,
|
||||
encoding,
|
||||
historyUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun postUrl(
|
||||
url: String,
|
||||
postData: ByteArray,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.PostUrl(
|
||||
url,
|
||||
postData,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview back to the previous page.
|
||||
*/
|
||||
fun navigateBack() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview forward after going back from a page.
|
||||
*/
|
||||
fun navigateForward() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page in the webview.
|
||||
*/
|
||||
fun reload() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current page load (if one is loading).
|
||||
*/
|
||||
fun stopLoading() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
|
||||
* override.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewNavigator(
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
|
||||
|
||||
/**
|
||||
* A wrapper class to hold errors from the WebView.
|
||||
*/
|
||||
@Immutable
|
||||
data class WebViewError(
|
||||
/**
|
||||
* The request the error came from.
|
||||
*/
|
||||
val request: WebResourceRequest?,
|
||||
/**
|
||||
* The error that was reported.
|
||||
*/
|
||||
val error: WebResourceError,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions.
|
||||
*
|
||||
* @param url The url to load in the WebView
|
||||
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
|
||||
* Note that these headers are used for all subsequent requests of the WebView.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewState(
|
||||
url: String,
|
||||
additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
): 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(
|
||||
WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}.apply {
|
||||
this.content = WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
)
|
||||
}
|
||||
@@ -18,28 +18,31 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
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 coil.ImageLoaderFactory
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.util.DebugLogger
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.ImageDecoderDecoder
|
||||
import coil3.request.crossfade
|
||||
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.MergeInterceptor
|
||||
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
|
||||
import com.hippo.ehviewer.coil.installKtorHttpUriFetcher
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.ktbuilder.cache
|
||||
import com.hippo.ehviewer.ktbuilder.chunker
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.httpClient
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.CronetEngine
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.legacy.migrateCookies
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
@@ -48,15 +51,15 @@ import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isCronetAvailable
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.apache5.Apache5
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.kt.unreachable
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
@@ -64,11 +67,16 @@ import splitties.init.appCtx
|
||||
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication : Application(), ImageLoaderFactory {
|
||||
class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
val mode = Settings.theme
|
||||
if (!isAtLeastS) {
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lockObserver)
|
||||
val handler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
@@ -88,9 +96,6 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
System.loadLibrary("ehviewer_rust")
|
||||
ReadableTime.initialize(this)
|
||||
lifecycleScope.launchIO {
|
||||
launchIO {
|
||||
migrateCookies()
|
||||
}
|
||||
launchIO {
|
||||
EhTagDatabase
|
||||
}
|
||||
@@ -110,6 +115,12 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
}
|
||||
cleanObsoleteCache(this)
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat(Log.VERBOSE) { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupDownload() {
|
||||
@@ -145,12 +156,7 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
override fun newImageLoader() = imageLoader {
|
||||
components {
|
||||
if (isCronetSupported) {
|
||||
callFactory { unreachable() }
|
||||
installCronetHttpUriFetcher()
|
||||
} else {
|
||||
callFactory(baseOkHttpClient)
|
||||
}
|
||||
installKtorHttpUriFetcher()
|
||||
if (isAtLeastP) {
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
}
|
||||
@@ -158,37 +164,34 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
diskCache(imageCache)
|
||||
crossfade(300)
|
||||
error(R.drawable.image_failed)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asCoilImage(true))
|
||||
if (BuildConfig.DEBUG) logger(DebugLogger())
|
||||
respectCacheHeaders(false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val baseOkHttpClient by lazy {
|
||||
httpClient {
|
||||
cookieJar(EhCookieStore)
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
val ktorClient by lazy {
|
||||
if (Settings.enableCronet && isCronetAvailable) {
|
||||
HttpClient(CronetEngine) {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpClient(Apache5) {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
chunker { alwaysReadResponseBody(false) }
|
||||
addInterceptor(UncaughtExceptionInterceptor())
|
||||
addInterceptor(CloudflareInterceptor(appCtx))
|
||||
}
|
||||
}
|
||||
|
||||
val noRedirectOkHttpClient by lazy {
|
||||
httpClient(baseOkHttpClient) {
|
||||
followRedirects(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Never use this okhttp client to download large blobs!!!
|
||||
val okHttpClient by lazy {
|
||||
httpClient(baseOkHttpClient) {
|
||||
cache(
|
||||
appCtx.cacheDir.toOkioPath() / "http_cache",
|
||||
20L * 1024L * 1024L,
|
||||
)
|
||||
val noRedirectKtorClient by lazy {
|
||||
HttpClient(ktorClient.engine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ object EhDB {
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
removeLocalFavorites(it)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ object EhDB {
|
||||
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
putLocalFavorites(it)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ object EhDB {
|
||||
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun modifyHistoryInfoFavslotNonRefresh(gid: Long, slot: Int) {
|
||||
suspend fun updateFavoriteSlot(gid: Long, slot: Int) {
|
||||
val dao = db.galleryDao()
|
||||
dao.load(gid)?.let {
|
||||
it.favoriteSlot = slot
|
||||
@@ -292,7 +292,7 @@ object EhDB {
|
||||
}
|
||||
runCatching {
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList, false)
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.MutableState
|
||||
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.EhUtils
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -13,9 +22,78 @@ import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import splitties.preferences.BoolPref
|
||||
import splitties.preferences.DataStorePreferences
|
||||
import splitties.preferences.FloatPref
|
||||
import splitties.preferences.IntPref
|
||||
import splitties.preferences.LongPref
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.preferences.StringOrNullPref
|
||||
import splitties.preferences.StringPref
|
||||
import splitties.preferences.StringSetOrNullPref
|
||||
import splitties.preferences.StringSetPref
|
||||
import splitties.preferences.edit
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
|
||||
is BoolPref -> value
|
||||
is IntPref -> value
|
||||
is FloatPref -> value
|
||||
is LongPref -> value
|
||||
is StringPref -> value
|
||||
is StringOrNullPref -> value
|
||||
is StringSetPref -> value
|
||||
is StringSetOrNullPref -> value
|
||||
} as T
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
|
||||
is BoolPref -> value = newValue as Boolean
|
||||
is IntPref -> value = newValue as Int
|
||||
is FloatPref -> value = newValue as Float
|
||||
is LongPref -> value = newValue as Long
|
||||
is StringPref -> value = newValue as String
|
||||
is StringOrNullPref -> value = newValue as? String
|
||||
is StringSetPref -> value = newValue as Set<String?>
|
||||
is StringSetOrNullPref -> value = newValue as? Set<String?>
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
|
||||
val flow = remember { valueFlow().map { transform(it) } }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(transform(init))
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(init)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
val readOnly = flow.collectAsState(init)
|
||||
return remember {
|
||||
object : MutableState<T> {
|
||||
override var value: T
|
||||
get() = readOnly.value
|
||||
set(value) {
|
||||
setValue(value)
|
||||
}
|
||||
override fun component1() = TODO()
|
||||
override fun component2() = TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Settings : DataStorePreferences(null) {
|
||||
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
|
||||
|
||||
@@ -25,6 +103,15 @@ object Settings : DataStorePreferences(null) {
|
||||
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
|
||||
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
|
||||
|
||||
val listMode = intPref("list_mode_2", 0)
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
|
||||
val needSignInFlow: Flow<Boolean>
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
@@ -38,8 +125,6 @@ object Settings : DataStorePreferences(null) {
|
||||
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 listMode by intPref("list_mode_2", 0)
|
||||
var detailSize by intPref("detail_size_2", 0)
|
||||
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)
|
||||
@@ -51,7 +136,6 @@ object Settings : DataStorePreferences(null) {
|
||||
var showJpnTitle by boolPref("show_jpn_title", false)
|
||||
var showGalleryPages by boolPref("show_gallery_pages", true)
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var meteredNetworkWarning by boolPref("cellular_network_warning", false)
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
@@ -63,21 +147,18 @@ object Settings : DataStorePreferences(null) {
|
||||
var security by boolPref("require_unlock", false)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
|
||||
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
|
||||
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 enableCronet by boolPref("enable_cronet", true)
|
||||
var thumbSizeDp by intPref("thumb_size_", 120)
|
||||
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)
|
||||
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
|
||||
var listThumbSize by intPref("list_tile_size", 40)
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
|
||||
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
|
||||
var updateIntervalDays by intPref("update_interval_days", 0)
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
|
||||
@@ -85,8 +166,22 @@ object Settings : DataStorePreferences(null) {
|
||||
var displayName by stringOrNullPref("display_name", null)
|
||||
var avatar by stringOrNullPref("avatar", null)
|
||||
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
|
||||
var lastDawnDay by longPref("last_dawn_day", 0)
|
||||
var lastUpdateDay by longPref("last_update_day", 0)
|
||||
var lastDawnTime by longPref("last_dawn_time", 0)
|
||||
var lastUpdateTime by longPref("last_update_time", 0)
|
||||
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
|
||||
|
||||
// TODO: Remove this after swipe gestures are correctly handled in compose
|
||||
var touchSlopFactor by intPref("touch_slop", 3)
|
||||
|
||||
// Tachiyomi Reader
|
||||
val colorFilter = boolPref("pref_color_filter_key", false)
|
||||
val colorFilterValue = intPref("color_filter_value", 0)
|
||||
val colorFilterMode = intPref("color_filter_mode", 0)
|
||||
val customBrightness = boolPref("pref_custom_brightness_key", false)
|
||||
val customBrightnessValue = intPref("custom_brightness_value", 0)
|
||||
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.LEFT_TO_RIGHT.flagValue)
|
||||
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
|
||||
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.systemservices.uiModeManager
|
||||
|
||||
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
|
||||
@@ -44,14 +46,14 @@ fun updateWhenKeepMediaStatusChanges() {
|
||||
fun updateWhenThemeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenAmoledModeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY)
|
||||
if (isAtLeastS) {
|
||||
val mode = when (Settings.theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastFilter
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastJoinToString
|
||||
import androidx.compose.ui.util.fastMapNotNull
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import io.ktor.client.plugins.cookies.CookiesStorage
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.parseClientCookiesHeader
|
||||
import io.ktor.http.renderSetCookieHeader
|
||||
|
||||
object EhCookieStore : CookieJar {
|
||||
object EhCookieStore : CookiesStorage {
|
||||
private val manager = CookieManager.getInstance()
|
||||
fun signOut() = manager.removeAllCookies(null)
|
||||
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (!cookies.isNullOrEmpty()) {
|
||||
cookies.split(";").fastMapNotNull { setCookie ->
|
||||
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
|
||||
|
||||
fun hasSignedIn(): Boolean {
|
||||
val url = EhUrl.HOST_E.toHttpUrl()
|
||||
val url = EhUrl.HOST_E
|
||||
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
|
||||
}
|
||||
|
||||
@@ -55,16 +27,13 @@ object EhCookieStore : CookieJar {
|
||||
private const val KEY_CONTENT_WARNING = "nw"
|
||||
private const val CONTENT_WARNING_NOT_SHOW = "1"
|
||||
private const val KEY_UTMP_NAME = "__utmp"
|
||||
private val sTipsCookie = Cookie.Builder().apply {
|
||||
name(KEY_CONTENT_WARNING)
|
||||
value(CONTENT_WARNING_NOT_SHOW)
|
||||
domain(EhUrl.DOMAIN_E)
|
||||
path("/")
|
||||
expiresAt(Long.MAX_VALUE)
|
||||
}.build()
|
||||
private val sTipsCookie = Cookie(
|
||||
name = KEY_CONTENT_WARNING,
|
||||
value = CONTENT_WARNING_NOT_SHOW,
|
||||
)
|
||||
|
||||
fun copyNecessaryCookies() {
|
||||
val cookies = get(EhUrl.HOST_E.toHttpUrl())
|
||||
val cookies = load(Url(EhUrl.HOST_E))
|
||||
cookies.fastForEach {
|
||||
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
|
||||
manager.setCookie(EhUrl.HOST_EX, it.toString())
|
||||
@@ -72,37 +41,41 @@ object EhCookieStore : CookieJar {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCookie(url: HttpUrl, name: String) {
|
||||
manager.setCookie(url.toString(), "$name=;Max-Age=0")
|
||||
}
|
||||
|
||||
fun addCookie(cookie: Cookie) {
|
||||
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
|
||||
fun addCookie(k: String, v: String, domain: String) {
|
||||
val cookie = Cookie(name = k, value = v, domain = domain, maxAge = Int.MAX_VALUE)
|
||||
val url = if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX
|
||||
manager.setCookie(url, renderSetCookieHeader(cookie))
|
||||
}
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
fun getCookieHeader(url: HttpUrl): String {
|
||||
return loadForRequest(url).fastJoinToString("; ") {
|
||||
fun getCookieHeader(url: String): String {
|
||||
return load(Url(url)).fastJoinToString("; ") {
|
||||
"${it.name}=${it.value}"
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
|
||||
if (it.name != KEY_UTMP_NAME) {
|
||||
manager.setCookie(url.toString(), it.toString())
|
||||
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
|
||||
if (cookie.name != KEY_UTMP_NAME) {
|
||||
manager.setCookie(requestUrl.toString(), renderSetCookieHeader(cookie))
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
override fun close() = Unit
|
||||
|
||||
fun load(url: Url): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString()) ?: return emptyList()
|
||||
val checkTips = EhUrl.DOMAIN_E in url.host
|
||||
return get(url).run {
|
||||
if (checkTips) {
|
||||
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
|
||||
} else {
|
||||
this
|
||||
return parseClientCookiesHeader(cookies)
|
||||
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
|
||||
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
|
||||
if (checkTips) {
|
||||
removeAll { it.name == KEY_CONTENT_WARNING }
|
||||
add(sTipsCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(requestUrl: Url) = load(requestUrl)
|
||||
}
|
||||
@@ -15,12 +15,15 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.util.Log
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
@@ -33,8 +36,8 @@ import com.hippo.ehviewer.client.parser.ForumsParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListResult
|
||||
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
@@ -45,45 +48,68 @@ import com.hippo.ehviewer.client.parser.TorrentParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteCommentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.cronet.awaitBodyFully
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.forms.append
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.core.writeFully
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
|
||||
private const val TAG = "EhEngine"
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
|
||||
const val TAG = "EhEngine"
|
||||
|
||||
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
// https://ehwiki.org/wiki/API#Basics
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
private const val REQUEST_INTERVAL = 5000L
|
||||
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
|
||||
RandomAccessFile(file, "rw").use { randomAccessFile ->
|
||||
with(randomAccessFile) {
|
||||
writeUTF(e.message + "\n")
|
||||
onLeft { writeUTF(it) }
|
||||
onRight { channel.write(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Check sad panda(without panda)
|
||||
if (body.isEmpty()) {
|
||||
val empty = body.fold(
|
||||
{ it.isEmpty() },
|
||||
{ !it.hasRemaining() },
|
||||
)
|
||||
if (empty) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
} else {
|
||||
@@ -92,10 +118,12 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
}
|
||||
|
||||
// Check Gallery Not Available
|
||||
if (body.contains("Gallery Not Available - ")) {
|
||||
val error = GalleryNotAvailableParser.parse(body)
|
||||
if (!error.isNullOrBlank()) {
|
||||
throw EhException(error)
|
||||
body.onLeft {
|
||||
if ("Gallery Not Available - " in it) {
|
||||
val error = GalleryNotAvailableParser.parse(it)
|
||||
if (!error.isNullOrBlank()) {
|
||||
throw EhException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,136 +132,108 @@ private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
|
||||
if (e is ParseException) {
|
||||
if ("<" !in body) {
|
||||
throw EhException(body)
|
||||
} else {
|
||||
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
}
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
body.onLeft { if ("<" !in it) throw EhException(it) }
|
||||
if (Settings.saveParseErrorBody) body.saveParseError(e)
|
||||
throw EhException(appCtx.getString(R.string.error_parse_error), e)
|
||||
}
|
||||
|
||||
// We can't translate it, rethrow it anyway
|
||||
throw e
|
||||
}
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(16, 0x20000)
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend inline fun <T> fetchCompat(url: String, referer: String? = null, crossinline parser: (ByteBuffer) -> T): T {
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer).execute {
|
||||
httpContentPool.useInstance { buffer ->
|
||||
awaitBodyFully {
|
||||
buffer.put(it)
|
||||
}
|
||||
buffer.flip()
|
||||
parser(buffer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ehRequest(url, referer).execute {
|
||||
httpContentPool.useInstance { buffer ->
|
||||
while (true) {
|
||||
if (body.source().read(buffer) == -1) break
|
||||
}
|
||||
buffer.flip()
|
||||
parser(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
val body = response.bodyAsText()
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, body.left(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
|
||||
Log.d(TAG, url.toString())
|
||||
return execute {
|
||||
val body = body.string()
|
||||
try {
|
||||
block(body)
|
||||
} catch (e: Exception) {
|
||||
rethrowExactly(code, body, e)
|
||||
}
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
httpContentPool.useInstance { buffer ->
|
||||
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
|
||||
buffer.flip()
|
||||
runSuspendCatching {
|
||||
block(buffer)
|
||||
}.onFailure {
|
||||
rethrowExactly(response.status.value, buffer.right(), it)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object EhEngine {
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
|
||||
Log.d(TAG, url)
|
||||
return ehRequest(url, referer).executeNoRedirect {
|
||||
header("Location")?.apply {
|
||||
if (contains("bounce_login")) {
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
} ?: throw InsufficientFundsException()
|
||||
}
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).execute { response ->
|
||||
val location = response.headers["Location"] ?: throw InsufficientFundsException()
|
||||
location.takeIf { "bounce_login" !in it } ?: throw NotLoggedInException()
|
||||
}
|
||||
|
||||
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return fetchCompat(url, referer, TorrentParser::parse)
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.executeAndParsingWith(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
|
||||
.fetchUsingAsText(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
|
||||
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
|
||||
{ limits, funds -> HomeParser.Result(limits, funds) },
|
||||
)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
|
||||
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
suspend fun getProfile(): ProfileParser.Result {
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsText(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsText(ProfileParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
runSuspendCatching {
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
}.onFailure {
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
}.onFailure { throwable ->
|
||||
// It may get redirected when accessing ex for the first time
|
||||
if (url == EhUrl.URL_UCONFIG_EX) {
|
||||
it.printStackTrace()
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
throwable.printStackTrace()
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
} else {
|
||||
throw it
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGalleryPage(
|
||||
url: String,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
): GalleryPageParser.Result {
|
||||
suspend fun getGalleryPage(url: String, gid: Long, token: String?): GalleryPageParser.Result {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
|
||||
return ehRequest(url, referer).fetchUsingAsText(GalleryPageParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer)
|
||||
.executeAndParsingWith(GalleryListParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, true) }
|
||||
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryListParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url, true) }
|
||||
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
|
||||
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
EventPaneParser.parse(this)?.let {
|
||||
Settings.lastDawnDay = today
|
||||
showEventNotification(it)
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
val eventPane = EventPaneParser.parse(this)
|
||||
if (eventPane != null) {
|
||||
Settings.lastDawnTime = Clock.System.now().epochSeconds
|
||||
showEventNotification(eventPane)
|
||||
}
|
||||
GalleryDetailParser.parse(this)
|
||||
}
|
||||
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
|
||||
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
|
||||
}
|
||||
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer)
|
||||
.executeAndParsingWith(FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url) }
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
@@ -241,26 +241,26 @@ object EhEngine {
|
||||
val origin = "https://forums.e-hentai.org"
|
||||
return ehRequest(url, referer, origin) {
|
||||
formBody {
|
||||
add("referer", referer)
|
||||
add("b", "")
|
||||
add("bt", "")
|
||||
add("UserName", username)
|
||||
add("PassWord", password)
|
||||
add("CookieDate", "1")
|
||||
append("referer", referer)
|
||||
append("b", "")
|
||||
append("bt", "")
|
||||
append("UserName", username)
|
||||
append("PassWord", password)
|
||||
append("CookieDate", "1")
|
||||
}
|
||||
}.executeAndParsingWith(SignInParser::parse)
|
||||
}.fetchUsingAsText(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: String?) = ehRequest(url, url, EhUrl.origin) {
|
||||
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == null) {
|
||||
add("commenttext_new", comment)
|
||||
if (id == -1L) {
|
||||
append("commenttext_new", comment)
|
||||
} else {
|
||||
add("commenttext_edit", comment)
|
||||
add("edit_comment", id)
|
||||
append("commenttext_edit", comment)
|
||||
append("edit_comment", id.toString())
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith {
|
||||
}.fetchUsingAsText {
|
||||
val document = Jsoup.parse(this)
|
||||
val elements = document.select("#chd + p")
|
||||
if (elements.size > 0) {
|
||||
@@ -269,71 +269,50 @@ object EhEngine {
|
||||
GalleryDetailParser.parseComments(document)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dstCat -1 for delete, 0 - 9 for cloud favorite, others throw Exception
|
||||
* @param note max 250 characters
|
||||
*/
|
||||
suspend fun modifyFavorites(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
dstCat: Int = -1,
|
||||
note: String = "",
|
||||
) {
|
||||
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "favdel"
|
||||
in 0..9 -> dstCat.toString()
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
val url = EhUrl.getAddFavorites(gid, token)
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
add("favcat", catStr)
|
||||
add("favnote", note)
|
||||
append("favcat", catStr)
|
||||
append("favnote", note)
|
||||
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
|
||||
add("apply", "Apply Changes")
|
||||
add("update", "1")
|
||||
append("apply", "Apply Changes")
|
||||
append("update", "1")
|
||||
}
|
||||
}.executeAndParsingWith { }
|
||||
}.execute { }
|
||||
}
|
||||
|
||||
suspend fun downloadArchive(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
or: String?,
|
||||
res: String?,
|
||||
isHAtH: Boolean,
|
||||
): String? {
|
||||
if (or.isNullOrEmpty()) {
|
||||
throw EhException("Invalid form param or: $or")
|
||||
}
|
||||
if (res.isNullOrEmpty()) {
|
||||
throw EhException("Invalid res: $res")
|
||||
}
|
||||
suspend fun downloadArchive(gid: Long, token: String?, or: String?, res: String?, isHAtH: Boolean): String? {
|
||||
if (or.isNullOrEmpty()) throw EhException("Invalid form param or: $or")
|
||||
if (res.isNullOrEmpty()) throw EhException("Invalid res: $res")
|
||||
val url = EhUrl.getDownloadArchive(gid, token, or)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
val request = ehRequest(url, referer, EhUrl.origin) {
|
||||
formBody {
|
||||
if (isHAtH) {
|
||||
add("hathdl_xres", res)
|
||||
append("hathdl_xres", res)
|
||||
} else {
|
||||
add("dltype", res)
|
||||
append("dltype", res)
|
||||
if (res == "org") {
|
||||
add("dlcheck", "Download Original Archive")
|
||||
append("dlcheck", "Download Original Archive")
|
||||
} else {
|
||||
add("dlcheck", "Download Resample Archive")
|
||||
append("dlcheck", "Download Resample Archive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
var result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
if (!isHAtH) {
|
||||
if (result == null) {
|
||||
// Wait for the server to prepare archives
|
||||
delay(1000)
|
||||
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) {
|
||||
throw EhException("Archive unavailable")
|
||||
}
|
||||
result = request.fetchUsingAsText(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) throw EhException("Archive unavailable")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -342,11 +321,14 @@ object EhEngine {
|
||||
|
||||
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
|
||||
formBody {
|
||||
add("reset_imagelimit", "Reset Limit")
|
||||
append("reset_imagelimit", "Reset Limit")
|
||||
}
|
||||
}.executeAndParsingWith(HomeParser::parseResetLimits)
|
||||
}.fetchUsingAsText(HomeParser::parseResetLimits)
|
||||
|
||||
suspend fun modifyFavorites(url: String, gidArray: LongArray, dstCat: Int): FavoritesParser.Result {
|
||||
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
|
||||
val url = ehUrl(EhUrl.FAV_PATH) {
|
||||
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
|
||||
}.buildString()
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "delete"
|
||||
in 0..9 -> "fav$dstCat"
|
||||
@@ -354,19 +336,13 @@ object EhEngine {
|
||||
}
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
add("ddact", catStr)
|
||||
gidArray.forEach { add("modifygids[]", it.toString()) }
|
||||
append("ddact", catStr)
|
||||
gidArray.forEach { append("modifygids[]", it.toString()) }
|
||||
}
|
||||
}.executeAndParsingWith(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url) }
|
||||
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
|
||||
}
|
||||
|
||||
suspend fun getGalleryPageApi(
|
||||
gid: Long,
|
||||
index: Int,
|
||||
pToken: String,
|
||||
showKey: String?,
|
||||
previousPToken: String?,
|
||||
): GalleryPageApiParser.Result {
|
||||
suspend fun getGalleryPageApi(gid: Long, index: Int, pToken: String, showKey: String?, previousPToken: String?): GalleryPageParser.Result {
|
||||
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
|
||||
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
@@ -376,16 +352,10 @@ object EhEngine {
|
||||
put("imgkey", pToken)
|
||||
put("showkey", showKey)
|
||||
}
|
||||
}.executeAndParsingWith(GalleryPageApiParser::parse)
|
||||
}.fetchUsingAsText { GalleryPageParser.parse(filterNot { it == '\\' }) }
|
||||
}
|
||||
|
||||
suspend fun rateGallery(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
rating: Float,
|
||||
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
|
||||
suspend fun rateGallery(apiUid: Long, apiKey: String?, gid: Long, token: String?, rating: Float): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "rategallery")
|
||||
put("apiuid", apiUid)
|
||||
@@ -394,62 +364,56 @@ object EhEngine {
|
||||
put("token", token)
|
||||
put("rating", ceil((rating * 2).toDouble()).toInt())
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
}.fetchUsingAsText(String::parseAs)
|
||||
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) = galleryInfoList.chunked(MAX_REQUEST_SIZE).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) =
|
||||
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)
|
||||
}
|
||||
}
|
||||
put("namespace", 1)
|
||||
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
}.executeAndParsingWith { 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", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.executeAndParsingWith(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", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.executeAndParsingWith(VoteTagParser::parse)
|
||||
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", 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", 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 {
|
||||
@@ -462,47 +426,44 @@ object EhEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith(GalleryTokenApiParser::parse)
|
||||
}.fetchUsingAsText(GalleryTokenApiParser::parse)
|
||||
|
||||
/**
|
||||
* @param image Must be jpeg
|
||||
*/
|
||||
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = ehRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
|
||||
multipartBody {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
|
||||
if (uss) addFormDataPart("fs_similar", "on")
|
||||
if (osc) addFormDataPart("fs_covers", "on")
|
||||
addFormDataPart("f_sfile", "File Search")
|
||||
suspend fun imageSearch(jpeg: File, uss: Boolean, osc: Boolean): GalleryListResult {
|
||||
val location = noRedirectEhRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
|
||||
multipartBody {
|
||||
append("sfile", "a.jpg", ContentType.Image.JPEG, jpeg.length()) {
|
||||
RandomAccessFile(jpeg, "r").use {
|
||||
writeFully(it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.length()))
|
||||
}
|
||||
}
|
||||
if (uss) append("fs_similar", "on")
|
||||
if (osc) append("fs_covers", "on")
|
||||
append("f_sfile", "File Search")
|
||||
}
|
||||
}.execute { it.headers["Location"] ?: error("Failed to search image!!!") }
|
||||
return ehRequest(location).fetchUsingAsByteBuffer(GalleryListParser::parse).apply {
|
||||
galleryInfoList.fillInfo(EhUrl.imageSearchUrl)
|
||||
}
|
||||
}.executeAndParsingWith(GalleryListParser::parse).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
|
||||
|
||||
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
|
||||
// Filter title and uploader
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
|
||||
|
||||
var hasTags = false
|
||||
var hasPages = false
|
||||
var hasRated = false
|
||||
for (gi in list) {
|
||||
if (!gi.simpleTags.isNullOrEmpty()) {
|
||||
hasTags = true
|
||||
}
|
||||
if (gi.pages != 0) {
|
||||
hasPages = true
|
||||
}
|
||||
if (gi.rated) {
|
||||
hasRated = true
|
||||
}
|
||||
}
|
||||
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
|
||||
if (needApi) fillGalleryListByApi(list, url)
|
||||
|
||||
// Filter tag, thumbnail mode need filter uploader again
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
|
||||
}
|
||||
|
||||
suspend fun modifyFavoritesRange(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.forEach { (gid, token) -> modifyFavorites(gid, token, dstCat) }
|
||||
private suspend fun MutableList<BaseGalleryInfo>.fillInfo(url: String, filter: Boolean = false) = with(EhFilter) {
|
||||
if (filter) removeAllSuspend { filterTitle(it) || filterUploader(it) }
|
||||
val hasTags = any { !it.simpleTags.isNullOrEmpty() }
|
||||
val hasPages = any { it.pages != 0 }
|
||||
val hasRated = any { it.rated }
|
||||
val needApi = filter && needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
|
||||
if (needApi) fillGalleryListByApi(this@fillInfo, url)
|
||||
if (filter) removeAllSuspend { filterUploader(it) || filterTag(it) || filterTagNamespace(it) }
|
||||
}
|
||||
|
||||
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap { (gid, token) ->
|
||||
modifyFavorites(gid, token, dstCat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,110 +15,36 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.util.Log
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareRequest
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
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.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArrayBuilder
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import okhttp3.Call
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.executeAsync
|
||||
import okio.BufferedSource
|
||||
|
||||
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
|
||||
addHeader("User-Agent", Settings.userAgent)
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}.apply(builder).build()
|
||||
|
||||
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
|
||||
|
||||
inline fun Request.Builder.multipartBody(builder: MultipartBody.Builder.() -> Unit) = post(MultipartBody.Builder().apply(builder).build())
|
||||
|
||||
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
|
||||
|
||||
const val TAG_CALL = "CancellableCallReadingScope"
|
||||
|
||||
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
|
||||
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
|
||||
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
|
||||
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
|
||||
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
|
||||
// TODO: Remove logging after this function stable
|
||||
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
|
||||
val call = this
|
||||
Log.d(TAG_CALL, "Got response of call$call")
|
||||
coroutineScope {
|
||||
suspendCancellableCoroutine<R> { cont ->
|
||||
launch {
|
||||
val r = runCatching {
|
||||
Log.d(TAG_CALL, "Reading response of call$call")
|
||||
block(it)
|
||||
}
|
||||
if (!isCanceled() && cont.isActive) {
|
||||
r.exceptionOrNull()?.let {
|
||||
Log.e(TAG_CALL, "Reading response of call$call failed!")
|
||||
cont.resumeWithException(it)
|
||||
}
|
||||
r.getOrNull()?.let {
|
||||
Log.d(TAG_CALL, "Reading response of call$call succeed!")
|
||||
cont.resume(it)
|
||||
}
|
||||
} else if (isCanceled() && cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
|
||||
} else if (!isCanceled() && !cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
|
||||
} else {
|
||||
Log.d(TAG_CALL, "Reading response of call$call cancelled")
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
Log.d(TAG_CALL, "Cancelling call$call")
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
Log.d(TAG_CALL, "Reading response of call$call finished!")
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return okHttpClient.newCall(this).executeAsync().use(block)
|
||||
}
|
||||
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
|
||||
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
|
||||
|
||||
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
|
||||
|
||||
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
|
||||
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = execute { it.bodyAsText().parseAs<T>() }
|
||||
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
|
||||
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
@@ -127,3 +53,49 @@ val json = Json { ignoreUnknownKeys = true }
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_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(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
builder: HttpRequestBuilder.() -> Unit = {},
|
||||
) = ktorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
|
||||
suspend inline fun noRedirectEhRequest(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
builder: HttpRequestBuilder.() -> Unit = {},
|
||||
) = noRedirectKtorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
|
||||
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
val parameters = ParametersBuilder().apply(builder).build()
|
||||
setBody(FormDataContent(parameters))
|
||||
}
|
||||
|
||||
inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
val json = buildJsonObject(builder).toString()
|
||||
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
|
||||
}
|
||||
|
||||
fun HttpRequestBuilder.multipartBody(builder: FormBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
setBody(MultiPartFormDataContent(formData(builder)))
|
||||
}
|
||||
@@ -19,11 +19,16 @@ import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.copyToFile
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -35,9 +40,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.executeAsync
|
||||
import okio.BufferedSource
|
||||
import okio.HashingSink.Companion.sha1
|
||||
import okio.blackholeSink
|
||||
@@ -175,24 +177,18 @@ object EhTagDatabase : CoroutineScope {
|
||||
return sha1 != null && sha1 == getFileSha1(data)
|
||||
}
|
||||
|
||||
private suspend fun save(client: OkHttpClient, url: String, file: File): Boolean {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
val call = client.newCall(request)
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
runCatching {
|
||||
call.executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
client.prepareGet(url).execute {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
throw StatusCodeException(it.status.value)
|
||||
}
|
||||
response.body.use {
|
||||
it.copyToFile(file)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
it.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun update() {
|
||||
@@ -246,7 +242,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
// Save new sha1
|
||||
val tempSha1File = File(dir, "$sha1Name.tmp")
|
||||
check(save(baseOkHttpClient, sha1Url, tempSha1File))
|
||||
save(ktorClient, sha1Url, tempSha1File)
|
||||
val tempSha1 = getFileContent(tempSha1File)
|
||||
|
||||
// Check new sha1 and current sha1
|
||||
@@ -258,7 +254,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
// Save new data
|
||||
val tempDataFile = File(dir, "$dataName.tmp")
|
||||
check(save(baseOkHttpClient, dataUrl, tempDataFile))
|
||||
save(ktorClient, dataUrl, tempDataFile)
|
||||
|
||||
// Check new sha1 and new data
|
||||
if (!checkData(tempSha1, tempDataFile)) {
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import io.ktor.http.Parameters
|
||||
import io.ktor.http.ParametersBuilder
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.URLProtocol
|
||||
|
||||
object EhUrl {
|
||||
const val SITE_E = 0
|
||||
@@ -120,35 +123,41 @@ object EhUrl {
|
||||
return getGalleryDetailUrl(gid, token, 0, false)
|
||||
}
|
||||
|
||||
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl {
|
||||
addPathSegments("g/$gid/$token/")
|
||||
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl(listOf("g", gid.toString(), token.orEmpty())) {
|
||||
if (index != 0) addQueryParameter("p", "$index")
|
||||
if (allComment) addQueryParameter("hc", "1")
|
||||
}.toString()
|
||||
}.buildString()
|
||||
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
|
||||
addPathSegments("mpv/$gid/$token/")
|
||||
}.toString()
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", gid.toString(), token)).buildString()
|
||||
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
|
||||
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
}
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String) = host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
|
||||
fun getAddFavorites(gid: Long, token: String?): String {
|
||||
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
}
|
||||
fun getAddFavorites(gid: Long, token: String?) = host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
|
||||
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
}
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String) = host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
|
||||
fun getTagDefinitionUrl(tag: String): String {
|
||||
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
}
|
||||
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
}
|
||||
|
||||
inline fun httpsUrl(builder: HttpUrl.Builder.() -> Unit) = HttpUrl.Builder().apply(builder).scheme("https").build()
|
||||
inline fun ehUrl(builder: HttpUrl.Builder.() -> Unit) = httpsUrl { apply(builder).host(EhUrl.domain) }
|
||||
fun HttpUrl.Builder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
|
||||
if (!value.isNullOrBlank()) addQueryParameter(name, value)
|
||||
inline fun ehUrl(path: String?, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
inline fun ehUrl(path: List<String>, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = path,
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
fun ehUrl(path: List<String>, parameters: Parameters = Parameters.Empty) = URLBuilder(
|
||||
protocol = URLProtocol.HTTPS,
|
||||
host = EhUrl.domain,
|
||||
pathSegments = path,
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
|
||||
if (!value.isNullOrBlank()) append(name, value)
|
||||
}
|
||||
|
||||
fun ParametersBuilder.addQueryParameter(name: String, value: String) = append(name, value)
|
||||
@@ -16,12 +16,13 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.platform.LocalContext
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import arrow.core.memoize
|
||||
import com.google.android.material.color.utilities.Hct
|
||||
import com.google.android.material.color.utilities.MathUtils
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -102,17 +103,19 @@ object EhUtils {
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun harmonizeWithRole(context: Context, src: Int): Int {
|
||||
val primary = MaterialColors.getColor(context, R.attr.colorPrimaryContainer, "EhUtils")
|
||||
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
|
||||
val fromHct = Hct.fromInt(src)
|
||||
val toHct = Hct.fromInt(primary)
|
||||
val toHct = Hct.fromInt(primaryContainer)
|
||||
val differenceDegrees = MathUtils.differenceDegrees(fromHct.hue, toHct.hue)
|
||||
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
|
||||
val outputHue = MathUtils.sanitizeDegreesDouble(fromHct.hue + rotationDegrees * MathUtils.rotationDirection(fromHct.hue, toHct.hue))
|
||||
return Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
|
||||
}
|
||||
Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
|
||||
}.memoize()
|
||||
|
||||
private fun getCategoryColor(context: Context, category: Int): Int {
|
||||
@Stable
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun getCategoryColor(category: Int): Color {
|
||||
val primary = when (category) {
|
||||
DOUJINSHI -> BG_COLOR_DOUJINSHI
|
||||
MANGA -> BG_COLOR_MANGA
|
||||
@@ -126,16 +129,15 @@ object EhUtils {
|
||||
MISC -> BG_COLOR_MISC
|
||||
else -> BG_COLOR_UNKNOWN
|
||||
}.toInt()
|
||||
return if (Settings.harmonizeCategoryColor) {
|
||||
harmonizeWithRole(context, primary)
|
||||
val color = if (Settings.harmonizeCategoryColor) {
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
|
||||
harmonizeWithRole(primaryContainer, primary)
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getCategoryColor(category: Int) = Color(getCategoryColor(LocalContext.current, category))
|
||||
|
||||
val categoryTextColor = Color(0xffe6e0e9)
|
||||
|
||||
fun signOut() {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
@@ -25,7 +26,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
@Entity(tableName = "GALLERIES")
|
||||
data class BaseGalleryInfo(
|
||||
data class BaseGalleryInfo @Keep constructor(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "GID")
|
||||
override var gid: Long = 0,
|
||||
|
||||
@@ -17,41 +17,38 @@ package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class FavListUrlBuilder(
|
||||
var mPrev: String? = null,
|
||||
var mNext: String? = null,
|
||||
data class FavListUrlBuilder(
|
||||
val favCat: Int = FAV_CAT_ALL,
|
||||
val keyword: String? = null,
|
||||
var jumpTo: String? = null,
|
||||
var keyword: String? = null,
|
||||
var favCat: Int = FAV_CAT_ALL,
|
||||
var prev: String? = null,
|
||||
var next: String? = null,
|
||||
) : Parcelable {
|
||||
fun setIndex(index: String?, isNext: Boolean) {
|
||||
mNext = index.takeIf { isNext }
|
||||
mPrev = index.takeUnless { isNext }
|
||||
next = index.takeIf { isNext }
|
||||
prev = index.takeUnless { isNext }
|
||||
}
|
||||
|
||||
fun build() = ehUrl {
|
||||
addPathSegment(EhUrl.FAV_PATH)
|
||||
if (isValidFavCat(favCat)) {
|
||||
addQueryParameter("favcat", "$favCat")
|
||||
} else if (favCat == FAV_CAT_ALL) {
|
||||
addQueryParameter("favcat", "all")
|
||||
fun build() = ehUrl(EhUrl.FAV_PATH) {
|
||||
when {
|
||||
isValidFavCat(favCat) -> addQueryParameter("favcat", "$favCat")
|
||||
favCat == FAV_CAT_ALL -> addQueryParameter("favcat", "all")
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", keyword)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("prev", prev)
|
||||
addQueryParameterIfNotBlank("next", next)
|
||||
addQueryParameterIfNotBlank("seek", jumpTo)
|
||||
}.toString()
|
||||
}.buildString()
|
||||
|
||||
companion object {
|
||||
const val FAV_CAT_ALL = -1
|
||||
const val FAV_CAT_LOCAL = -2
|
||||
fun isValidFavCat(favCat: Int): Boolean {
|
||||
return favCat in 0..9
|
||||
}
|
||||
fun isValidFavCat(favCat: Int) = favCat in 0..9
|
||||
}
|
||||
}
|
||||
@@ -1,38 +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.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryApiInfo(
|
||||
var gid: Long = 0,
|
||||
var token: String? = null,
|
||||
var archiverKey: String? = null,
|
||||
var title: String? = null,
|
||||
var titleJpn: String? = null,
|
||||
var category: Int = 0,
|
||||
var thumb: String? = null,
|
||||
var uploader: String? = null,
|
||||
var posted: Long = 0,
|
||||
var filecount: Int = 0,
|
||||
var filesize: Long = 0,
|
||||
var expunged: Boolean = false,
|
||||
var rating: Float = 0f,
|
||||
var torrentcount: Int = 0,
|
||||
var tags: Array<String>?,
|
||||
) : Parcelable
|
||||
@@ -19,19 +19,18 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryComment(
|
||||
// 0 for uploader comment. can't vote
|
||||
var id: Long = 0,
|
||||
var score: Int = 0,
|
||||
var editable: Boolean = false,
|
||||
var voteUpAble: Boolean = false,
|
||||
var voteUpEd: Boolean = false,
|
||||
var voteDownAble: Boolean = false,
|
||||
var voteDownEd: Boolean = false,
|
||||
var uploader: Boolean = false,
|
||||
var voteState: String? = null,
|
||||
var time: Long = 0,
|
||||
var user: String? = null,
|
||||
var comment: String? = null,
|
||||
var lastEdited: Long = 0,
|
||||
data class GalleryComment(
|
||||
val id: Long,
|
||||
val score: Int,
|
||||
val editable: Boolean,
|
||||
val voteUpAble: Boolean,
|
||||
val voteUpEd: Boolean,
|
||||
val voteDownAble: Boolean,
|
||||
val voteDownEd: Boolean,
|
||||
val uploader: Boolean,
|
||||
val voteState: String?,
|
||||
val time: Long,
|
||||
val user: String?,
|
||||
val comment: String,
|
||||
val lastEdited: Long,
|
||||
) : Parcelable
|
||||
@@ -19,7 +19,7 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryCommentList(
|
||||
var comments: Array<GalleryComment>,
|
||||
data class GalleryCommentList(
|
||||
val comments: List<GalleryComment>,
|
||||
val hasMore: Boolean,
|
||||
) : Parcelable
|
||||
@@ -37,7 +37,7 @@ private val LANGUAGES = arrayOf(
|
||||
)
|
||||
|
||||
@Parcelize
|
||||
class GalleryDetail(
|
||||
data class GalleryDetail(
|
||||
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
|
||||
var apiUid: Long = -1L,
|
||||
var apiKey: String? = null,
|
||||
@@ -51,7 +51,7 @@ class GalleryDetail(
|
||||
var size: String? = null,
|
||||
var favoriteCount: Int = 0,
|
||||
var ratingCount: Int = 0,
|
||||
val tags: Array<GalleryTagGroup>,
|
||||
val tags: List<GalleryTagGroup>,
|
||||
var comments: GalleryCommentList,
|
||||
val previewPages: Int,
|
||||
val previewList: List<GalleryPreview>,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import java.util.regex.Pattern
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
interface GalleryInfo {
|
||||
var gid: Long
|
||||
@@ -92,7 +93,7 @@ interface GalleryInfo {
|
||||
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
|
||||
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
|
||||
)
|
||||
private val S_LANG_TAGS = arrayOf(
|
||||
val S_LANG_TAGS = arrayOf(
|
||||
"language:english",
|
||||
"language:chinese",
|
||||
"language:spanish",
|
||||
@@ -133,3 +134,15 @@ interface GalleryInfo {
|
||||
const val LOCAL_FAVORITED = -1
|
||||
}
|
||||
}
|
||||
|
||||
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
|
||||
return when (this) {
|
||||
is BaseGalleryInfo -> this
|
||||
is GalleryDetail -> galleryInfo
|
||||
else -> unreachable()
|
||||
}
|
||||
}
|
||||
|
||||
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
|
||||
return this as? GalleryDetail
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryTagGroup(
|
||||
data class GalleryTagGroup(
|
||||
val groupName: String,
|
||||
private val mTagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable, MutableList<String> by mTagList
|
||||
private val tagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable, MutableList<String> by tagList
|
||||
@@ -17,8 +17,10 @@ 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
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
@@ -35,6 +37,7 @@ data class ListUrlBuilder(
|
||||
var mode: Int = MODE_NORMAL,
|
||||
private var mPrev: String? = null,
|
||||
var mNext: String? = null,
|
||||
// Reset to null after initial loading
|
||||
var mJumpTo: String? = null,
|
||||
var category: Int = EhUtils.NONE,
|
||||
private var mKeyword: String? = null,
|
||||
@@ -48,23 +51,6 @@ data class ListUrlBuilder(
|
||||
var isOnlySearchCovers: Boolean = false,
|
||||
) : Parcelable {
|
||||
|
||||
fun reset() {
|
||||
mode = MODE_NORMAL
|
||||
mPrev = null
|
||||
mNext = null
|
||||
mJumpTo = null
|
||||
this.category = EhUtils.NONE
|
||||
mKeyword = null
|
||||
advanceSearch = -1
|
||||
minRating = -1
|
||||
pageFrom = -1
|
||||
pageTo = -1
|
||||
imagePath = null
|
||||
isUseSimilarityScan = false
|
||||
isOnlySearchCovers = false
|
||||
hash = null
|
||||
}
|
||||
|
||||
fun setIndex(index: String?, isNext: Boolean = true) {
|
||||
mNext = index?.takeIf { isNext }
|
||||
mPrev = index?.takeUnless { isNext }
|
||||
@@ -80,8 +66,7 @@ data class ListUrlBuilder(
|
||||
mKeyword = keyword
|
||||
}
|
||||
|
||||
fun set(q: QuickSearch) {
|
||||
reset()
|
||||
constructor(q: QuickSearch) : this() {
|
||||
mode = q.mode
|
||||
this.category = q.category
|
||||
mKeyword = q.keyword
|
||||
@@ -134,9 +119,8 @@ data class ListUrlBuilder(
|
||||
/**
|
||||
* @param query xxx=yyy&mmm=nnn
|
||||
*/
|
||||
fun setQuery(query: String?) {
|
||||
constructor(query: String?) : this() {
|
||||
// TODO page
|
||||
reset()
|
||||
if (query.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
@@ -271,31 +255,23 @@ data class ListUrlBuilder(
|
||||
|
||||
fun build(): String {
|
||||
return when (mode) {
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl {
|
||||
if (mode == MODE_SUBSCRIPTION) addPathSegment(EhUrl.WATCHED_PATH)
|
||||
if (category != EhUtils.NONE) {
|
||||
addEncodedQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
}
|
||||
val query = mKeyword?.let {
|
||||
val index = Settings.languageFilter.value
|
||||
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
|
||||
"$it $lang"
|
||||
} ?: it
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
addQueryParameterIfNotBlank("f_shash", hash)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
// Search key
|
||||
// the settings of ub:UrlBuilder may be overwritten by following Advance search
|
||||
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
|
||||
val keyword = kwd.trim { it <= ' ' }
|
||||
when (idx) {
|
||||
0 -> addQueryParameterIfNotBlank("f_search", keyword)
|
||||
|
||||
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
|
||||
val key = keyword.substring(0, this).trim { it <= ' ' }
|
||||
val value = keyword.substring(this + 1).trim { it <= ' ' }
|
||||
addQueryParameterIfNotBlank(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Advance search
|
||||
if (advanceSearch != -1) {
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
if (advanceSearch and AdvanceTable.SH != 0) {
|
||||
addQueryParameter("f_sh", "on")
|
||||
@@ -313,20 +289,20 @@ data class ListUrlBuilder(
|
||||
addQueryParameter("f_sft", "on")
|
||||
}
|
||||
// Set min star
|
||||
if (minRating != -1) {
|
||||
if (minRating > 0) {
|
||||
addQueryParameter("f_sr", "on")
|
||||
addQueryParameter("f_srdd", "$minRating")
|
||||
}
|
||||
// Pages
|
||||
if (pageFrom != -1 || pageTo != -1) {
|
||||
if (pageFrom > 0 || pageTo > 0) {
|
||||
addQueryParameter("f_sp", "on")
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spf",
|
||||
pageFrom.takeUnless { it == -1 }?.toString(),
|
||||
pageFrom.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
addQueryParameterIfNotBlank(
|
||||
"f_spt",
|
||||
pageTo.takeUnless { it == -1 }?.toString(),
|
||||
pageTo.takeIf { it > 0 }?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -374,9 +350,7 @@ data class ListUrlBuilder(
|
||||
buildString {
|
||||
append(EhUrl.HOST_E)
|
||||
append("toplist.php?tl=")
|
||||
mKeyword.orEmpty().let {
|
||||
append(encodeUTF8(it))
|
||||
}
|
||||
append(mKeyword!!)
|
||||
mJumpTo?.let {
|
||||
append("&p=").append(it)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.NotLoggedInException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
object FavoritesParser {
|
||||
suspend fun parse(body: String): Result {
|
||||
if (body.contains("This page requires you to log on.</p>")) {
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
suspend fun parse(body: ByteBuffer): Result {
|
||||
val catArray = arrayOfNulls<String>(10)
|
||||
val countArray = runCatching {
|
||||
parseFav(body, catArray).also { check(it.isNotEmpty()) }
|
||||
val countArray = runSuspendCatching {
|
||||
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
|
||||
}.getOrElse {
|
||||
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
|
||||
throw ParseException("Parse favorites error", it)
|
||||
}
|
||||
val result = GalleryListParser.parse(body)
|
||||
@@ -29,4 +29,4 @@ object FavoritesParser {
|
||||
}
|
||||
}
|
||||
|
||||
private external fun parseFav(body: String, favCat: Array<String?>): IntArray
|
||||
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
|
||||
@@ -33,7 +33,7 @@ object GalleryApiParser {
|
||||
titleJpn = item.titleJpn.unescapeXml()
|
||||
category = getCategory(item.category)
|
||||
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
|
||||
uploader = item.uploader.unescapeXml()
|
||||
uploader = item.uploader?.unescapeXml()
|
||||
posted = ParserUtils.formatDate(item.posted * 1000)
|
||||
rating = item.rating
|
||||
simpleTags = item.tags
|
||||
@@ -54,7 +54,8 @@ object GalleryApiParser {
|
||||
val titleJpn: String,
|
||||
val category: String,
|
||||
val thumb: String,
|
||||
val uploader: String,
|
||||
// Null in some old galleries
|
||||
val uploader: String?,
|
||||
val posted: Long,
|
||||
@SerialName("filecount")
|
||||
val pages: Int,
|
||||
|
||||
@@ -76,8 +76,7 @@ object GalleryDetailParser {
|
||||
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_TAG_GROUP_ARRAY = arrayOf<GalleryTagGroup>()
|
||||
private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(arrayOf(), false)
|
||||
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
|
||||
private val WEB_COMMENT_DATE_FORMAT = DateTimeFormatter
|
||||
.ofPattern("dd MMMM yyyy, HH:mm", Locale.US).withZone(ZoneOffset.UTC)
|
||||
private const val OFFENSIVE_STRING =
|
||||
@@ -308,7 +307,7 @@ object GalleryDetailParser {
|
||||
/**
|
||||
* Parse tag groups with html parser
|
||||
*/
|
||||
private fun parseTagGroups(document: Document): Array<GalleryTagGroup> {
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
|
||||
return try {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
val tagGroups = taglist.child(0).child(0).children()
|
||||
@@ -316,73 +315,69 @@ object GalleryDetailParser {
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
EMPTY_GALLERY_TAG_GROUP_ARRAY
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTagGroups(trs: Elements): Array<GalleryTagGroup> {
|
||||
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
|
||||
return try {
|
||||
trs.mapNotNull { parseTagGroup(it) }.toTypedArray()
|
||||
trs.mapNotNull { parseTagGroup(it) }
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
EMPTY_GALLERY_TAG_GROUP_ARRAY
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseComment(element: Element): GalleryComment? {
|
||||
return try {
|
||||
val comment = GalleryComment()
|
||||
// Id
|
||||
val a = element.previousElementSibling()
|
||||
val name = a!!.attr("name")
|
||||
comment.id = name trimAnd { substring(1).toInt().toLong() }
|
||||
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()) {
|
||||
comment.uploader = true
|
||||
uploader = true
|
||||
}
|
||||
for (e in c4.children()) {
|
||||
when (e.text()) {
|
||||
"Vote+" -> {
|
||||
comment.voteUpAble = true
|
||||
comment.voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
voteUpAble = true
|
||||
voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Vote-" -> {
|
||||
comment.voteDownAble = true
|
||||
comment.voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
voteDownAble = true
|
||||
voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Edit" -> comment.editable = true
|
||||
"Edit" -> editable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vote state
|
||||
val c7 = element.getElementsByClass("c7").first()
|
||||
if (null != c7) {
|
||||
comment.voteState = c7.text().trim()
|
||||
}
|
||||
val voteState = c7?.text()?.trim()
|
||||
// Score
|
||||
val c5 = element.getElementsByClass("c5").first()
|
||||
if (null != c5) {
|
||||
val es = c5.children()
|
||||
if (!es.isEmpty()) {
|
||||
comment.score = es[0].text().trim().toIntOrDefault(0)
|
||||
}
|
||||
}
|
||||
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
|
||||
// time
|
||||
val c3 = element.getElementsByClass("c3").first()
|
||||
val temp = c3!!.ownText()
|
||||
val time = if (temp.endsWith(':')) {
|
||||
// user
|
||||
comment.user = c3.child(0).text()
|
||||
temp.substring("Posted on ".length, temp.length - " by:".length)
|
||||
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)
|
||||
temp.substring("Posted on ".length) to null
|
||||
}
|
||||
comment.time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(time)).toEpochMilli()
|
||||
val time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(timeStr)).toEpochMilli()
|
||||
// comment
|
||||
val c6 = element.getElementsByClass("c6").first()
|
||||
// fix underline support
|
||||
@@ -391,24 +386,21 @@ object GalleryDetailParser {
|
||||
e.tagName("u")
|
||||
}
|
||||
}
|
||||
comment.comment = c6.html()
|
||||
val commentHtml = c6.html()
|
||||
// filter comment
|
||||
if (!comment.uploader) {
|
||||
if (!uploader) {
|
||||
val sEhFilter = EhFilter
|
||||
if (comment.score <= Settings.commentThreshold || sEhFilter.filterCommenter(comment.user!!) || sEhFilter.filterComment(comment.comment!!)) {
|
||||
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// last edited
|
||||
val c8 = element.getElementsByClass("c8").first()
|
||||
if (c8 != null) {
|
||||
val e = c8.children().first()
|
||||
if (e != null) {
|
||||
comment.lastEdited =
|
||||
Instant.from(WEB_COMMENT_DATE_FORMAT.parse(e.text())).toEpochMilli()
|
||||
}
|
||||
}
|
||||
comment
|
||||
val lastEdited = c8?.children()?.first()?.run { Instant.from(WEB_COMMENT_DATE_FORMAT.parse(text())).toEpochMilli() } ?: 0
|
||||
GalleryComment(
|
||||
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
|
||||
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
@@ -438,11 +430,11 @@ object GalleryDetailParser {
|
||||
},
|
||||
chd!!,
|
||||
)
|
||||
GalleryCommentList(list.toTypedArray(), hasMore)
|
||||
GalleryCommentList(list, hasMore)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
EMPTY_GALLERY_COMMENT_ARRAY
|
||||
EMPTY_GALLERY_COMMENT_LIST
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
@@ -7,34 +8,41 @@ 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.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
object GalleryListParser {
|
||||
suspend fun parse(body: String) = runCatching {
|
||||
parseGalleryInfoList(body).apply {
|
||||
galleryInfoList.onEach {
|
||||
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
|
||||
it.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
it.generateSLang()
|
||||
suspend fun parse(body: ByteBuffer) = runSuspendCatching {
|
||||
parseGalleryInfoList(body)
|
||||
}.onFailure {
|
||||
if (it is RuntimeException) {
|
||||
if (it.message?.startsWith("Your IP address") == true) {
|
||||
throw EhException(it.message)
|
||||
}
|
||||
if (it.message == "No watched tags!") {
|
||||
throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
}
|
||||
if (it.message == "No hits found!") {
|
||||
throw EhException(R.string.gallery_list_empty_hit)
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
if (body.contains("<p>You do not have any watched tags")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
}
|
||||
if (body.contains("No hits found</p>")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit)
|
||||
}
|
||||
throw ParseException("Can't parse gallery list", it)
|
||||
}.getOrThrow().apply {
|
||||
galleryInfoList.onEach {
|
||||
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
|
||||
it.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
it.generateSLang()
|
||||
}
|
||||
}
|
||||
|
||||
val emptyResult = GalleryListResult(null, null, arrayListOf())
|
||||
}
|
||||
|
||||
class GalleryListResult(
|
||||
class GalleryListResult @Keep constructor(
|
||||
val prev: String?,
|
||||
val next: String?,
|
||||
val galleryInfoList: ArrayList<BaseGalleryInfo>,
|
||||
)
|
||||
|
||||
private external fun parseGalleryInfoList(e: String): GalleryListResult
|
||||
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): GalleryListResult
|
||||
@@ -42,9 +42,7 @@ object GalleryListUrlParser {
|
||||
}
|
||||
val path = url.path ?: return null
|
||||
return if (PATH_NORMAL == path || path.isEmpty()) {
|
||||
val builder = ListUrlBuilder()
|
||||
builder.setQuery(url.query)
|
||||
builder
|
||||
ListUrlBuilder(url.query)
|
||||
} else if (path.startsWith(PATH_UPLOADER)) {
|
||||
parseUploader(path)
|
||||
} else if (path.startsWith(PATH_TAG)) {
|
||||
@@ -57,10 +55,7 @@ object GalleryListUrlParser {
|
||||
} catch (e: NumberFormatException) {
|
||||
return null
|
||||
}
|
||||
val builder = ListUrlBuilder()
|
||||
builder.setQuery(url.query)
|
||||
builder.category = category
|
||||
builder
|
||||
ListUrlBuilder(url.query).also { it.category = category }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object GalleryPageApiParser {
|
||||
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
|
||||
fun parse(body: String) = runCatching {
|
||||
val res = body.parseAs<JsonResult>()
|
||||
val imageUrl = PATTERN_IMAGE_URL.find(res.imageUrl)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(res.skipHathKeyAndOriginImageUrl)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(res.skipHathKeyAndOriginImageUrl)?.run {
|
||||
groupValues[1].unescapeXml() + "fullimg.php" + groupValues[2].unescapeXml()
|
||||
}
|
||||
check(!imageUrl.isNullOrEmpty())
|
||||
Result(imageUrl, skipHathKey, originImageUrl)
|
||||
}.getOrElse { e ->
|
||||
runCatching { body.parseAs<Error>() }.onSuccess { throw ParseException(it.error) }
|
||||
throw ParseException("Parse image url and skip hath key error", e)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class JsonResult(
|
||||
@SerialName("i3")
|
||||
val imageUrl: String,
|
||||
@SerialName("i6")
|
||||
val skipHathKeyAndOriginImageUrl: String,
|
||||
)
|
||||
|
||||
class Result(val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?)
|
||||
}
|
||||
@@ -17,46 +17,32 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object GalleryPageParser {
|
||||
private val PATTERN_IMAGE_URL = Pattern.compile("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL =
|
||||
Pattern.compile("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([\\d-]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+/fullimg/[^\"]+)\">")
|
||||
|
||||
// TODO Not sure about the size of show keys
|
||||
private val PATTERN_SHOW_KEY = Pattern.compile("var showkey=\"([0-9a-z]+)\";")
|
||||
private val PATTERN_SHOW_KEY = Regex("var showkey=\"([0-9a-z]+)\";")
|
||||
|
||||
fun parse(body: String): Result {
|
||||
var m = PATTERN_IMAGE_URL.matcher(body)
|
||||
val imageUrl = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
val imageUrl = PATTERN_IMAGE_URL.find(body)?.run {
|
||||
groupValues[1].unescapeXml()
|
||||
}
|
||||
m = PATTERN_SKIP_HATH_KEY.matcher(body)
|
||||
val skipHathKey = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(body)?.run {
|
||||
groupValues[1]
|
||||
}
|
||||
m = PATTERN_ORIGIN_IMAGE_URL.matcher(body)
|
||||
val originImageUrl = if (m.find()) {
|
||||
m.group(1)!!.unescapeXml() + "fullimg.php" + m.group(2)!!.unescapeXml()
|
||||
} else {
|
||||
null
|
||||
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(body)?.run {
|
||||
groupValues[1].unescapeXml()
|
||||
}
|
||||
m = PATTERN_SHOW_KEY.matcher(body)
|
||||
val showKey = if (m.find()) {
|
||||
m.group(1)
|
||||
} else {
|
||||
null
|
||||
val showKey = PATTERN_SHOW_KEY.find(body)?.run {
|
||||
groupValues[1]
|
||||
}
|
||||
return if (!imageUrl.isNullOrEmpty() && !showKey.isNullOrEmpty()) {
|
||||
return if (!imageUrl.isNullOrEmpty()) {
|
||||
Result(imageUrl, skipHathKey, originImageUrl, showKey)
|
||||
} else {
|
||||
throw ParseException("Parse image url and show error")
|
||||
throw ParseException("Parse image url error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +50,6 @@ object GalleryPageParser {
|
||||
val imageUrl: String,
|
||||
val skipHathKey: String?,
|
||||
val originImageUrl: String?,
|
||||
val showKey: String,
|
||||
val showKey: String?,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
@@ -35,6 +36,6 @@ object HomeParser {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
data class Limits @Keep constructor(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
|
||||
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@@ -9,7 +10,7 @@ object TorrentParser {
|
||||
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
|
||||
}
|
||||
|
||||
class Torrent(
|
||||
class Torrent @Keep constructor(
|
||||
val posted: String,
|
||||
val size: String,
|
||||
val seeds: Int,
|
||||
@@ -21,6 +22,6 @@ class Torrent(
|
||||
|
||||
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
|
||||
|
||||
typealias TorrentResult = ArrayList<Torrent>
|
||||
typealias TorrentResult = List<Torrent>
|
||||
|
||||
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil.disk.DiskCache
|
||||
import coil3.disk.DiskCache
|
||||
|
||||
inline fun DiskCache.edit(key: String, crossinline block: DiskCache.Editor.() -> Unit): Boolean {
|
||||
val editor = openEditor(key) ?: return false
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import coil.decode.DecodeResult
|
||||
import coil.decode.Decoder
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
@@ -27,7 +28,8 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
|
||||
}
|
||||
|
||||
private val stubResult = DecodeResult(ColorDrawable(Color.BLACK), false)
|
||||
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
|
||||
private val stubResult = DecodeResult(stubImage, false)
|
||||
private val stubFactory = Decoder { stubResult }
|
||||
|
||||
fun ImageRequest.Builder.justDownload() = apply {
|
||||
|
||||
+19
-25
@@ -2,45 +2,39 @@
|
||||
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import com.hippo.ehviewer.cronet.copyToChannel
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.noCache
|
||||
import java.io.RandomAccessFile
|
||||
import coil3.ComponentRegistry
|
||||
import coil3.ImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
|
||||
/**
|
||||
* A HttpUriFetcher impl use Cronet for jvmheapless IO
|
||||
* A HttpUriFetcher impl use Ktor (Will do jvmheapless IO when backing engine is Cronet)
|
||||
* cacheHeader is never respected | recorded since thumb is immutable
|
||||
*/
|
||||
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
|
||||
class KtorHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val diskCacheKey = options.diskCacheKey ?: data
|
||||
val diskCache = requireNotNull(imageLoader.diskCache)
|
||||
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
|
||||
val success = cronetRequest(data) {
|
||||
noCache()
|
||||
}.execute {
|
||||
val success = ehRequest(data).execute {
|
||||
diskCache.suspendEdit(diskCacheKey) {
|
||||
RandomAccessFile(data.toFile(), "rw").use {
|
||||
copyToChannel(it.channel)
|
||||
}
|
||||
it.bodyAsChannel().copyTo(data.toFile())
|
||||
}
|
||||
}
|
||||
check(success)
|
||||
// diskcache snapshot MUST exist here
|
||||
requireNotNull(diskCache.openSnapshot(diskCacheKey))
|
||||
}
|
||||
return SourceResult(
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
|
||||
mimeType = getMimeType(data),
|
||||
dataSource = DataSource.DISK,
|
||||
@@ -48,9 +42,9 @@ class CronetHttpUriFetcher(private val data: String, private val options: Option
|
||||
}
|
||||
}
|
||||
|
||||
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
|
||||
fun ComponentRegistry.Builder.installKtorHttpUriFetcher() = add { data: Uri, options, loader ->
|
||||
if (data.scheme != "http" && data.scheme != "https") return@add null
|
||||
CronetHttpUriFetcher(data.toString(), options, loader)
|
||||
KtorHttpUriFetcher(data.toString(), options, loader)
|
||||
}
|
||||
|
||||
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.key?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed(req) }
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
|
||||
}
|
||||
|
||||
try {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed(req) }.apply {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
|
||||
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
|
||||
val pool = DirectByteBufferPool(32)
|
||||
|
||||
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
|
||||
|
||||
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
|
||||
return pool.useInstance { buffer ->
|
||||
suspendCancellableCoroutine { cont ->
|
||||
consumer = {
|
||||
callback(it)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
onError = { readerCont.resumeWithException(it) }
|
||||
readerCont = cont
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
|
||||
val bytes = chan.write(it)
|
||||
listener(bytes)
|
||||
}
|
||||
// Limit thread to 1 since we are async & non-blocking
|
||||
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
|
||||
@@ -25,15 +25,17 @@ import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import splitties.init.appCtx
|
||||
|
||||
private val signedIn
|
||||
get() = EhCookieStore.hasSignedIn()
|
||||
|
||||
private const val CHANNEL_ID = "DailyCheckNotification"
|
||||
|
||||
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
|
||||
@@ -52,22 +54,20 @@ val schedHour
|
||||
val schedMinute
|
||||
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
|
||||
|
||||
private val whenToWork
|
||||
get() = LocalDateTime.now()
|
||||
.run { withHour(schedHour) }
|
||||
.run { withMinute(schedMinute) }
|
||||
|
||||
private val initialDelay
|
||||
get() = Duration.between(LocalDateTime.now(), whenToWork)
|
||||
.run { if (isNegative) plusDays(1) else this }
|
||||
|
||||
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
|
||||
val now = Clock.System.now()
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val whenToWork = LocalDateTime(
|
||||
now.toLocalDateTime(timeZone).date,
|
||||
LocalTime(schedHour, schedMinute),
|
||||
).toInstant(timeZone)
|
||||
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
return PeriodicWorkRequestBuilder<DailyCheckWork>(Duration.ofDays(1))
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(initialDelay.seconds, TimeUnit.SECONDS)
|
||||
.setInitialDelay(initialDelay.inWholeSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -85,13 +85,12 @@ fun updateDailyCheckWork(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val today
|
||||
get() = Instant.now().truncatedTo(ChronoUnit.DAYS).epochSecond
|
||||
|
||||
suspend fun checkDawn() = runCatching {
|
||||
if (signedIn && Settings.lastDawnDay != today) {
|
||||
val now = Clock.System.now()
|
||||
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
|
||||
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
|
||||
EhEngine.getNews(true)?.let {
|
||||
Settings.lastDawnDay = today
|
||||
Settings.lastDawnTime = now.epochSeconds
|
||||
showEventNotification(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "OK_HTTP_3_COOKIE")
|
||||
data class Cookie(
|
||||
@ColumnInfo(name = "NAME")
|
||||
var name: String,
|
||||
@ColumnInfo(name = "VALUE")
|
||||
var value: String,
|
||||
@ColumnInfo(name = "EXPIRES_AT")
|
||||
var expiresAt: Long,
|
||||
@ColumnInfo(name = "DOMAIN")
|
||||
var domain: String,
|
||||
@ColumnInfo(name = "PATH")
|
||||
var path: String,
|
||||
@ColumnInfo(name = "SECURE")
|
||||
var secure: Boolean,
|
||||
@ColumnInfo(name = "HTTP_ONLY")
|
||||
var httpOnly: Boolean,
|
||||
@ColumnInfo(name = "PERSISTENT")
|
||||
var persistent: Boolean,
|
||||
@ColumnInfo(name = "HOST_ONLY")
|
||||
var hostOnly: Boolean,
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "_id")
|
||||
var id: Long?,
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CookiesDao {
|
||||
@Query("SELECT * FROM OK_HTTP_3_COOKIE")
|
||||
suspend fun list(): List<Cookie>
|
||||
}
|
||||
@@ -181,21 +181,6 @@ abstract class EhDatabase : RoomDatabase() {
|
||||
abstract fun quickSearchDao(): QuickSearchDao
|
||||
}
|
||||
|
||||
// 1 -> 2 some nullability changes
|
||||
@Database(
|
||||
entities = [Cookie::class],
|
||||
version = 2,
|
||||
autoMigrations = [
|
||||
AutoMigration(
|
||||
from = 1,
|
||||
to = 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
abstract class CookiesDatabase : RoomDatabase() {
|
||||
abstract fun cookiesDao(): CookiesDao
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [Search::class],
|
||||
version = 2,
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.room.Relation
|
||||
import com.hippo.ehviewer.client.data.AbstractDownloadInfo
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(
|
||||
tableName = "DOWNLOADS",
|
||||
@@ -37,7 +37,7 @@ data class DownloadEntity(
|
||||
override var legacy: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "TIME")
|
||||
override var time: Long = Instant.now().toEpochMilli(),
|
||||
override var time: Long = Clock.System.now().toEpochMilliseconds(),
|
||||
|
||||
@ColumnInfo(name = "LABEL", index = true)
|
||||
override var label: String? = null,
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(tableName = "HISTORY", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
|
||||
class HistoryInfo(gid: Long, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
|
||||
class HistoryInfo(gid: Long, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
|
||||
@@ -3,7 +3,7 @@ package com.hippo.ehviewer.dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import java.time.Instant
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Entity(tableName = "LOCAL_FAVORITES", foreignKeys = [ForeignKey(BaseGalleryInfo::class, ["GID"], ["GID"])])
|
||||
class LocalFavoriteInfo(gid: Long = 0, time: Long = Instant.now().toEpochMilli()) : TimeInfo(gid, time)
|
||||
class LocalFavoriteInfo(gid: Long = 0, time: Long = Clock.System.now().toEpochMilliseconds()) : TimeInfo(gid, time)
|
||||
@@ -18,6 +18,15 @@ package com.hippo.ehviewer.download
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.SparseLongArray
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import com.google.android.material.math.MathUtils
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -32,51 +41,48 @@ import com.hippo.ehviewer.spider.readCompatFromUniFile
|
||||
import com.hippo.ehviewer.spider.write
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ConcurrentPool
|
||||
import com.hippo.ehviewer.util.LongList
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import com.hippo.ehviewer.util.assertNotMainThread
|
||||
import com.hippo.ehviewer.util.mapNotNull
|
||||
import com.hippo.ehviewer.util.runAssertingNotMainThread
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import java.util.LinkedList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.preferences.edit
|
||||
|
||||
object DownloadManager : OnSpiderListener {
|
||||
// All download info list
|
||||
val allInfoList: LinkedList<DownloadInfo> = runAssertingNotMainThread { LinkedList(EhDB.getAllDownloadInfo()) }
|
||||
val allInfoList = runAssertingNotMainThread { EhDB.getAllDownloadInfo() }.toMutableStateList()
|
||||
|
||||
// All download info map
|
||||
private val mAllInfoMap = allInfoList.associateBy { it.gid } as MutableMap<Long, DownloadInfo>
|
||||
|
||||
// label and info list map, without default label info list
|
||||
private val map: MutableMap<String?, LinkedList<DownloadInfo>>
|
||||
private val map: MutableMap<String?, SnapshotStateList<DownloadInfo>>
|
||||
|
||||
// All labels without default label
|
||||
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() } as MutableList<DownloadLabel>
|
||||
val labelList = runAssertingNotMainThread { EhDB.getAllDownloadLabelList() }.toMutableStateList()
|
||||
|
||||
// Store download info with default label
|
||||
val defaultInfoList: LinkedList<DownloadInfo>
|
||||
private val defaultInfoList: SnapshotStateList<DownloadInfo>
|
||||
|
||||
// Store download info wait to start
|
||||
private val mWaitList = LinkedList<DownloadInfo>()
|
||||
private val mWaitList = ArrayDeque<DownloadInfo>()
|
||||
private val mSpeedReminder = SpeedReminder()
|
||||
private var mDownloadInfoListener: DownloadInfoListener? = null
|
||||
private val mNotifyTaskPool = ConcurrentPool<NotifyTask?>(5)
|
||||
private var mDownloadListener: DownloadListener? = null
|
||||
private var mCurrentTask: DownloadInfo? = null
|
||||
private var mCurrentSpider: SpiderQueen? = null
|
||||
|
||||
private val mutableNotifyFlow = MutableSharedFlow<DownloadInfo>(extraBufferCapacity = 1)
|
||||
val notifyFlow = mutableNotifyFlow.asSharedFlow()
|
||||
|
||||
init {
|
||||
assertNotMainThread()
|
||||
map = runAssertingNotMainThread {
|
||||
@@ -87,16 +93,16 @@ object DownloadManager : OnSpiderListener {
|
||||
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
|
||||
}
|
||||
}
|
||||
it.key to LinkedList(it.value)
|
||||
it.key to it.value.toMutableStateList()
|
||||
}
|
||||
} as MutableMap<String?, LinkedList<DownloadInfo>>
|
||||
defaultInfoList = map.remove(null) ?: LinkedList()
|
||||
} as MutableMap<String?, SnapshotStateList<DownloadInfo>>
|
||||
defaultInfoList = map.remove(null) ?: mutableStateListOf()
|
||||
labelList.forEach { (label) ->
|
||||
map[label] ?: LinkedList<DownloadInfo>().also { map[label] = it }
|
||||
map[label] ?: mutableStateListOf<DownloadInfo>().also { map[label] = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoListForLabel(label: String?) = if (label == null) {
|
||||
fun getInfoListForLabel(label: String?) = if (label == null) {
|
||||
defaultInfoList
|
||||
} else {
|
||||
map[label]
|
||||
@@ -116,8 +122,6 @@ object DownloadManager : OnSpiderListener {
|
||||
|
||||
fun containDownloadInfo(gid: Long) = mAllInfoMap.containsKey(gid)
|
||||
|
||||
fun getLabelDownloadInfoList(label: String?) = map[label]
|
||||
|
||||
fun getDownloadInfo(gid: Long) = mAllInfoMap[gid]
|
||||
|
||||
fun getDownloadState(gid: Long): Int {
|
||||
@@ -158,7 +162,7 @@ object DownloadManager : OnSpiderListener {
|
||||
mDownloadListener!!.onStart(info)
|
||||
}
|
||||
// Notify state update
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +183,7 @@ object DownloadManager : OnSpiderListener {
|
||||
// Update in DB
|
||||
EhDB.putDownloadInfo(info)
|
||||
// Notify state update
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
// Make sure download is running
|
||||
ensureDownload()
|
||||
}
|
||||
@@ -196,10 +200,10 @@ object DownloadManager : OnSpiderListener {
|
||||
Log.e(TAG, "Can't find download info list with label: $label")
|
||||
return
|
||||
}
|
||||
list.addFirst(info)
|
||||
list.add(0, info)
|
||||
|
||||
// Add to all download list and map
|
||||
allInfoList.addFirst(info)
|
||||
allInfoList.add(0, info)
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
|
||||
// Add to wait list
|
||||
@@ -209,7 +213,7 @@ object DownloadManager : OnSpiderListener {
|
||||
EhDB.putDownloadInfo(info)
|
||||
|
||||
// Notify
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
// Make sure download is running
|
||||
ensureDownload()
|
||||
|
||||
@@ -218,90 +222,71 @@ object DownloadManager : OnSpiderListener {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startRangeDownload(gidList: LongList) {
|
||||
var update = false
|
||||
for (i in 0 until gidList.size) {
|
||||
val gid = gidList[i]
|
||||
val info = mAllInfoMap[gid]
|
||||
if (null == info) {
|
||||
Log.d(TAG, "Can't get download info with gid: $gid")
|
||||
continue
|
||||
suspend fun startRangeDownload(gidList: LongArray) {
|
||||
val updateList = gidList.mapNotNull { mAllInfoMap[it] }
|
||||
.filter {
|
||||
when (it.state) {
|
||||
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED, DownloadInfo.STATE_FINISH -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED || info.state == DownloadInfo.STATE_FINISH) {
|
||||
update = true
|
||||
// Set state DownloadInfo.STATE_WAIT
|
||||
info.state = DownloadInfo.STATE_WAIT
|
||||
// Add to wait list
|
||||
mWaitList.add(info)
|
||||
// Update in DB
|
||||
EhDB.putDownloadInfo(info)
|
||||
if (updateList.isNotEmpty()) {
|
||||
updateList.onEach {
|
||||
it.state = DownloadInfo.STATE_WAIT
|
||||
EhDB.putDownloadInfo(it)
|
||||
mutableNotifyFlow.tryEmit(it)
|
||||
}
|
||||
}
|
||||
if (update) {
|
||||
// Notify Listener
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
// Ensure download
|
||||
mWaitList.addAll(updateList)
|
||||
ensureDownload()
|
||||
}
|
||||
}
|
||||
|
||||
private val callbackFlowScope = CoroutineScope(Dispatchers.IO)
|
||||
@Stable
|
||||
@Composable
|
||||
fun collectDownloadState(gid: Long): State<Int> = remember {
|
||||
notifyFlow.transform { if (it.gid == gid) emit(getDownloadState(gid)) }
|
||||
}.collectAsState(getDownloadState(gid))
|
||||
|
||||
private val _stateFlow = callbackFlow {
|
||||
val listener = object : DownloadInfoListener {
|
||||
override fun onUpdate(info: DownloadInfo) {
|
||||
trySend(info.gid)
|
||||
}
|
||||
@Stable
|
||||
@Composable
|
||||
fun collectContainDownloadInfo(gid: Long): State<Boolean> = remember {
|
||||
notifyFlow.transform { if (it.gid == gid) emit(containDownloadInfo(gid)) }
|
||||
}.collectAsState(containDownloadInfo(gid))
|
||||
|
||||
override fun onUpdateAll() {
|
||||
trySend(0)
|
||||
}
|
||||
}
|
||||
mDownloadInfoListener = listener
|
||||
awaitClose {
|
||||
mDownloadInfoListener = null
|
||||
}
|
||||
}.shareIn(callbackFlowScope, SharingStarted.Eagerly)
|
||||
|
||||
fun stateFlow(gid: Long) = _stateFlow.filter { it == gid }
|
||||
|
||||
fun stateFlow() = _stateFlow
|
||||
@Stable
|
||||
@Composable
|
||||
inline fun <T> updatedDownloadInfo(info: DownloadInfo, crossinline transform: @DisallowComposableCalls DownloadInfo.() -> T): T = remember {
|
||||
notifyFlow.transform { if (it.gid == info.gid) emit(transform(it)) }
|
||||
}.collectAsState(transform(info)).value
|
||||
|
||||
suspend fun startAllDownload() {
|
||||
var update = false
|
||||
// Start all STATE_NONE and STATE_FAILED item
|
||||
for (info in allInfoList) {
|
||||
if (info.state == DownloadInfo.STATE_NONE || info.state == DownloadInfo.STATE_FAILED) {
|
||||
update = true
|
||||
// Set state DownloadInfo.STATE_WAIT
|
||||
info.state = DownloadInfo.STATE_WAIT
|
||||
// Add to wait list
|
||||
mWaitList.add(info)
|
||||
// Update in DB
|
||||
EhDB.putDownloadInfo(info)
|
||||
val updateList = allInfoList.filter {
|
||||
when (it.state) {
|
||||
DownloadInfo.STATE_NONE, DownloadInfo.STATE_FAILED -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (update) {
|
||||
// Notify Listener
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
// Ensure download
|
||||
if (updateList.isNotEmpty()) {
|
||||
updateList.onEach {
|
||||
it.state = DownloadInfo.STATE_WAIT
|
||||
EhDB.putDownloadInfo(it)
|
||||
mutableNotifyFlow.tryEmit(it)
|
||||
}
|
||||
mWaitList.addAll(updateList)
|
||||
ensureDownload()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDownload(downloadInfoList: List<DownloadInfo>, notify: Boolean = true) {
|
||||
suspend fun addDownload(downloadInfoList: List<DownloadInfo>) {
|
||||
downloadInfoList.filterNot { containDownloadInfo(it.gid) }.forEach { info ->
|
||||
// Ensure download state
|
||||
if (DownloadInfo.STATE_WAIT == info.state ||
|
||||
DownloadInfo.STATE_DOWNLOAD == info.state
|
||||
) {
|
||||
if (DownloadInfo.STATE_WAIT == info.state || DownloadInfo.STATE_DOWNLOAD == info.state) {
|
||||
info.state = DownloadInfo.STATE_NONE
|
||||
}
|
||||
info.position = allInfoList.size
|
||||
|
||||
// Add to label download list
|
||||
val list = getInfoListForLabel(info.label)
|
||||
?: addLabel(info.label).let { getInfoListForLabel(info.label)!! }
|
||||
val list = getInfoListForLabel(info.label) ?: addLabel(info.label).let { getInfoListForLabel(info.label)!! }
|
||||
list.add(info)
|
||||
// Sort
|
||||
list.sortByPositionDescending()
|
||||
@@ -316,17 +301,12 @@ object DownloadManager : OnSpiderListener {
|
||||
|
||||
// Sort all download list
|
||||
allInfoList.sortByPositionDescending()
|
||||
|
||||
// Notify
|
||||
if (notify) {
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDownloadLabel(downloadLabelList: List<DownloadLabel>) {
|
||||
val offset = downloadLabelList.size
|
||||
downloadLabelList.filterNot { containLabel(it.label) }.forEachIndexed { index, label ->
|
||||
map[label.label] = LinkedList()
|
||||
map[label.label] = mutableStateListOf()
|
||||
label.position = index + offset
|
||||
labelList.add(EhDB.addDownloadLabel(label))
|
||||
}
|
||||
@@ -339,26 +319,24 @@ object DownloadManager : OnSpiderListener {
|
||||
|
||||
// Add to default label download list
|
||||
val list = defaultInfoList
|
||||
list.addFirst(info)
|
||||
list.add(0, info)
|
||||
|
||||
// Add to all download list and map
|
||||
allInfoList.addFirst(info)
|
||||
allInfoList.add(0, info)
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
|
||||
// Save to
|
||||
EhDB.putDownloadInfo(info)
|
||||
|
||||
// Notify
|
||||
withUIContext {
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
}
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
|
||||
suspend fun stopDownload(gid: Long) {
|
||||
val info = stopDownloadInternal(gid)
|
||||
if (info != null) {
|
||||
// Update listener
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
// Ensure download
|
||||
ensureDownload()
|
||||
}
|
||||
@@ -368,17 +346,19 @@ object DownloadManager : OnSpiderListener {
|
||||
val info = stopCurrentDownloadInternal()
|
||||
if (info != null) {
|
||||
// Update listener
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
// Ensure download
|
||||
ensureDownload()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopRangeDownload(gidList: LongList) {
|
||||
suspend fun stopRangeDownload(gidList: LongArray) {
|
||||
stopRangeDownloadInternal(gidList)
|
||||
|
||||
// Update listener
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
gidList.mapNotNull { mAllInfoMap[it] }.forEach {
|
||||
mutableNotifyFlow.tryEmit(it)
|
||||
}
|
||||
|
||||
// Ensure download
|
||||
ensureDownload()
|
||||
@@ -390,14 +370,14 @@ object DownloadManager : OnSpiderListener {
|
||||
info.state = DownloadInfo.STATE_NONE
|
||||
// Update in DB
|
||||
EhDB.putDownloadInfo(info)
|
||||
|
||||
// Notify
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
mWaitList.clear()
|
||||
|
||||
// Stop current
|
||||
stopCurrentDownloadInternal()
|
||||
|
||||
// Notify mDownloadInfoListener
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
}
|
||||
|
||||
suspend fun deleteDownload(gid: Long, deleteFiles: Boolean = false) {
|
||||
@@ -418,7 +398,7 @@ object DownloadManager : OnSpiderListener {
|
||||
if (index >= 0) {
|
||||
list.remove(info)
|
||||
// Update listener
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +412,7 @@ object DownloadManager : OnSpiderListener {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteRangeDownload(gidList: LongList) {
|
||||
suspend fun deleteRangeDownload(gidList: LongArray) {
|
||||
stopRangeDownloadInternal(gidList)
|
||||
val list = gidList.mapNotNull { gid ->
|
||||
mAllInfoMap.remove(gid)?.also { getInfoListForLabel(it.label)?.remove(it) }
|
||||
@@ -441,7 +421,7 @@ object DownloadManager : OnSpiderListener {
|
||||
allInfoList.removeAll(list.toSet())
|
||||
|
||||
// Update listener
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
list.forEach { mutableNotifyFlow.tryEmit(it) }
|
||||
|
||||
// Ensure download
|
||||
ensureDownload()
|
||||
@@ -546,11 +526,11 @@ object DownloadManager : OnSpiderListener {
|
||||
|
||||
// Update in DB
|
||||
// Update mDownloadListener
|
||||
private suspend fun stopRangeDownloadInternal(gidList: LongList) {
|
||||
private suspend fun stopRangeDownloadInternal(gidList: LongArray) {
|
||||
// Two way
|
||||
if (gidList.size < mWaitList.size) {
|
||||
for (i in 0 until gidList.size) {
|
||||
stopDownloadInternal(gidList[i])
|
||||
for (element in gidList) {
|
||||
stopDownloadInternal(element)
|
||||
}
|
||||
} else {
|
||||
// Check current task
|
||||
@@ -578,12 +558,12 @@ object DownloadManager : OnSpiderListener {
|
||||
/**
|
||||
* @param label Not allow new label
|
||||
*/
|
||||
suspend fun changeLabel(list: List<DownloadInfo>, label: String?) {
|
||||
suspend fun changeLabel(list: Collection<DownloadInfo>, label: String?) {
|
||||
if (null != label && !containLabel(label)) {
|
||||
Log.e(TAG, "Not exits label: $label")
|
||||
return
|
||||
}
|
||||
val dstList: MutableList<DownloadInfo>? = getInfoListForLabel(label)
|
||||
val dstList = getInfoListForLabel(label)
|
||||
if (dstList == null) {
|
||||
Log.e(TAG, "Can't find label with label: $label")
|
||||
return
|
||||
@@ -592,7 +572,7 @@ object DownloadManager : OnSpiderListener {
|
||||
if (info.label == label) {
|
||||
continue
|
||||
}
|
||||
val srcList: MutableList<DownloadInfo>? = getInfoListForLabel(info.label)
|
||||
val srcList = getInfoListForLabel(info.label)
|
||||
if (srcList == null) {
|
||||
Log.e(TAG, "Can't find label with label: " + info.label)
|
||||
continue
|
||||
@@ -605,7 +585,6 @@ object DownloadManager : OnSpiderListener {
|
||||
// Save to DB
|
||||
EhDB.putDownloadInfo(info)
|
||||
}
|
||||
mDownloadInfoListener?.onUpdateAll()
|
||||
}
|
||||
|
||||
suspend fun addLabel(label: String?) {
|
||||
@@ -613,41 +592,30 @@ object DownloadManager : OnSpiderListener {
|
||||
return
|
||||
}
|
||||
labelList.add(EhDB.addDownloadLabel(DownloadLabel(label, labelList.size)))
|
||||
map[label] = LinkedList()
|
||||
map[label] = mutableStateListOf()
|
||||
}
|
||||
|
||||
suspend fun moveLabel(fromPosition: Int, toPosition: Int) {
|
||||
val item = labelList.removeAt(fromPosition)
|
||||
labelList.add(toPosition, item)
|
||||
val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition
|
||||
val list = labelList.slice(range)
|
||||
list.zip(range).forEach { it.first.position = it.second }
|
||||
EhDB.updateDownloadLabel(list)
|
||||
withIOContext {
|
||||
val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition
|
||||
val list = labelList.slice(range)
|
||||
list.zip(range).forEach { it.first.position = it.second }
|
||||
EhDB.updateDownloadLabel(list)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun renameLabel(from: String, to: String) {
|
||||
// Find in label list
|
||||
var found = false
|
||||
for (raw in labelList) {
|
||||
if (from == raw.label) {
|
||||
found = true
|
||||
raw.label = to
|
||||
// Update in DB
|
||||
EhDB.updateDownloadLabel(raw)
|
||||
break
|
||||
}
|
||||
val index = labelList.indexOfFirst { it.label == from }
|
||||
if (index != -1) {
|
||||
val exist = labelList.removeAt(index)
|
||||
val new = exist.copy(label = to)
|
||||
labelList.add(index, new)
|
||||
EhDB.updateDownloadLabel(new)
|
||||
val list = map.remove(from)?.onEach { it.label = to }
|
||||
if (list != null) map[to] = list
|
||||
}
|
||||
if (!found) {
|
||||
return
|
||||
}
|
||||
val list = map.remove(from) ?: return
|
||||
|
||||
// Update info label
|
||||
for (info in list) {
|
||||
info.label = to
|
||||
}
|
||||
// Put list back with new label
|
||||
map[to] = list
|
||||
}
|
||||
|
||||
suspend fun deleteLabel(label: String) {
|
||||
@@ -754,18 +722,6 @@ object DownloadManager : OnSpiderListener {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
interface DownloadInfoListener {
|
||||
/**
|
||||
* The special info is changed
|
||||
*/
|
||||
fun onUpdate(info: DownloadInfo)
|
||||
|
||||
/**
|
||||
* Maybe all data is changed, but size is the same
|
||||
*/
|
||||
fun onUpdateAll()
|
||||
}
|
||||
|
||||
interface DownloadListener {
|
||||
/**
|
||||
* Get 509 error
|
||||
@@ -870,7 +826,7 @@ object DownloadManager : OnSpiderListener {
|
||||
Log.e(TAG, "Current task is null, but it should not be")
|
||||
} else {
|
||||
info.total = mPages
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,7 +855,7 @@ object DownloadManager : OnSpiderListener {
|
||||
if (mDownloadListener != null) {
|
||||
mDownloadListener!!.onGetPage(info)
|
||||
}
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,7 +868,7 @@ object DownloadManager : OnSpiderListener {
|
||||
info.finished = mFinished
|
||||
info.downloaded = mDownloaded
|
||||
info.total = mTotal
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,7 +907,7 @@ object DownloadManager : OnSpiderListener {
|
||||
if (mDownloadListener != null) {
|
||||
mDownloadListener!!.onFinish(info)
|
||||
}
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
// Start next download
|
||||
ensureDownload()
|
||||
}
|
||||
@@ -1037,7 +993,7 @@ object DownloadManager : OnSpiderListener {
|
||||
if (mDownloadListener != null) {
|
||||
mDownloadListener!!.onDownload(info)
|
||||
}
|
||||
mDownloadInfoListener?.onUpdate(info)
|
||||
mutableNotifyFlow.tryEmit(info)
|
||||
}
|
||||
mBytesRead = 0
|
||||
if (!mStop) {
|
||||
|
||||
@@ -35,9 +35,8 @@ import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.scene.DownloadsScene
|
||||
import com.hippo.ehviewer.ui.screen.DownloadsFragment
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.LongList
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
@@ -105,7 +104,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
|
||||
ACTION_START_RANGE -> {
|
||||
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().startRangeDownload(gidList)
|
||||
}
|
||||
@@ -125,7 +124,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
ACTION_STOP_CURRENT -> deferredMgr.await().stopCurrentDownload()
|
||||
|
||||
ACTION_STOP_RANGE -> {
|
||||
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().stopRangeDownload(gidList)
|
||||
}
|
||||
@@ -141,7 +140,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
|
||||
ACTION_DELETE_RANGE -> {
|
||||
val gidList = intent.getParcelableExtraCompat<LongList>(KEY_GID_LIST)
|
||||
val gidList = intent.getLongArrayExtra(KEY_GID_LIST)
|
||||
if (gidList != null) {
|
||||
deferredMgr.await().deleteRangeDownload(gidList)
|
||||
}
|
||||
@@ -188,7 +187,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
clearIntent.action = ACTION_CLEAR
|
||||
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val bundle = Bundle()
|
||||
bundle.putString(DownloadsScene.KEY_ACTION, DownloadsScene.ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
@@ -244,7 +243,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
ensureDownloadingBuilder()
|
||||
val bundle = Bundle()
|
||||
bundle.putLong(DownloadsScene.KEY_GID, info.gid)
|
||||
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
|
||||
@@ -43,14 +43,16 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import moe.tarsin.coroutines.NamedMutex
|
||||
import moe.tarsin.coroutines.withLock
|
||||
|
||||
class ArchivePageLoader(context: Context, private val uri: Uri, passwdFlow: Flow<String>) : PageLoader2(), CoroutineScope {
|
||||
typealias PasswdInvalidator = (String) -> Boolean
|
||||
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
|
||||
|
||||
class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider: PasswdProvider) : PageLoader2(), CoroutineScope {
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
private lateinit var pfd: ParcelFileDescriptor
|
||||
private val hostJob = launch(start = CoroutineStart.LAZY) {
|
||||
@@ -65,12 +67,8 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdFlow: Flow
|
||||
it ?: return@forEach
|
||||
if (providePassword(it)) return@launch
|
||||
}
|
||||
passwdFlow.collect {
|
||||
if (providePassword(it)) {
|
||||
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(it) } ?: setOf(it)
|
||||
currentCoroutineContext().cancel()
|
||||
}
|
||||
}
|
||||
val toAdd = passwdProvider { providePassword(it) }
|
||||
archivePasswds = archivePasswds?.toMutableSet()?.apply { add(toAdd) } ?: setOf(toAdd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.hippo.ehviewer.icons.big
|
||||
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.bigIcon
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
|
||||
val EhIcons.Big.Filled.Download by unsafeLazy {
|
||||
bigIcon(name = "Download") {
|
||||
materialPath {
|
||||
moveTo(0.0F, 19.0F)
|
||||
verticalLineTo(24.0F)
|
||||
horizontalLineTo(24.0F)
|
||||
verticalLineTo(19.0F)
|
||||
horizontalLineTo(0.0F)
|
||||
close()
|
||||
moveTo(16.0F, 22.0F)
|
||||
horizontalLineTo(15.0F)
|
||||
verticalLineTo(21.0F)
|
||||
horizontalLineTo(16.0F)
|
||||
verticalLineTo(22.0F)
|
||||
close()
|
||||
moveTo(19.0F, 22.0F)
|
||||
horizontalLineTo(18.0F)
|
||||
verticalLineTo(21.0F)
|
||||
horizontalLineTo(19.0F)
|
||||
verticalLineTo(22.0F)
|
||||
close()
|
||||
moveTo(22.0F, 22.0F)
|
||||
horizontalLineTo(21.0F)
|
||||
verticalLineTo(21.0F)
|
||||
horizontalLineTo(22.0F)
|
||||
verticalLineTo(22.0F)
|
||||
moveTo(15.0F, 11.0F)
|
||||
lineTo(15.0F, 0.0F)
|
||||
lineTo(9.0F, 0.0F)
|
||||
lineTo(9.0F, 11.0F)
|
||||
lineTo(5.0F, 11.0F)
|
||||
lineTo(12.0F, 18.0F)
|
||||
lineTo(19.0F, 11.0F)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.hippo.ehviewer.icons.filled
|
||||
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
|
||||
val EhIcons.Filled.GoTo by unsafeLazy {
|
||||
materialIcon(name = "GoTo") {
|
||||
materialPath {
|
||||
moveTo(13.0F, 13.0F)
|
||||
lineTo(17.0F, 17.0F)
|
||||
lineTo(21.0F, 13.0F)
|
||||
horizontalLineTo(18.0F)
|
||||
verticalLineTo(10.0F)
|
||||
curveTo(18.0F, 6.1F, 14.9F, 3.0F, 11.0F, 3.0F)
|
||||
reflectiveCurveTo(4.0F, 6.1F, 4.0F, 10.0F)
|
||||
verticalLineTo(17.0F)
|
||||
horizontalLineTo(6.0F)
|
||||
verticalLineTo(10.0F)
|
||||
curveTo(6.0F, 7.2F, 8.2F, 5.0F, 11.0F, 5.0F)
|
||||
reflectiveCurveTo(16.0F, 7.2F, 16.0F, 10.0F)
|
||||
verticalLineTo(13.0F)
|
||||
horizontalLineTo(13.0F)
|
||||
moveTo(16.0F, 19.0F)
|
||||
horizontalLineTo(18.0F)
|
||||
verticalLineTo(21.0F)
|
||||
horizontalLineTo(16.0F)
|
||||
verticalLineTo(19.0F)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,24 +30,24 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import coil.decode.BitmapFactoryDecoder
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.decode.ImageSource
|
||||
import coil.request.Options
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil3.decode.BitmapFactoryDecoder
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.request.Options
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.jni.isGif
|
||||
import com.hippo.ehviewer.jni.mmap
|
||||
import com.hippo.ehviewer.jni.munmap
|
||||
import com.hippo.ehviewer.jni.rewriteGifSource
|
||||
import com.hippo.ehviewer.util.isAtLeastO
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.min
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import splitties.init.appCtx
|
||||
@@ -86,8 +86,8 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
)
|
||||
}
|
||||
}
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
|
||||
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
|
||||
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 / 2
|
||||
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 3 / 2
|
||||
|
||||
@delegate:RequiresApi(Build.VERSION_CODES.O)
|
||||
val isWideColorGamut by lazy { appCtx.resources.configuration.isScreenWideColorGamut }
|
||||
@@ -122,12 +122,15 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
} else {
|
||||
val options = Options(
|
||||
appCtx,
|
||||
colorSpace = if (isAtLeastO) colorSpace else null,
|
||||
size = Size(targetWidth, targetHeight),
|
||||
scale = Scale.FILL,
|
||||
allowInexactSize = true,
|
||||
)
|
||||
val drawable = ImageSource(src.source.openInputStream().source().buffer(), appCtx).use {
|
||||
BitmapFactoryDecoder(it, options).decode().drawable
|
||||
val drawable = ImageSource(
|
||||
src.source.openInputStream().source().buffer(),
|
||||
FileSystem.SYSTEM,
|
||||
).use {
|
||||
BitmapFactoryDecoder(it, options).decode().image.asDrawable(appCtx.resources)
|
||||
}
|
||||
src.close()
|
||||
Image(drawable, src)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.hippo.ehviewer.ktbuilder
|
||||
|
||||
import android.content.Context
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.request.ImageRequest
|
||||
import coil3.ImageLoader
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.coil.ehPreview
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.hippo.ehviewer.ktbuilder
|
||||
|
||||
import com.chuckerteam.chucker.api.ChuckerInterceptor
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import splitties.init.appCtx
|
||||
|
||||
inline fun httpClient(builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = OkHttpClient.Builder().apply(builder).build()
|
||||
inline fun httpClient(client: OkHttpClient, builder: OkHttpClient.Builder.() -> Unit): OkHttpClient = client.newBuilder().apply(builder).build()
|
||||
fun OkHttpClient.Builder.cache(directory: Path, maxSize: Long, fileSystem: FileSystem = FileSystem.SYSTEM) = cache(Cache(directory, maxSize, fileSystem))
|
||||
inline fun OkHttpClient.Builder.chunker(builder: ChuckerInterceptor.Builder.() -> Unit) = addInterceptor(ChuckerInterceptor.Builder(appCtx).apply(builder).build())
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.hippo.ehviewer.legacy
|
||||
|
||||
import arrow.fx.coroutines.release
|
||||
import arrow.fx.coroutines.resource
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.dao.Cookie
|
||||
import com.hippo.ehviewer.dao.CookiesDatabase
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
|
||||
private const val LEGACY_COOKIES_DB = "okhttp3-cookie.db"
|
||||
|
||||
private fun Cookie.toOkHttp3Cookie(): okhttp3.Cookie {
|
||||
return okhttp3.Cookie.Builder().apply {
|
||||
name(name)
|
||||
value(value)
|
||||
expiresAt(expiresAt)
|
||||
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
||||
path(path)
|
||||
if (secure) secure()
|
||||
if (httpOnly) httpOnly()
|
||||
}.build()
|
||||
}
|
||||
|
||||
suspend fun migrateCookies() {
|
||||
if (LEGACY_COOKIES_DB !in appCtx.databaseList()) return
|
||||
resource {
|
||||
roomDb<CookiesDatabase>(LEGACY_COOKIES_DB)
|
||||
} release {
|
||||
it.close()
|
||||
appCtx.deleteDatabase(LEGACY_COOKIES_DB)
|
||||
} use { database ->
|
||||
database.cookiesDao().list().forEach {
|
||||
EhCookieStore.addCookie(it.toOkHttp3Cookie())
|
||||
}
|
||||
EhCookieStore.flush()
|
||||
}
|
||||
}
|
||||
@@ -22,30 +22,24 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.executeNonCache
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.coil.suspendEdit
|
||||
import com.hippo.ehviewer.cronet.copyToChannel
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.getHeadersMap
|
||||
import com.hippo.ehviewer.cronet.noCache
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.gallery.SUPPORT_IMAGE_EXTENSIONS
|
||||
import com.hippo.ehviewer.image.Image.UniFileSource
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.nio.copyTo
|
||||
import java.util.Locale
|
||||
import kotlin.io.path.readText
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import okio.ForwardingSink
|
||||
import okio.sink
|
||||
|
||||
class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
private val mGid = mGalleryInfo.gid
|
||||
@@ -132,36 +126,25 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
url: String,
|
||||
referer: String?,
|
||||
notifyProgress: (Long, Long, Int) -> Unit,
|
||||
): Boolean {
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer) {
|
||||
noCache()
|
||||
}.execute { info ->
|
||||
val headers = info.getHeadersMap()
|
||||
val type = headers["Content-Type"]?.first()?.toMediaType()?.subtype ?: "jpg"
|
||||
val length = headers["Content-Length"]!!.first().toLong()
|
||||
saveResponseMeta(index, type, length) { file ->
|
||||
file.openOutputStream().use { os ->
|
||||
val chan = os.channel
|
||||
chan.truncate(0)
|
||||
copyToChannel(chan) {
|
||||
notifyProgress(length, info.receivedByteCount, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) = ehRequest(url, referer) {
|
||||
var prev = 0L
|
||||
onDownload { done, total ->
|
||||
notifyProgress(total!!, done, (done - prev).toInt())
|
||||
prev = done
|
||||
}
|
||||
}.execute {
|
||||
if (it.status.isSuccess()) {
|
||||
saveFromHttpResponse(index, it)
|
||||
} else {
|
||||
ehRequest(url, referer).executeNonCache {
|
||||
if (code >= 400) {
|
||||
false
|
||||
} else {
|
||||
saveFromHttpResponse(index, this, notifyProgress)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveResponseMeta(index: Int, ext: String, length: Long, fops: suspend (UniFile) -> Unit): Boolean {
|
||||
private suspend inline fun saveResponseMeta(
|
||||
index: Int,
|
||||
ext: String,
|
||||
crossinline fops: suspend (UniFile) -> Unit,
|
||||
): Boolean {
|
||||
findDownloadFileForIndex(index, ext)?.run {
|
||||
fops(this)
|
||||
return true
|
||||
@@ -178,19 +161,14 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun saveFromHttpResponse(index: Int, response: Response, notifyProgress: (Long, Long, Int) -> Unit): Boolean {
|
||||
val contentType = response.body.contentType()
|
||||
val extension = contentType?.subtype ?: "jpg"
|
||||
val length = response.body.contentLength()
|
||||
return saveResponseMeta(index, extension, length) { outFile ->
|
||||
object : ForwardingSink(outFile.openOutputStream().also { it.channel.truncate(0) }.sink()) {
|
||||
var totalBytesRead = 0L
|
||||
override fun write(source: Buffer, byteCount: Long) {
|
||||
super.write(source, byteCount)
|
||||
totalBytesRead += byteCount
|
||||
notifyProgress(length, totalBytesRead, byteCount.toInt())
|
||||
}
|
||||
}.use { sink -> response.body.source().use { source -> source.readAll(sink) } }
|
||||
private suspend fun saveFromHttpResponse(index: Int, response: HttpResponse): Boolean {
|
||||
val contentType = response.contentType()
|
||||
val extension = contentType?.contentSubtype ?: "jpg"
|
||||
return saveResponseMeta(index, extension) { outFile ->
|
||||
outFile.openOutputStream().use {
|
||||
val chan = it.channel.apply { truncate(0) }
|
||||
response.bodyAsChannel().copyTo(chan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import com.hippo.ehviewer.legacy.readLegacySpiderInfo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
import com.hippo.unifile.openOutputStream
|
||||
import java.io.File
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.init.appCtx
|
||||
|
||||
@Serializable
|
||||
@@ -50,7 +50,7 @@ fun SpiderInfo.saveToCache() {
|
||||
|
||||
private val spiderInfoCache by lazy {
|
||||
diskCache {
|
||||
directory(File(appCtx.cacheDir, "spider_info_v2_1"))
|
||||
directory(appCtx.cacheDir.toOkioPath() / "spider_info_v2_1")
|
||||
maxSizeBytes(20 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import com.hippo.ehviewer.client.EhUrl.referer
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.ehRequest
|
||||
import com.hippo.ehviewer.client.exception.QuotaExceededException
|
||||
import com.hippo.ehviewer.client.execute
|
||||
import com.hippo.ehviewer.client.fetchUsingAsText
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePages
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewList
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser.parsePreviewPages
|
||||
@@ -41,9 +41,10 @@ import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import java.time.Instant
|
||||
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.Dispatchers
|
||||
@@ -366,12 +367,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
ehRequest(
|
||||
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
|
||||
referer,
|
||||
).execute {
|
||||
val bodyStr = body.string()
|
||||
val pages = parsePages(bodyStr)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, pages)
|
||||
spiderInfo.token = galleryInfo.token
|
||||
readPreviews(bodyStr, 0, spiderInfo)
|
||||
).fetchUsingAsText {
|
||||
val pages = parsePages(this)
|
||||
val spiderInfo = SpiderInfo(galleryInfo.gid, pages, token = galleryInfo.token)
|
||||
readPreviews(this, 0, spiderInfo)
|
||||
spiderInfo
|
||||
}
|
||||
}.onFailure {
|
||||
@@ -386,9 +385,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
galleryInfo.token!!,
|
||||
)
|
||||
return try {
|
||||
ehRequest(url, referer).execute {
|
||||
val bodyStr = body.string()
|
||||
GalleryMultiPageViewerPTokenParser.parse(bodyStr).forEachIndexed { index, s ->
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
GalleryMultiPageViewerPTokenParser.parse(this).forEachIndexed { index, s ->
|
||||
spiderInfo.pTokenMap[index] = s
|
||||
}
|
||||
spiderInfo.pTokenMap[index]
|
||||
@@ -419,8 +417,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
false,
|
||||
)
|
||||
return try {
|
||||
ehRequest(url, referer).execute {
|
||||
readPreviews(body.string(), previewIndex, spiderInfo)
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
readPreviews(this, previewIndex, spiderInfo)
|
||||
spiderInfo.pTokenMap[index]
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -521,10 +519,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
private val pTokenLock = Mutex()
|
||||
private var showKey: String? = null
|
||||
private val showKeyLock = Mutex()
|
||||
private val mDownloadDelay = Settings.downloadDelay.toLong()
|
||||
private val mDownloadDelay = Settings.downloadDelay.milliseconds
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private var delayedTime = 0L
|
||||
private var delayedTime = TimeSource.Monotonic.markNow()
|
||||
private var isDownloadMode = false
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
@@ -561,8 +559,17 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
if (force) currentJob?.cancel()
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg)
|
||||
runCatching {
|
||||
mSemaphore.withPermit {
|
||||
doInJob(index, force, orgImg)
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is CancellationException) {
|
||||
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
|
||||
updatePageState(index, STATE_FAILED, "Cancelled")
|
||||
throw it
|
||||
}
|
||||
updatePageState(index, STATE_FAILED, ExceptionUtils.getReadableString(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,17 +617,23 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
|
||||
delayLock.withLock {
|
||||
val curTime = Instant.now().toEpochMilli()
|
||||
delayedTime = (delayedTime + mDownloadDelay).coerceAtLeast(curTime)
|
||||
delay(delayedTime - curTime)
|
||||
val new = delayedTime + mDownloadDelay
|
||||
val duration = new.elapsedNow()
|
||||
if (duration.isNegative()) {
|
||||
delayedTime = new
|
||||
delay(-duration)
|
||||
} else {
|
||||
delayedTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
}
|
||||
|
||||
var skipHathKey: String? = null
|
||||
var originImageUrl: String? = null
|
||||
var error: String? = null
|
||||
var forceHtml = false
|
||||
runCatching {
|
||||
repeat(2) {
|
||||
val original = Settings.downloadOriginImage || orgImg
|
||||
runSuspendCatching {
|
||||
repeat(2) { retries ->
|
||||
var imageUrl: String? = null
|
||||
var localShowKey: String?
|
||||
|
||||
@@ -630,11 +643,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
var pageUrl = EhUrl.getPageUrl(mSpiderInfo.gid, index, pToken)
|
||||
// Skipping H@H costs 50 points, only use it as last resort
|
||||
if (skipHathKey != null) {
|
||||
pageUrl += if ("?" in pageUrl) {
|
||||
"&nl=$skipHathKey"
|
||||
} else {
|
||||
"?nl=$skipHathKey"
|
||||
}
|
||||
pageUrl += "?nl=$skipHathKey"
|
||||
}
|
||||
EhEngine.getGalleryPage(pageUrl, mSpiderInfo.gid, mSpiderInfo.token)
|
||||
.let { result ->
|
||||
@@ -671,7 +680,10 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
val targetImageUrl: String?
|
||||
val referer: String?
|
||||
|
||||
if ((Settings.downloadOriginImage || orgImg) && !originImageUrl.isNullOrBlank()) {
|
||||
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
|
||||
@@ -713,14 +725,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}.onFailure {
|
||||
when (it) {
|
||||
is CancellationException -> {
|
||||
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
|
||||
error = "Cancelled"
|
||||
updatePageState(index, STATE_FAILED, error)
|
||||
throw it
|
||||
}
|
||||
|
||||
is QuotaExceededException -> notifyGet509(index)
|
||||
// TODO: Check IP ban
|
||||
}
|
||||
error = ExceptionUtils.getReadableString(it)
|
||||
}
|
||||
@@ -750,18 +756,22 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
|
||||
private suspend fun doInJob(index: Int) {
|
||||
mFetcherJobMap[index]?.takeIf { it.isActive }?.join()
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = mSemaphore.withPermit { Image.decode(src) }
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image?.recycle()
|
||||
throw it
|
||||
}
|
||||
if (image == null) {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
} else {
|
||||
val src = mSpiderDen.getImageSource(index) ?: return
|
||||
val image = mSemaphore.withPermit { Image.decode(src) }
|
||||
checkNotNull(image)
|
||||
runCatching {
|
||||
currentCoroutineContext().ensureActive()
|
||||
}.onFailure {
|
||||
image.recycle()
|
||||
throw it
|
||||
}
|
||||
notifyGetImageSuccess(index, image)
|
||||
}.onFailure {
|
||||
notifyGetImageFailure(index, DECODE_ERROR)
|
||||
if (it is CancellationException) {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -771,14 +781,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
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_ARRAY = arrayOf(
|
||||
"https://ehgt.org/g/509.gif",
|
||||
"https://ehgt.org/g/509s.gif",
|
||||
"https://exhentai.org/img/509.gif",
|
||||
"https://exhentai.org/img/509s.gif",
|
||||
)
|
||||
private val URL_509_PATTERN = Regex("\\.org/.+/509s?\\.gif")
|
||||
private const val WORKER_DEBUG_TAG = "SpiderQueenWorker"
|
||||
|
||||
private fun check509(url: String) {
|
||||
if (url in URL_509_ARRAY) throw QuotaExceededException()
|
||||
if (URL_509_PATTERN in url) throw QuotaExceededException()
|
||||
}
|
||||
@@ -18,10 +18,10 @@ package com.hippo.ehviewer.ui
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
@@ -30,13 +30,15 @@ import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.HeartBroken
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SelectableDates
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
@@ -52,137 +54,32 @@ import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.DownloadService
|
||||
import com.hippo.ehviewer.download.downloadDir
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.ui.legacy.ListCheckBoxDialogBuilder
|
||||
import com.hippo.ehviewer.ui.scene.BaseScene
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.LongList
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.hippo.ehviewer.util.requestPermission
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.lang.launchNow
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.todayIn
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.init.appCtx
|
||||
|
||||
object CommonOperations {
|
||||
fun startDownload(activity: MainActivity?, galleryInfo: BaseGalleryInfo, forceDefault: Boolean) {
|
||||
startDownload(activity!!, listOf(galleryInfo), forceDefault)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun startDownload(
|
||||
activity: MainActivity,
|
||||
galleryInfos: List<BaseGalleryInfo>,
|
||||
forceDefault: Boolean,
|
||||
) {
|
||||
launchNow {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity.requestPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
doStartDownload(activity, galleryInfos, forceDefault)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doStartDownload(
|
||||
activity: MainActivity,
|
||||
galleryInfos: List<BaseGalleryInfo>,
|
||||
forceDefault: Boolean,
|
||||
) {
|
||||
val toStart = LongList()
|
||||
val toAdd: MutableList<BaseGalleryInfo> = ArrayList()
|
||||
for (gi in galleryInfos) {
|
||||
if (DownloadManager.containDownloadInfo(gi.gid)) {
|
||||
toStart.add(gi.gid)
|
||||
} else {
|
||||
toAdd.add(gi)
|
||||
}
|
||||
}
|
||||
if (!toStart.isEmpty()) {
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START_RANGE
|
||||
intent.putExtra(DownloadService.KEY_GID_LIST, toStart)
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
if (toAdd.isEmpty()) {
|
||||
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
|
||||
return
|
||||
}
|
||||
var justStart = forceDefault
|
||||
var label: String? = null
|
||||
// Get default download label
|
||||
if (!justStart && Settings.hasDefaultDownloadLabel) {
|
||||
label = Settings.defaultDownloadLabel
|
||||
justStart = label == null || DownloadManager.containLabel(label)
|
||||
}
|
||||
// If there is no other label, just use null label
|
||||
if (!justStart && DownloadManager.labelList.isEmpty()) {
|
||||
justStart = true
|
||||
label = null
|
||||
}
|
||||
if (justStart) {
|
||||
// Got default label
|
||||
for (gi in toAdd) {
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START
|
||||
intent.putExtra(DownloadService.KEY_LABEL, label)
|
||||
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
// Notify
|
||||
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
|
||||
} else {
|
||||
// Let use chose label
|
||||
val list = DownloadManager.labelList
|
||||
val items = mutableListOf<String>()
|
||||
items.add(activity.getString(R.string.default_download_label_name))
|
||||
items.addAll(list.map { it.label })
|
||||
ListCheckBoxDialogBuilder(
|
||||
activity,
|
||||
items,
|
||||
{ builder: ListCheckBoxDialogBuilder?, _: AlertDialog?, position: Int ->
|
||||
var label1: String?
|
||||
if (position == 0) {
|
||||
label1 = null
|
||||
} else {
|
||||
label1 = items[position]
|
||||
if (!DownloadManager.containLabel(label1)) {
|
||||
label1 = null
|
||||
}
|
||||
}
|
||||
// Start download
|
||||
for (gi in toAdd) {
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START
|
||||
intent.putExtra(DownloadService.KEY_LABEL, label1)
|
||||
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
}
|
||||
// Save settings
|
||||
if (builder?.isChecked == true) {
|
||||
Settings.hasDefaultDownloadLabel = true
|
||||
Settings.defaultDownloadLabel = label1
|
||||
} else {
|
||||
Settings.hasDefaultDownloadLabel = false
|
||||
}
|
||||
// Notify
|
||||
activity.showTip(R.string.added_to_download_list, BaseScene.LENGTH_SHORT)
|
||||
},
|
||||
activity.getString(R.string.remember_download_label),
|
||||
false,
|
||||
)
|
||||
.setTitle(R.string.download)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeNoMediaFile(downloadDir: UniFile) {
|
||||
val noMedia = downloadDir.subFile(".nomedia") ?: return
|
||||
noMedia.delete()
|
||||
@@ -207,6 +104,86 @@ suspend fun keepNoMediaFileStatus() {
|
||||
fun getFavoriteIcon(favorited: Boolean) =
|
||||
if (favorited) Icons.Default.Favorite else Icons.Default.FavoriteBorder
|
||||
|
||||
suspend fun DialogState.startDownload(
|
||||
context: Context,
|
||||
forceDefault: Boolean,
|
||||
vararg galleryInfos: BaseGalleryInfo,
|
||||
) = with(context) {
|
||||
if (isAtLeastT) {
|
||||
requestPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val (toStart, toAdd) = galleryInfos.partition { DownloadManager.containDownloadInfo(it.gid) }
|
||||
if (toStart.isNotEmpty()) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START_RANGE
|
||||
val list = toStart.mapToLongArray(GalleryInfo::gid)
|
||||
intent.putExtra(DownloadService.KEY_GID_LIST, list)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
if (toAdd.isEmpty()) {
|
||||
return with(findActivity<MainActivity>()) {
|
||||
showTip(R.string.added_to_download_list)
|
||||
}
|
||||
}
|
||||
var justStart = forceDefault
|
||||
var label: String? = null
|
||||
// Get default download label
|
||||
if (!justStart && Settings.hasDefaultDownloadLabel) {
|
||||
label = Settings.defaultDownloadLabel
|
||||
justStart = label == null || DownloadManager.containLabel(label)
|
||||
}
|
||||
// If there is no other label, just use null label
|
||||
if (!justStart && DownloadManager.labelList.isEmpty()) {
|
||||
justStart = true
|
||||
label = null
|
||||
}
|
||||
if (justStart) {
|
||||
// Got default label
|
||||
for (gi in toAdd) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START
|
||||
intent.putExtra(DownloadService.KEY_LABEL, label)
|
||||
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
// Notify
|
||||
with(findActivity<MainActivity>()) {
|
||||
showTip(R.string.added_to_download_list)
|
||||
}
|
||||
} else {
|
||||
// Let use chose label
|
||||
val list = DownloadManager.labelList
|
||||
val items = arrayOf(
|
||||
getString(R.string.default_download_label_name),
|
||||
*list.map { it.label }.toTypedArray(),
|
||||
)
|
||||
val (selected, checked) = showSelectItemWithCheckBox(
|
||||
*items,
|
||||
title = R.string.download,
|
||||
checkBoxText = R.string.remember_download_label,
|
||||
)
|
||||
val label1 = if (selected == 0) null else items[selected].takeIf { DownloadManager.containLabel(it) }
|
||||
// Start download
|
||||
for (gi in toAdd) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START
|
||||
intent.putExtra(DownloadService.KEY_LABEL, label1)
|
||||
intent.putExtra(DownloadService.KEY_GALLERY_INFO, gi)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
// Save settings
|
||||
if (checked) {
|
||||
Settings.hasDefaultDownloadLabel = true
|
||||
Settings.defaultDownloadLabel = label1
|
||||
} else {
|
||||
Settings.hasDefaultDownloadLabel = false
|
||||
}
|
||||
with(context.findActivity<MainActivity>()) {
|
||||
showTip(R.string.added_to_download_list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
|
||||
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
|
||||
if (EhCookieStore.hasSignedIn()) {
|
||||
@@ -285,7 +262,10 @@ private suspend fun doModifyFavorites(
|
||||
return add
|
||||
}
|
||||
|
||||
suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorites(galleryInfo)
|
||||
suspend fun removeFromFavorites(galleryInfo: BaseGalleryInfo) = doModifyFavorites(
|
||||
galleryInfo = galleryInfo,
|
||||
localFavorited = EhDB.containLocalFavorites(galleryInfo.gid),
|
||||
)
|
||||
|
||||
fun Context.navToReader(info: BaseGalleryInfo, page: Int = -1) {
|
||||
val intent = Intent(this, ReaderActivity::class.java)
|
||||
@@ -321,23 +301,23 @@ suspend fun DialogState.doGalleryInfoAction(info: BaseGalleryInfo, context: Cont
|
||||
if (downloaded) {
|
||||
confirmRemoveDownload(info)
|
||||
} else {
|
||||
CommonOperations.startDownload(this@with, info, false)
|
||||
startDownload(context, false, info)
|
||||
}
|
||||
}
|
||||
|
||||
2 -> if (favourite) {
|
||||
runSuspendCatching {
|
||||
removeFromFavorites(info)
|
||||
showTip(R.string.remove_from_favorite_success, BaseScene.LENGTH_SHORT)
|
||||
showTip(R.string.remove_from_favorite_success)
|
||||
}.onFailure {
|
||||
showTip(R.string.remove_from_favorite_failure, BaseScene.LENGTH_LONG)
|
||||
showTip(R.string.remove_from_favorite_failure)
|
||||
}
|
||||
} else {
|
||||
runSuspendCatching {
|
||||
modifyFavorites(info)
|
||||
showTip(R.string.add_to_favorite_success, BaseScene.LENGTH_SHORT)
|
||||
showTip(R.string.add_to_favorite_success)
|
||||
}.onFailure {
|
||||
showTip(R.string.add_to_favorite_failure, BaseScene.LENGTH_LONG)
|
||||
showTip(R.string.add_to_favorite_failure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,17 +332,22 @@ private const val MAX_FAVNOTE_CHAR = 200
|
||||
suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo, onDismiss: () -> Unit = {}) {
|
||||
var checked by mutableStateOf(Settings.removeImageFiles)
|
||||
awaitPermissionOrCancel(
|
||||
confirmText = android.R.string.ok,
|
||||
dismissText = android.R.string.cancel,
|
||||
title = R.string.download_remove_dialog_title,
|
||||
onDismiss = onDismiss,
|
||||
text = {
|
||||
Column {
|
||||
Text(text = stringResource(id = R.string.download_remove_dialog_message, info.title.orEmpty()))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = checked, onCheckedChange = { checked = it })
|
||||
Text(text = stringResource(id = R.string.download_remove_dialog_check_text))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.download_remove_dialog_message, info.title.orEmpty()),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LabeledCheckbox(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it },
|
||||
label = stringResource(id = R.string.download_remove_dialog_check_text),
|
||||
indication = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -372,6 +357,43 @@ suspend fun DialogState.confirmRemoveDownload(info: GalleryInfo, onDismiss: () -
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DialogState.confirmRemoveDownloadRange(list: Collection<DownloadInfo>) {
|
||||
var checked by mutableStateOf(Settings.removeImageFiles)
|
||||
awaitPermissionOrCancel(
|
||||
title = R.string.download_remove_dialog_title,
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.download_remove_dialog_message_2, list.size),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LabeledCheckbox(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it },
|
||||
label = stringResource(id = R.string.download_remove_dialog_check_text),
|
||||
indication = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Settings.removeImageFiles = checked
|
||||
withIOContext {
|
||||
// Delete
|
||||
DownloadManager.deleteRangeDownload(list.mapToLongArray(DownloadInfo::gid))
|
||||
// Delete image files
|
||||
if (checked) {
|
||||
list.forEach { info ->
|
||||
// Delete file
|
||||
info.downloadDir?.delete()
|
||||
// Remove download path
|
||||
EhDB.removeDownloadDirname(info.gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
|
||||
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
|
||||
val labels = DownloadManager.labelList.map { it.label }.toTypedArray()
|
||||
@@ -380,3 +402,33 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
|
||||
val label = if (selected == 0) null else labels[selected - 1]
|
||||
withUIContext { DownloadManager.changeLabel(listOf(downloadInfo), label) }
|
||||
}
|
||||
|
||||
suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>): String? {
|
||||
val defaultLabel = appCtx.getString(R.string.default_download_label_name)
|
||||
val labels = DownloadManager.labelList.map { it.label }.toTypedArray()
|
||||
val selected = showSelectItem(defaultLabel, *labels, title = R.string.download_move_dialog_title)
|
||||
val label = if (selected == 0) null else labels[selected - 1]
|
||||
withUIContext { DownloadManager.changeLabel(list, label) }
|
||||
return label
|
||||
}
|
||||
|
||||
suspend fun DialogState.showDatePicker(): String? {
|
||||
val initial = LocalDate(2007, 3, 21)
|
||||
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
|
||||
val initialMillis = initial.toEpochMillis()
|
||||
val yesterdayMillis = yesterday.toEpochMillis()
|
||||
val dateRange = initialMillis..yesterdayMillis
|
||||
val dateMillis = showDatePicker(
|
||||
title = R.string.go_to,
|
||||
yearRange = initial.year..yesterday.year,
|
||||
selectableDates = object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
return utcTimeMillis in dateRange
|
||||
}
|
||||
},
|
||||
)
|
||||
val date = dateMillis?.let {
|
||||
kotlinx.datetime.Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date.toString()
|
||||
}
|
||||
return date
|
||||
}
|
||||
@@ -1,126 +1,51 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.ui.login.CookieSignInScene
|
||||
import com.hippo.ehviewer.ui.login.SelectSiteScreen
|
||||
import com.hippo.ehviewer.ui.login.SignInScreen
|
||||
import com.hippo.ehviewer.ui.login.WebViewSignInScreen
|
||||
import com.hippo.ehviewer.ui.settings.AboutScreen
|
||||
import com.hippo.ehviewer.ui.settings.AdvancedScreen
|
||||
import com.hippo.ehviewer.ui.settings.BaseScreen
|
||||
import com.hippo.ehviewer.ui.settings.DownloadScreen
|
||||
import com.hippo.ehviewer.ui.settings.EhScreen
|
||||
import com.hippo.ehviewer.ui.settings.FilterScreen
|
||||
import com.hippo.ehviewer.ui.settings.LicenseScreen
|
||||
import com.hippo.ehviewer.ui.settings.MyTagsScreen
|
||||
import com.hippo.ehviewer.ui.settings.PrivacyScreen
|
||||
import com.hippo.ehviewer.ui.settings.UConfigScreen
|
||||
import com.hippo.ehviewer.ui.destinations.BaseScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.rememberNavHostEngine
|
||||
|
||||
class ConfigureActivity : EhActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setMD3Content {
|
||||
val navController = rememberNavController()
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
CompositionLocalProvider(LocalNavController provides navController) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (Settings.needSignIn) {
|
||||
if (EhCookieStore.hasSignedIn()) SELECT_SITE_ROUTE_NAME else SIGN_IN_ROUTE_NAME
|
||||
} else {
|
||||
BASE_SETTINGS_SCREEN
|
||||
},
|
||||
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
modifier = Modifier.imePadding(),
|
||||
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up, tween(200)) },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down, tween(200)) },
|
||||
) {
|
||||
composable(SIGN_IN_ROUTE_NAME) {
|
||||
SignInScreen(windowSizeClass)
|
||||
}
|
||||
composable(WEBVIEW_SIGN_IN_ROUTE_NAME) {
|
||||
WebViewSignInScreen()
|
||||
}
|
||||
composable(COOKIE_SIGN_IN_ROUTE_NAME) {
|
||||
CookieSignInScene(windowSizeClass)
|
||||
}
|
||||
composable(SELECT_SITE_ROUTE_NAME) {
|
||||
SelectSiteScreen()
|
||||
}
|
||||
composable(BASE_SETTINGS_SCREEN) {
|
||||
BaseScreen()
|
||||
}
|
||||
composable(ABOUT_SETTINGS_SCREEN) {
|
||||
AboutScreen()
|
||||
}
|
||||
composable(LICENSE_SCREEN) {
|
||||
LicenseScreen()
|
||||
}
|
||||
composable(PRIVACY_SETTINGS_SCREEN) {
|
||||
PrivacyScreen()
|
||||
}
|
||||
composable(ADVANCED_SETTINGS_SCREEN) {
|
||||
AdvancedScreen()
|
||||
}
|
||||
composable(DOWNLOAD_SETTINGS_SCREEN) {
|
||||
DownloadScreen()
|
||||
}
|
||||
composable(EH_SETTINGS_SCREEN) {
|
||||
EhScreen()
|
||||
}
|
||||
composable(UCONFIG_SCREEN) {
|
||||
UConfigScreen()
|
||||
}
|
||||
composable(MYTAGS_SCREEN) {
|
||||
MyTagsScreen()
|
||||
}
|
||||
composable(FILTER_SCREEN) {
|
||||
FilterScreen()
|
||||
}
|
||||
composable(FINISH_ROUTE_NAME) {
|
||||
SideEffect {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
startRoute = if (Settings.needSignIn) {
|
||||
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
|
||||
} else {
|
||||
BaseScreenDestination
|
||||
},
|
||||
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("CompositionLocal LocalNavController not present!") }
|
||||
|
||||
const val BASE_SETTINGS_SCREEN = "Base"
|
||||
const val EH_SETTINGS_SCREEN = "Eh"
|
||||
const val DOWNLOAD_SETTINGS_SCREEN = "Download"
|
||||
const val PRIVACY_SETTINGS_SCREEN = "Privacy"
|
||||
const val ADVANCED_SETTINGS_SCREEN = "Advanced"
|
||||
const val ABOUT_SETTINGS_SCREEN = "About"
|
||||
const val LICENSE_SCREEN = "License"
|
||||
const val UCONFIG_SCREEN = "UConfig"
|
||||
const val MYTAGS_SCREEN = "Mytags"
|
||||
const val FILTER_SCREEN = "Filter"
|
||||
const val SIGN_IN_ROUTE_NAME = "SignIn"
|
||||
const val WEBVIEW_SIGN_IN_ROUTE_NAME = "WebViewSignIn"
|
||||
const val COOKIE_SIGN_IN_ROUTE_NAME = "CookieSignIn"
|
||||
const val SELECT_SITE_ROUTE_NAME = "SelectSite"
|
||||
const val FINISH_ROUTE_NAME = "Finish"
|
||||
@Destination
|
||||
@Composable
|
||||
fun Finish() {
|
||||
val context = LocalContext.current
|
||||
SideEffect {
|
||||
context.findActivity<Activity>().finish()
|
||||
}
|
||||
}
|
||||
@@ -15,36 +15,17 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.content.res.Resources.Theme
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import eu.kanade.tachiyomi.util.system.isNightMode
|
||||
import eu.kanade.tachiyomi.util.view.setSecureScreen
|
||||
|
||||
abstract class EhActivity : AppCompatActivity() {
|
||||
@StyleRes
|
||||
fun getThemeStyleRes(): Int {
|
||||
return if (Settings.blackDarkTheme && isNightMode()) R.style.ThemeOverlay_Black else R.style.ThemeOverlay
|
||||
}
|
||||
|
||||
override fun onApplyThemeResource(theme: Theme, resid: Int, first: Boolean) {
|
||||
theme.applyStyle(resid, true)
|
||||
theme.applyStyle(getThemeStyleRes(), true)
|
||||
}
|
||||
|
||||
override fun onNightModeChanged(mode: Int) {
|
||||
theme.applyStyle(getThemeStyleRes(), true)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !isNightMode()
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightNavigationBars = !isNightMode()
|
||||
enableEdgeToEdge()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -21,8 +24,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
@@ -30,9 +31,9 @@ import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.databinding.InfoSheetBinding
|
||||
import com.hippo.ehviewer.ui.scene.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
|
||||
private const val INDEX_URL = 2
|
||||
private const val INDEX_PARENT = 9
|
||||
@@ -61,43 +62,39 @@ private fun GalleryDetail.generateContent() = arrayOf(
|
||||
R.string.favorite_name to favoriteName,
|
||||
)
|
||||
|
||||
class GalleryInfoBottomSheet(private val detail: GalleryDetail) : BottomSheetDialogFragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = InfoSheetBinding.inflate(layoutInflater, container, false).apply {
|
||||
content.setMD3Content {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
val navController = remember { findNavController() }
|
||||
Text(
|
||||
text = stringResource(id = R.string.gallery_info),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
val data = remember(detail) { detail.generateContent() }
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
LazyColumn {
|
||||
itemsIndexed(data) { index, (key, content) ->
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
|
||||
if (index == INDEX_PARENT) {
|
||||
if (content != null) {
|
||||
navController.navWithUrl(content)
|
||||
}
|
||||
} else {
|
||||
context.addTextToClipboard(content, true)
|
||||
if (index == INDEX_URL) {
|
||||
// Save it to avoid detect the gallery
|
||||
Settings.clipboardTextHashCode = data[index].hashCode()
|
||||
}
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = key), modifier = Modifier.width(90.dp).padding(8.dp))
|
||||
Text(content.orEmpty(), modifier = Modifier.padding(8.dp))
|
||||
@Composable
|
||||
fun GalleryInfoBottomSheet(detail: GalleryDetail, navigator: DestinationsNavigator) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
Text(
|
||||
text = stringResource(id = R.string.gallery_info),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
val data = remember(detail) { detail.generateContent() }
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
LazyColumn(contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues()) {
|
||||
itemsIndexed(data) { index, (key, content) ->
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
|
||||
if (index == INDEX_PARENT) {
|
||||
if (content != null) {
|
||||
navigator.navWithUrl(content)
|
||||
}
|
||||
} else {
|
||||
context.addTextToClipboard(content, true)
|
||||
if (index == INDEX_URL) {
|
||||
// Save it to avoid detect the gallery
|
||||
Settings.clipboardTextHashCode = data[index].hashCode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = key), modifier = Modifier.width(90.dp).padding(8.dp))
|
||||
Text(content.orEmpty(), modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.root
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
@@ -26,20 +27,21 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
@@ -49,87 +51,127 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material3.DrawerState2
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.SideDrawer
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.currentRecomposeScope
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidViewBinding
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.onNavDestinationSelected2
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.Settings.launchPage
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
|
||||
import com.hippo.ehviewer.databinding.ActivityMainBinding
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.download.DownloadService
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.filled.Subscriptions
|
||||
import com.hippo.ehviewer.image.Image.Companion.decodeBitmap
|
||||
import com.hippo.ehviewer.ui.destinations.DownloadsScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.FavouritesScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.HistoryScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.HomePageScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.SubscriptionScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.ToplistScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder
|
||||
import com.hippo.ehviewer.ui.scene.BaseScene
|
||||
import com.hippo.ehviewer.ui.scene.GalleryDetailScene
|
||||
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
|
||||
import com.hippo.ehviewer.ui.scene.ProgressScene
|
||||
import com.hippo.ehviewer.ui.scene.navAnimated
|
||||
import com.hippo.ehviewer.ui.scene.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.TokenArgs
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navigate
|
||||
import com.hippo.ehviewer.ui.settings.showNewVersion
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.buildWindowInsets
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.getUrlFromClipboard
|
||||
import com.hippo.ehviewer.util.set
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.rememberNavHostEngine
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import splitties.systemservices.clipboardManager
|
||||
import splitties.systemservices.connectivityManager
|
||||
|
||||
private val navItems = arrayOf(
|
||||
Triple(R.id.nav_homepage, R.string.homepage, Icons.Default.Home),
|
||||
Triple(R.id.nav_subscription, R.string.subscription, EhIcons.Default.Subscriptions),
|
||||
Triple(R.id.nav_whats_hot, R.string.whats_hot, Icons.Default.Whatshot),
|
||||
Triple(R.id.nav_toplist, R.string.toplist, Icons.Default.FormatListNumbered),
|
||||
Triple(R.id.nav_favourite, R.string.favourite, Icons.Default.Favorite),
|
||||
Triple(R.id.nav_history, R.string.history, Icons.Default.History),
|
||||
Triple(R.id.nav_downloads, R.string.downloads, Icons.Default.Download),
|
||||
Triple(R.id.nav_settings, R.string.settings, Icons.Default.Settings),
|
||||
Triple(HomePageScreenDestination, R.string.homepage, Icons.Default.Home),
|
||||
Triple(SubscriptionScreenDestination, R.string.subscription, EhIcons.Default.Subscriptions),
|
||||
Triple(WhatshotScreenDestination, R.string.whats_hot, Icons.Default.Whatshot),
|
||||
Triple(ToplistScreenDestination, R.string.toplist, Icons.Default.FormatListNumbered),
|
||||
Triple(FavouritesScreenDestination, R.string.favourite, Icons.Default.Favorite),
|
||||
Triple(HistoryScreenDestination, R.string.history, Icons.Default.History),
|
||||
Triple(DownloadsScreenDestination, R.string.downloads, Icons.Default.Download),
|
||||
)
|
||||
|
||||
class MainActivity : EhActivity() {
|
||||
private lateinit var navController: NavController
|
||||
private val isInitializedFlow = MutableStateFlow(false)
|
||||
private val sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>()
|
||||
|
||||
@Composable
|
||||
fun ProvideSideSheetContent(content: @Composable ColumnScope.(DrawerState2) -> Unit) {
|
||||
DisposableEffect(content) {
|
||||
sideSheet.add(0, content)
|
||||
onDispose { sideSheet.remove(content) }
|
||||
}
|
||||
}
|
||||
|
||||
private var shareUrl: String? = null
|
||||
|
||||
@Composable
|
||||
fun ProvideAssistContent(url: String) {
|
||||
val urlState by rememberUpdatedState(url)
|
||||
DisposableEffect(urlState) {
|
||||
shareUrl = urlState
|
||||
onDispose {
|
||||
shareUrl = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImageToTempFile(uri: Uri): File? {
|
||||
val bitmap = runCatching {
|
||||
@@ -145,168 +187,224 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
lifecycleScope.launchUI {
|
||||
isInitializedFlow.first { it }
|
||||
if (!handleIntent(intent)) {
|
||||
if (intent != null && Intent.ACTION_VIEW == intent.action) {
|
||||
if (intent.data != null) {
|
||||
val url = intent.data.toString()
|
||||
EditTextDialogBuilder(this@MainActivity, url, "")
|
||||
.setTitle(R.string.error_cannot_parse_the_url)
|
||||
.setPositiveButton(android.R.string.copy) { _, _ ->
|
||||
this@MainActivity.addTextToClipboard(url)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
intentChannel.trySend(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?): Boolean {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val uri = intent.data ?: return false
|
||||
return navController.navWithUrl(uri.toString())
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND -> {
|
||||
val type = intent.type
|
||||
if ("text/plain" == type) {
|
||||
val builder = ListUrlBuilder()
|
||||
builder.keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
navController.navAnimated(
|
||||
R.id.galleryListScene,
|
||||
builder.toStartArgs(),
|
||||
)
|
||||
return true
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
val temp = saveImageToTempFile(uri)
|
||||
if (null != temp) {
|
||||
val builder = ListUrlBuilder()
|
||||
builder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH
|
||||
builder.imagePath = temp.path
|
||||
builder.isUseSimilarityScan = true
|
||||
navController.navAnimated(
|
||||
R.id.galleryListScene,
|
||||
builder.toStartArgs(),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navAnimated(R.id.nav_downloads, args)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var drawerLocked by mutableStateOf(false)
|
||||
private var openDrawerFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
private var recomposeFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
fun recompose() {
|
||||
recomposeFlow.tryEmit(Unit)
|
||||
}
|
||||
private val tipFlow = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
private val intentChannel = Channel<Intent>(capacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val intentFlow = intentChannel.receiveAsFlow()
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setMD3Content {
|
||||
val configuration = LocalConfiguration.current
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val dialogState = LocalDialogState.current
|
||||
val navDrawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val sideSheetState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val recomposeScope = currentRecomposeScope
|
||||
fun isSelected(id: Int) = ::navController.isInitialized && id == navController.currentDestination?.id
|
||||
fun closeDrawer() = scope.launch { drawerState.close() }
|
||||
val navController = rememberNavController()
|
||||
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
|
||||
navDrawerState.close()
|
||||
callback()
|
||||
}
|
||||
|
||||
val cannotParse = stringResource(R.string.error_cannot_parse_the_url)
|
||||
LaunchedEffect(Unit) {
|
||||
openDrawerFlow.collect {
|
||||
drawerState.open()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
recomposeFlow.collect {
|
||||
recomposeScope.invalidate()
|
||||
}
|
||||
}
|
||||
BackHandler(drawerState.isOpen) {
|
||||
closeDrawer()
|
||||
}
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState).navigationBarsPadding(),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
navItems.forEach { (id, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = isSelected(id),
|
||||
onClick = {
|
||||
closeDrawer()
|
||||
onNavDestinationSelected2(id, navController)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
intentFlow.collect { intent ->
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navController.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND -> {
|
||||
val type = intent.type
|
||||
if ("text/plain" == type) {
|
||||
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
navController.navigate(GalleryListScreenDestination(ListUrlBuilder(mKeyword = keyword)))
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
val temp = saveImageToTempFile(uri)
|
||||
if (null != temp) {
|
||||
val builder = ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
imagePath = temp.path,
|
||||
isUseSimilarityScan = true,
|
||||
)
|
||||
navController.navigate(GalleryListScreenDestination(builder))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navigate(DownloadsScreenDestination)
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !drawerLocked || drawerState.isOpen,
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runSuspendCatching {
|
||||
withIOContext {
|
||||
AppUpdater.checkForUpdate()?.let {
|
||||
dialogState.showNewVersion(this@MainActivity, it)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
showTip(getString(R.string.update_failed, ExceptionUtils.getReadableString(it)))
|
||||
}
|
||||
}
|
||||
val snackbarState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
tipFlow.collectLatest {
|
||||
snackbarState.showSnackbar(it)
|
||||
}
|
||||
}
|
||||
val warning = stringResource(R.string.metered_network_warning)
|
||||
val settings = stringResource(R.string.settings)
|
||||
val checkMeteredNetwork by Settings.meteredNetworkWarning.collectAsState()
|
||||
if (checkMeteredNetwork) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (connectivityManager.isActiveNetworkMetered) {
|
||||
if (isAtLeastQ) {
|
||||
val ret = snackbarState.showSnackbar(warning, settings)
|
||||
if (ret == SnackbarResult.ActionPerformed) {
|
||||
val panelIntent = Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
|
||||
startActivity(panelIntent)
|
||||
}
|
||||
} else {
|
||||
snackbarState.showSnackbar(warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
|
||||
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
|
||||
LifecycleResumeEffect {
|
||||
scope.launch {
|
||||
delay(300)
|
||||
val text = clipboardManager.getUrlFromClipboard(applicationContext)
|
||||
val hashCode = text?.hashCode() ?: 0
|
||||
if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
launch = { navController.navigate(GalleryDetailScreenDestination(TokenArgs(result1.gid, result1.token))) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
}
|
||||
launch?.let {
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction)
|
||||
if (ret == SnackbarResult.ActionPerformed) it()
|
||||
}
|
||||
}
|
||||
Settings.clipboardTextHashCode = hashCode
|
||||
}
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val currentDestination by navController.currentDestinationAsState()
|
||||
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
|
||||
val drawerLocked = lockDrawerHandle.isNotEmpty()
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
CompositionLocalProvider(
|
||||
LocalNavDrawerState provides navDrawerState,
|
||||
LocalSideSheetState provides sideSheetState,
|
||||
LocalDrawerLockHandle provides lockDrawerHandle,
|
||||
LocalSnackbarHostState provides snackbarState,
|
||||
) {
|
||||
val insets = buildWindowInsets {
|
||||
set(
|
||||
WindowInsetsCompat.Type.statusBars(),
|
||||
WindowInsets.statusBars,
|
||||
)
|
||||
set(
|
||||
WindowInsetsCompat.Type.navigationBars(),
|
||||
WindowInsets.navigationBars,
|
||||
)
|
||||
set(
|
||||
WindowInsetsCompat.Type.ime(),
|
||||
WindowInsets.ime,
|
||||
)
|
||||
}
|
||||
AndroidViewBinding(factory = { inflater, parent, attachToParent ->
|
||||
ActivityMainBinding.inflate(inflater, parent, attachToParent).apply {
|
||||
val navHostFragment = fragmentContainer.getFragment<NavHostFragment>()
|
||||
navController = navHostFragment.navController.apply {
|
||||
graph = navInflater.inflate(R.navigation.nav_graph).apply {
|
||||
check(launchPage in 0..3)
|
||||
setStartDestination(navItems[launchPage].first)
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
navItems.forEach { (direction, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = currentDestination === direction,
|
||||
onClick = {
|
||||
navController.navigate(direction.route)
|
||||
closeDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.settings))
|
||||
},
|
||||
selected = false,
|
||||
onClick = {
|
||||
closeDrawer { startActivity(Intent(applicationContext, ConfigureActivity::class.java)) }
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = navDrawerState,
|
||||
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
SideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
sheet(sideSheetState)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && !drawerLocked,
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = navItems[Settings.launchPage].first,
|
||||
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
isInitializedFlow.value = true
|
||||
}
|
||||
}) {
|
||||
ViewCompat.dispatchApplyWindowInsets(root, insets)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,12 +414,6 @@ class MainActivity : EhActivity() {
|
||||
onNewIntent(intent)
|
||||
}
|
||||
checkDownloadLocation()
|
||||
if (Settings.meteredNetworkWarning) {
|
||||
lifecycleScope.launchUI {
|
||||
isInitializedFlow.first { it }
|
||||
checkMeteredNetwork()
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (!Settings.appLinkVerifyTip) {
|
||||
try {
|
||||
@@ -378,107 +470,45 @@ class MainActivity : EhActivity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun checkMeteredNetwork() {
|
||||
if (connectivityManager.isActiveNetworkMetered) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Snackbar.make(
|
||||
findViewById(R.id.fragment_container),
|
||||
R.string.metered_network_warning,
|
||||
Snackbar.LENGTH_LONG,
|
||||
)
|
||||
.setAction(R.string.settings) {
|
||||
val panelIntent =
|
||||
Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
|
||||
startActivity(panelIntent)
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
showTip(R.string.metered_network_warning, BaseScene.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (Settings.needSignIn) {
|
||||
startActivity(Intent(this, ConfigureActivity::class.java))
|
||||
}
|
||||
super.onResume()
|
||||
lifecycleScope.launch {
|
||||
delay(300)
|
||||
checkClipboardUrl()
|
||||
}
|
||||
|
||||
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
|
||||
showTip(getString(id), useToast)
|
||||
}
|
||||
|
||||
fun showTip(message: CharSequence, useToast: Boolean = false) {
|
||||
if (useToast || !tipFlow.tryEmit(message.toString())) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkClipboardUrl() {
|
||||
isInitializedFlow.first { it }
|
||||
val text = clipboardManager.getUrlFromClipboard(this)
|
||||
val hashCode = text?.hashCode() ?: 0
|
||||
if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
val args = Bundle()
|
||||
args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN)
|
||||
args.putLong(GalleryDetailScene.KEY_GID, result1.gid)
|
||||
args.putString(GalleryDetailScene.KEY_TOKEN, result1.token)
|
||||
launch = { navController.navAnimated(R.id.galleryDetailScene, args) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
val args = Bundle()
|
||||
args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN)
|
||||
args.putLong(ProgressScene.KEY_GID, result2.gid)
|
||||
args.putString(ProgressScene.KEY_PTOKEN, result2.pToken)
|
||||
args.putInt(ProgressScene.KEY_PAGE, result2.page)
|
||||
launch = { navController.navAnimated(R.id.progressScene, args) }
|
||||
}
|
||||
launch?.let {
|
||||
withUIContext {
|
||||
val snackbar = Snackbar.make(
|
||||
findViewById(R.id.fragment_container),
|
||||
R.string.clipboard_gallery_url_snack_message,
|
||||
Snackbar.LENGTH_SHORT,
|
||||
)
|
||||
snackbar.setAction(R.string.clipboard_gallery_url_snack_action) {
|
||||
it()
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
Settings.clipboardTextHashCode = hashCode
|
||||
}
|
||||
|
||||
fun openDrawer() {
|
||||
openDrawerFlow.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun showTip(@StringRes id: Int, length: Int, useToast: Boolean = false) {
|
||||
showTip(getString(id), length, useToast)
|
||||
}
|
||||
|
||||
/**
|
||||
* If activity is running, show snack bar, otherwise show toast
|
||||
*/
|
||||
fun showTip(message: CharSequence, length: Int, useToast: Boolean = false) {
|
||||
if (!useToast) {
|
||||
Snackbar.make(
|
||||
findViewById(R.id.fragment_container),
|
||||
message,
|
||||
if (length == BaseScene.LENGTH_LONG) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this,
|
||||
message,
|
||||
if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
var mShareUrl: String? = null
|
||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
mShareUrl?.let { outContent?.webUri = Uri.parse(mShareUrl) }
|
||||
shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavDrawerState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalNavDrawerState not present!") }
|
||||
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalSideSheetState not present!") }
|
||||
val LocalSnackbarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackbarHostState not present!") }
|
||||
|
||||
@Composable
|
||||
fun LockDrawer(value: Boolean) {
|
||||
val updated by rememberUpdatedState(value)
|
||||
if (updated) {
|
||||
val current = currentCompositeKeyHash
|
||||
val handle = LocalDrawerLockHandle.current
|
||||
DisposableEffect(current) {
|
||||
handle.add(current)
|
||||
onDispose {
|
||||
handle.remove(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,52 @@ package com.hippo.ehviewer.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
import com.hippo.ehviewer.ui.theme.EhTheme
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
|
||||
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
|
||||
|
||||
// To make sure compose theme and legacy view theme are in sync, we only use Mdc3Theme
|
||||
fun ComposeView.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
|
||||
fun ComponentActivity.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
|
||||
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
|
||||
EhTheme {
|
||||
ProvideVectorPainterCache {
|
||||
val dialogState = remember { DialogState() }
|
||||
dialogState.Intercept()
|
||||
CompositionLocalProvider(
|
||||
LocalDialogState provides dialogState,
|
||||
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ehNavAnim by lazy {
|
||||
RootNavGraphDefaultAnimations(
|
||||
enterTransition = { fadeIn(animationSpec = tween(500)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(500)) },
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 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.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.android.material.math.MathUtils
|
||||
import com.hippo.ehviewer.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* @param context used to get the configuration for the drawable from
|
||||
*/
|
||||
class AddDeleteDrawable(context: Context, color: Int) : Drawable() {
|
||||
private val mPaint = Paint()
|
||||
private val mPath = Path()
|
||||
private val mSize: Int
|
||||
private var mProgress = 0f
|
||||
private var mAutoUpdateMirror = false
|
||||
private var mVerticalMirror = false
|
||||
|
||||
init {
|
||||
val resources = context.resources
|
||||
mSize = resources.getDimensionPixelSize(R.dimen.add_size)
|
||||
val barThickness = resources.getDimension(R.dimen.add_thickness).roundToInt().toFloat()
|
||||
mPaint.color = color
|
||||
mPaint.style = Paint.Style.STROKE
|
||||
mPaint.strokeJoin = Paint.Join.MITER
|
||||
mPaint.strokeCap = Paint.Cap.BUTT
|
||||
mPaint.strokeWidth = barThickness
|
||||
val halfSize = (mSize / 2).toFloat()
|
||||
mPath.moveTo(0f, -halfSize)
|
||||
mPath.lineTo(0f, halfSize)
|
||||
mPath.moveTo(-halfSize, 0f)
|
||||
mPath.lineTo(halfSize, 0f)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val bounds = bounds
|
||||
val canvasRotate = if (mVerticalMirror) {
|
||||
MathUtils.lerp(270f, 135f, mProgress)
|
||||
} else {
|
||||
MathUtils.lerp(0f, 135f, mProgress)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.translate(bounds.centerX().toFloat(), bounds.centerY().toFloat())
|
||||
canvas.rotate(canvasRotate)
|
||||
canvas.drawPath(mPath, mPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
mPaint.color = color
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
mPaint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(cf: ColorFilter?) {
|
||||
mPaint.colorFilter = cf
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int {
|
||||
return mSize * 6 / 5
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth(): Int {
|
||||
return mSize * 6 / 5
|
||||
}
|
||||
|
||||
/**
|
||||
* If set, canvas is flipped when progress reached to end and going back to start.
|
||||
*/
|
||||
private fun setVerticalMirror(verticalMirror: Boolean) {
|
||||
mVerticalMirror = verticalMirror
|
||||
}
|
||||
|
||||
fun setAutoUpdateMirror(autoUpdateMirror: Boolean) {
|
||||
mAutoUpdateMirror = autoUpdateMirror
|
||||
}
|
||||
|
||||
@get:Keep
|
||||
@set:Keep
|
||||
var progress: Float
|
||||
get() = mProgress
|
||||
set(progress) {
|
||||
if (mAutoUpdateMirror) {
|
||||
if (progress == 1f) {
|
||||
setVerticalMirror(true)
|
||||
} else if (progress == 0f) {
|
||||
setVerticalMirror(false)
|
||||
}
|
||||
}
|
||||
mProgress = progress
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
fun setAdd(duration: Long) {
|
||||
setShape(false, duration)
|
||||
}
|
||||
|
||||
fun setDelete(duration: Long) {
|
||||
setShape(true, duration)
|
||||
}
|
||||
|
||||
private fun setShape(delete: Boolean, duration: Long) {
|
||||
if (!(!delete && mProgress == 0f || delete && mProgress == 1f)) {
|
||||
val endProgress = if (delete) 1f else 0f
|
||||
if (duration <= 0) {
|
||||
progress = endProgress
|
||||
} else {
|
||||
val oa = ObjectAnimator.ofFloat(this, "progress", endProgress)
|
||||
oa.duration = duration
|
||||
oa.setAutoCancel(true)
|
||||
oa.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"Deprecated in Java",
|
||||
ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"),
|
||||
)
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSLUCENT
|
||||
}
|
||||
}
|
||||
@@ -1,112 +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.ui.legacy
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class AutoStaggeredGridLayoutManager(columnSize: Int, orientation: Int) :
|
||||
StaggeredGridLayoutManager(1, orientation) {
|
||||
private var mColumnSize = columnSize
|
||||
private var mColumnSizeChanged = true
|
||||
private var mStrategy = 0
|
||||
var supportsPredictiveItemAnimations = true
|
||||
|
||||
fun setColumnSize(columnSize: Int) {
|
||||
if (columnSize == mColumnSize) {
|
||||
return
|
||||
}
|
||||
mColumnSize = columnSize
|
||||
mColumnSizeChanged = true
|
||||
}
|
||||
|
||||
fun setStrategy(strategy: Int) {
|
||||
if (strategy == mStrategy) {
|
||||
return
|
||||
}
|
||||
mStrategy = strategy
|
||||
mColumnSizeChanged = true
|
||||
}
|
||||
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return supportsPredictiveItemAnimations && super.supportsPredictiveItemAnimations()
|
||||
}
|
||||
|
||||
override fun onMeasure(
|
||||
recycler: RecyclerView.Recycler,
|
||||
state: RecyclerView.State,
|
||||
widthSpec: Int,
|
||||
heightSpec: Int,
|
||||
) {
|
||||
if (mColumnSizeChanged && mColumnSize > 0) {
|
||||
val totalSpace = if (orientation == VERTICAL) {
|
||||
check(
|
||||
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
|
||||
widthSpec,
|
||||
),
|
||||
) { "RecyclerView need a fixed width for AutoStaggeredGridLayoutManager" }
|
||||
View.MeasureSpec.getSize(widthSpec) - paddingRight - paddingLeft
|
||||
} else {
|
||||
check(
|
||||
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
|
||||
heightSpec,
|
||||
),
|
||||
) { "RecyclerView need a fixed height for AutoStaggeredGridLayoutManager" }
|
||||
View.MeasureSpec.getSize(heightSpec) - paddingTop - paddingBottom
|
||||
}
|
||||
val spanCount = when (mStrategy) {
|
||||
STRATEGY_MIN_SIZE -> getSpanCountForMinSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
|
||||
STRATEGY_SUITABLE_SIZE -> getSpanCountForSuitableSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
|
||||
else -> getSpanCountForMinSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
}
|
||||
setSpanCount(spanCount)
|
||||
mColumnSizeChanged = false
|
||||
}
|
||||
super.onMeasure(recycler, state, widthSpec, heightSpec)
|
||||
}
|
||||
}
|
||||
|
||||
const val STRATEGY_MIN_SIZE = 0
|
||||
const val STRATEGY_SUITABLE_SIZE = 1
|
||||
fun getSpanCountForSuitableSize(total: Int, single: Int): Int {
|
||||
return (total / single.toFloat()).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun calculateSuitableSpanCount(): Int {
|
||||
val totalSpace = LocalConfiguration.current.screenWidthDp
|
||||
return getSpanCountForSuitableSize(totalSpace, Settings.thumbSizeDp)
|
||||
}
|
||||
|
||||
fun getSpanCountForMinSize(total: Int, single: Int): Int {
|
||||
return (total / single).coerceAtLeast(1)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 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.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class BringOutTransition(contentLayout: FrameLayout, mSearchLayout: SearchLayout) :
|
||||
ViewTransition(contentLayout, mSearchLayout) {
|
||||
override fun startAnimations(hiddenView: View, shownView: View) {
|
||||
mAnimator1 = hiddenView.animate().alpha(0f).scaleY(0.7f).scaleX(0.7f).apply {
|
||||
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
hiddenView.visibility = View.GONE
|
||||
mAnimator1 = null
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
shownView.alpha = 0f
|
||||
shownView.scaleX = 0.7f
|
||||
shownView.scaleY = 0.7f
|
||||
shownView.visibility = View.VISIBLE
|
||||
mAnimator2 = shownView.animate().alpha(1f).scaleX(1f).scaleY(1f).apply {
|
||||
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mAnimator2 = null
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.CheckBox
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class CheckBoxDialogBuilder(
|
||||
context: Context,
|
||||
message: String?,
|
||||
checkText: String?,
|
||||
checked: Boolean,
|
||||
) : BaseDialogBuilder(
|
||||
context,
|
||||
) {
|
||||
private val mCheckBox: CheckBox
|
||||
val isChecked: Boolean
|
||||
get() = mCheckBox.isChecked
|
||||
|
||||
init {
|
||||
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_checkbox_builder, null)
|
||||
setView(view)
|
||||
setMessage(message)
|
||||
mCheckBox = view.findViewById(R.id.checkbox)
|
||||
mCheckBox.text = checkText
|
||||
mCheckBox.isChecked = checked
|
||||
view.setOnClickListener { mCheckBox.toggle() }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.DrawableWrapper
|
||||
import android.text.Html
|
||||
import android.widget.TextView
|
||||
import coil.imageLoader
|
||||
import coil3.imageLoader
|
||||
import coil3.request.crossfade
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
|
||||
class CoilImageGetter(
|
||||
@@ -16,7 +17,7 @@ class CoilImageGetter(
|
||||
data(source)
|
||||
crossfade(false)
|
||||
target { drawable ->
|
||||
setDrawable(drawable)
|
||||
setDrawable(drawable.asDrawable(textView.resources))
|
||||
if (drawable is Animatable) drawable.start()
|
||||
setBounds(0, 0, intrinsicWidth, intrinsicHeight)
|
||||
textView.text = textView.text
|
||||
|
||||
@@ -1,53 +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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class DrawerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs) {
|
||||
private var mMaxWidth = 0
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(attrs, SIZE_ATTRS)
|
||||
mMaxWidth = a.getDimensionPixelOffset(0, 0)
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
var widthSpec = widthSpec
|
||||
when (MeasureSpec.getMode(widthSpec)) {
|
||||
MeasureSpec.EXACTLY -> {}
|
||||
MeasureSpec.AT_MOST -> widthSpec = MeasureSpec.makeMeasureSpec(
|
||||
MeasureSpec.getSize(widthSpec).coerceAtMost(mMaxWidth),
|
||||
MeasureSpec.EXACTLY,
|
||||
)
|
||||
|
||||
MeasureSpec.UNSPECIFIED ->
|
||||
widthSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY)
|
||||
}
|
||||
// Let super sort out the height
|
||||
super.onMeasure(widthSpec, heightSpec)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SIZE_ATTRS = intArrayOf(android.R.attr.maxWidth)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class EditTextDialogBuilder(
|
||||
context: Context,
|
||||
text: String?,
|
||||
hint: String?,
|
||||
) : BaseDialogBuilder(
|
||||
context,
|
||||
),
|
||||
OnEditorActionListener {
|
||||
private val mTextInputLayout: TextInputLayout
|
||||
val editText: EditText
|
||||
private var mDialog: AlertDialog? = null
|
||||
val text: String
|
||||
get() = editText.text.toString()
|
||||
|
||||
fun setError(error: CharSequence?) {
|
||||
mTextInputLayout.error = error
|
||||
}
|
||||
|
||||
override fun create(): AlertDialog {
|
||||
mDialog = super.create()
|
||||
return mDialog as AlertDialog
|
||||
}
|
||||
|
||||
override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean {
|
||||
return if (mDialog != null) {
|
||||
val button = mDialog!!.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
button?.performClick()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_edittext_builder, null)
|
||||
setView(view)
|
||||
mTextInputLayout = view as TextInputLayout
|
||||
editText = view.findViewById(R.id.edit_text)
|
||||
editText.setText(text)
|
||||
editText.setSelection(editText.text.length)
|
||||
editText.setOnEditorActionListener(this)
|
||||
mTextInputLayout.hint = hint
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 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.animation.Animator
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AnimationUtils
|
||||
import com.hippo.ehviewer.util.SimpleAnimatorListener
|
||||
import com.hippo.ehviewer.util.getParcelableCompat
|
||||
|
||||
class FabLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : ViewGroup(context, attrs, defStyleAttr), View.OnClickListener {
|
||||
private var mFabSize = 0
|
||||
private var mFabMiniSize = 0
|
||||
private var mIntervalPrimary = 0
|
||||
private var mIntervalSecondary = 0
|
||||
private var mExpanded = true
|
||||
private var mAutoCancel = true
|
||||
private var mHidePrimaryFab = false
|
||||
private var mMainFabCenterY = -1f
|
||||
private var mOnExpandListeners = arrayListOf<OnExpandListener>()
|
||||
private var mOnClickFabListener: OnClickFabListener? = null
|
||||
|
||||
private val mOnBackPressedCallback = object : OnBackPressedCallback(false), OnExpandListener {
|
||||
override fun handleOnBackPressed() {
|
||||
isExpanded = false
|
||||
}
|
||||
|
||||
override fun onExpand(expanded: Boolean) {
|
||||
isEnabled = expanded
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
(context as ComponentActivity).onBackPressedDispatcher.addCallback(mOnBackPressedCallback)
|
||||
mOnExpandListeners.add(mOnBackPressedCallback)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
mOnBackPressedCallback.remove()
|
||||
mOnExpandListeners.remove(mOnBackPressedCallback)
|
||||
}
|
||||
|
||||
init {
|
||||
isSoundEffectsEnabled = false
|
||||
clipToPadding = false
|
||||
mFabSize = context.resources.getDimensionPixelOffset(R.dimen.fab_size)
|
||||
mFabMiniSize = context.resources.getDimensionPixelOffset(R.dimen.fab_min_size)
|
||||
mIntervalPrimary =
|
||||
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_primary_margin)
|
||||
mIntervalSecondary =
|
||||
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_secondary_margin)
|
||||
}
|
||||
|
||||
override fun addView(child: View, index: Int, params: LayoutParams) {
|
||||
check(child is FloatingActionButton) { "FloatingActionBarLayout should only " + "contain FloatingActionButton, but try to add " + child.javaClass.name }
|
||||
super.addView(child, index, params)
|
||||
}
|
||||
|
||||
val primaryFab: FloatingActionButton?
|
||||
get() = getChildAt(childCount - 1) as? FloatingActionButton
|
||||
|
||||
private val secondaryFabCount: Int
|
||||
get() = 0.coerceAtLeast(childCount - 1)
|
||||
|
||||
private fun getSecondaryFabAt(index: Int): FloatingActionButton? {
|
||||
return if (index < 0 || index >= secondaryFabCount) {
|
||||
null
|
||||
} else {
|
||||
getChildAt(index) as FloatingActionButton
|
||||
}
|
||||
}
|
||||
|
||||
fun setSecondaryFabVisibilityAt(index: Int, visible: Boolean) {
|
||||
getSecondaryFabAt(index)?.run {
|
||||
if (visible && visibility == GONE) {
|
||||
animate().cancel()
|
||||
visibility = if (mExpanded) VISIBLE else INVISIBLE
|
||||
} else if (!visible && visibility != GONE) {
|
||||
animate().cancel()
|
||||
visibility = GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChildMeasureSpec(parentMeasureSpec: Int): Int {
|
||||
val parentMode = MeasureSpec.getMode(parentMeasureSpec)
|
||||
val parentSize = MeasureSpec.getSize(parentMeasureSpec)
|
||||
val childMode: Int
|
||||
val childSize: Int
|
||||
when (parentMode) {
|
||||
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> {
|
||||
childMode = MeasureSpec.AT_MOST
|
||||
childSize = parentSize
|
||||
}
|
||||
|
||||
MeasureSpec.UNSPECIFIED -> {
|
||||
childMode = MeasureSpec.UNSPECIFIED
|
||||
childSize = parentSize
|
||||
}
|
||||
|
||||
else -> {
|
||||
childMode = MeasureSpec.AT_MOST
|
||||
childSize = parentSize
|
||||
}
|
||||
}
|
||||
return MeasureSpec.makeMeasureSpec(childSize, childMode)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec)
|
||||
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec)
|
||||
measureChildren(childWidthMeasureSpec, childHeightMeasureSpec)
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
val count = childCount
|
||||
for (i in 0 until count) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
maxWidth = maxWidth.coerceAtLeast(child.measuredWidth)
|
||||
maxHeight += child.measuredHeight
|
||||
}
|
||||
maxWidth += paddingLeft + paddingRight
|
||||
maxHeight += paddingTop + paddingBottom
|
||||
maxHeight = maxHeight.coerceAtLeast(suggestedMinimumHeight)
|
||||
maxWidth = maxWidth.coerceAtLeast(suggestedMinimumWidth)
|
||||
setMeasuredDimension(
|
||||
resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
|
||||
resolveSizeAndState(maxHeight, heightMeasureSpec, 0),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
var centerX = 0
|
||||
var bottom = measuredHeight - paddingBottom
|
||||
val count = childCount
|
||||
var i = count
|
||||
while (--i >= 0) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
val childWidth = child.measuredWidth
|
||||
val childHeight = child.measuredHeight
|
||||
var layoutBottom: Int
|
||||
var layoutRight: Int
|
||||
if (i == count - 1) {
|
||||
layoutBottom = bottom + (childHeight - mFabSize) / 2
|
||||
layoutRight = measuredWidth - paddingRight + (childWidth - mFabSize) / 2
|
||||
bottom -= mFabSize + mIntervalPrimary
|
||||
centerX = layoutRight - childWidth / 2
|
||||
mMainFabCenterY = layoutBottom - childHeight / 2f
|
||||
} else {
|
||||
layoutBottom = bottom + (childHeight - mFabMiniSize) / 2
|
||||
layoutRight = centerX + childWidth / 2
|
||||
bottom -= mFabMiniSize + mIntervalSecondary
|
||||
}
|
||||
child.layout(
|
||||
layoutRight - childWidth,
|
||||
layoutBottom - childHeight,
|
||||
layoutRight,
|
||||
layoutBottom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addOnExpandListener(listener: OnExpandListener) {
|
||||
mOnExpandListeners.add(listener)
|
||||
}
|
||||
|
||||
fun setOnClickFabListener(listener: OnClickFabListener?) {
|
||||
mOnClickFabListener = listener
|
||||
if (listener != null) {
|
||||
var i = 0
|
||||
val n = childCount
|
||||
while (i < n) {
|
||||
getChildAt(i).setOnClickListener(this)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
var i = 0
|
||||
val n = childCount
|
||||
while (i < n) {
|
||||
getChildAt(i).isClickable = false
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHidePrimaryFab(hidePrimaryFab: Boolean) {
|
||||
if (mHidePrimaryFab != hidePrimaryFab) {
|
||||
mHidePrimaryFab = hidePrimaryFab
|
||||
val expanded = mExpanded
|
||||
val count = childCount
|
||||
if (!expanded && count > 0) {
|
||||
getChildAt(count - 1).visibility = if (hidePrimaryFab) INVISIBLE else VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoCancel(autoCancel: Boolean) {
|
||||
if (mAutoCancel != autoCancel) {
|
||||
mAutoCancel = autoCancel
|
||||
if (mExpanded) {
|
||||
if (autoCancel) {
|
||||
setOnClickListener(this)
|
||||
} else {
|
||||
isClickable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
isExpanded = !mExpanded
|
||||
}
|
||||
|
||||
var isExpanded: Boolean
|
||||
get() = mExpanded
|
||||
set(expanded) {
|
||||
setExpanded(expanded, true)
|
||||
}
|
||||
|
||||
fun setExpanded(expanded: Boolean, animation: Boolean) {
|
||||
if (mExpanded != expanded) {
|
||||
mExpanded = expanded
|
||||
if (mAutoCancel) {
|
||||
if (expanded) {
|
||||
setOnClickListener(this)
|
||||
} else {
|
||||
isClickable = false
|
||||
}
|
||||
}
|
||||
val count = childCount
|
||||
if (count > 0) {
|
||||
if (mMainFabCenterY == -1f || !animation) {
|
||||
// It is before first onLayout
|
||||
val checkCount = if (mHidePrimaryFab) count else count - 1
|
||||
for (i in 0 until checkCount) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
child.visibility = if (expanded) VISIBLE else INVISIBLE
|
||||
if (expanded) {
|
||||
child.alpha = 1f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mHidePrimaryFab) {
|
||||
setPrimaryFabAnimation(getChildAt(count - 1), expanded, !expanded)
|
||||
}
|
||||
for (i in 0 until count - 1) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
setSecondaryFabAnimation(child, expanded, expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
mOnExpandListeners.forEach { it.onExpand(expanded) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPrimaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
|
||||
val startRotation: Float
|
||||
val endRotation: Float
|
||||
val startScale: Float
|
||||
val endScale: Float
|
||||
val interpolator: Interpolator
|
||||
if (expanded) {
|
||||
startRotation = -45.0f
|
||||
endRotation = 0.0f
|
||||
startScale = 0.0f
|
||||
endScale = 1.0f
|
||||
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
|
||||
} else {
|
||||
startRotation = 0.0f
|
||||
endRotation = 0.0f
|
||||
startScale = 1.0f
|
||||
endScale = 0.0f
|
||||
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
|
||||
}
|
||||
child.scaleX = startScale
|
||||
child.scaleY = startScale
|
||||
child.rotation = startRotation
|
||||
child.animate()
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.rotation(endRotation)
|
||||
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
|
||||
.setDuration(ANIMATE_TIME)
|
||||
.setInterpolator(interpolator)
|
||||
.setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationStart(animation: Animator) {
|
||||
if (expanded) {
|
||||
child.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (!expanded) {
|
||||
child.visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
private fun setSecondaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
|
||||
val startTranslationY: Float
|
||||
val endTranslationY: Float
|
||||
val startAlpha: Float
|
||||
val endAlpha: Float
|
||||
val interpolator: Interpolator
|
||||
if (expanded) {
|
||||
startTranslationY = mMainFabCenterY - child.top - child.height / 2
|
||||
endTranslationY = 0f
|
||||
startAlpha = 0f
|
||||
endAlpha = 1f
|
||||
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
|
||||
} else {
|
||||
startTranslationY = 0f
|
||||
endTranslationY = mMainFabCenterY - child.top - child.height / 2
|
||||
startAlpha = 1f
|
||||
endAlpha = 0f
|
||||
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
|
||||
}
|
||||
child.alpha = startAlpha
|
||||
child.translationY = startTranslationY
|
||||
child.animate()
|
||||
.alpha(endAlpha)
|
||||
.translationY(endTranslationY)
|
||||
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
|
||||
.setDuration(ANIMATE_TIME)
|
||||
.setInterpolator(interpolator)
|
||||
.setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationStart(animation: Animator) {
|
||||
if (expanded) {
|
||||
child.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (!expanded) {
|
||||
child.visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (this === v) {
|
||||
isExpanded = false
|
||||
} else {
|
||||
mOnClickFabListener?.let {
|
||||
val position = indexOfChild(v)
|
||||
if (position == childCount - 1) {
|
||||
it.onClickPrimaryFab(this, v as FloatingActionButton)
|
||||
} else if (position >= 0 && mExpanded) {
|
||||
it.onClickSecondaryFab(this, v as FloatingActionButton, position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchSetPressed(pressed: Boolean) {
|
||||
// Don't dispatch it to children
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val state = Bundle()
|
||||
state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState())
|
||||
state.putBoolean(STATE_KEY_AUTO_CANCEL, mAutoCancel)
|
||||
state.putBoolean(STATE_KEY_EXPANDED, mExpanded)
|
||||
return state
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable) {
|
||||
if (state is Bundle) {
|
||||
super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER))
|
||||
setAutoCancel(state.getBoolean(STATE_KEY_AUTO_CANCEL))
|
||||
setExpanded(state.getBoolean(STATE_KEY_EXPANDED), false)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnExpandListener {
|
||||
fun onExpand(expanded: Boolean)
|
||||
}
|
||||
|
||||
interface OnClickFabListener {
|
||||
fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton)
|
||||
fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANIMATE_TIME = 300L
|
||||
private const val STATE_KEY_SUPER = "super"
|
||||
private const val STATE_KEY_AUTO_CANCEL = "auto_cancel"
|
||||
private const val STATE_KEY_EXPANDED = "expanded"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user