Compare commits

...
107 Commits
Author SHA1 Message Date
FooIbar 85b538bbd3 Prepare for release 1.11.6
Baseline profile generation / baseline-profile (push) Has been cancelled
Releases / build (push) Has been cancelled
2024-07-09 22:03:04 +08:00
FooIbar d00d700a4b Improve Path/Uri conversion 2024-07-09 21:51:41 +08:00
github-actions[bot] ae66318843 Update baseline profile 2024-07-09 19:15:53 +08:00
FooIbar 1b9aa6a762 Retrieve favorite notes when modifying favorites 2024-07-09 18:54:57 +08:00
renovate[bot]andFooIbar 306ce83de4 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha08 (#1397)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha08

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-07-09 07:01:49 +00:00
FooIbar 89f9735ab9 Update Rust edition to 2021 2024-07-09 11:07:10 +08:00
FooIbar 843e1cc6a0 Fix MediaStore DATE_ADDED/DATE_MODIFIED
They are in seconds not milliseconds as per the documentation.
2024-07-09 10:55:34 +08:00
FooIbar 1a53d0d207 Fix download notification not showing 2024-07-09 10:18:22 +08:00
renovate[bot] 45c02d4fd6 Update Rust crate quick-xml to 0.36 2024-07-08 20:06:06 +00:00
renovate[bot] 862dee0845 Lock file maintenance 2024-07-08 01:53:46 +00:00
FooIbar 809bbfeba7 Refine clean redundancy dialog 2024-07-08 00:12:23 +08:00
FooIbar 959c740175 AndroidFileSystem POC (#1383)
* AndroidFileSystem POC

* Cache image file paths

* Refine
2024-07-07 07:13:49 +00:00
FooIbar a718510c89 Implicitly add language filter tag
This partially reverts commit 673c1837e9.
2024-07-06 15:02:55 +08:00
FooIbar f7c2fd8af3 Renew GitHub token 2024-07-05 21:12:46 +08:00
FooIbar c76440f167 Address deprecation 2024-07-05 20:57:47 +08:00
FooIbar c3bd959ee5 Update Compose snapshot (#1381)
* Update Compose snapshot

https://issuetracker.google.com/344633766

* Fix search bar text field padding
2024-07-04 13:34:27 +00:00
FooIbar 0a25643b87 Fix native crash on archive open failures 2024-07-04 20:24:37 +08:00
FooIbar c805ca8aff Revert "Reuse the root download location UniFile object"
Reason for revert: Caused OOM.
2024-07-03 22:34:18 +08:00
FooIbar 699a1ab801 Refactor DownloadService to use flows 2024-07-03 19:59:12 +08:00
FooIbar ee091aee0f Rescan download directory when restoring downloads (#1372) 2024-07-02 05:50:20 +00:00
FooIbar 673c1837e9 Fix language filter being applied to quick search 2024-07-02 13:44:01 +08:00
FooIbar 3998913c17 Fix existing download dirname not being used (#1371)
Tweak clean redundancy logic and add a confirm dialog.
2024-07-02 13:41:31 +08:00
renovate[bot] 1e83d7128e Update androidx.lifecycle to v2.8.3 2024-07-01 17:29:06 +00:00
FooIbar 355289a576 Avoid relying on implementation details (#1357)
* Avoid relying on implementation details

* Refine

* Fixup

* Extract common logic of RawFile & TreeDocumentFile

* Reuse the root download location UniFile object
2024-07-01 20:00:13 +08:00
FooIbar ba3a41f0ee Limit search bar text to single line 2024-07-01 17:17:38 +08:00
github-actions[bot] fc6d0bd6ef Update Chrome version for default User-Agent 2024-07-01 14:26:38 +08:00
FooIbar 15ef0ae362 Exclude scrollbar area from the system gesture 2024-07-01 14:26:15 +08:00
renovate[bot] a598f8562d Lock file maintenance 2024-07-01 01:28:33 +00:00
github-actions[bot] 2860fed5ab Update baseline profile 2024-06-30 11:07:14 +08:00
FooIbar f247544439 Migrate to CMP's implementation of scrollbar 2024-06-30 11:06:12 +08:00
renovate[bot] fdc3d94d5c Update Rust crate quick-xml to 0.35 2024-06-29 20:08:16 +00:00
renovate[bot] afc2ff7478 Update dependency io.github.petertrr:kotlin-multiplatform-diff to v0.7.0 2024-06-29 15:38:04 +00:00
renovate[bot] 83072d4eee Update aboutlibraries to v11.2.2 2024-06-29 12:13:33 +00:00
renovate[bot] 7e7be8d40a Update agp to v8.6.0-alpha08 2024-06-27 21:42:03 +00:00
FooIbar 90d7555333 [no ci] Fix benchmark build type configuration 2024-06-27 14:48:55 +08:00
github-actions[bot] ba268cfe6d Update baseline profile 2024-06-27 12:37:32 +08:00
renovate[bot]andFooIbar 0a7a9beb84 Update dependency androidx.test.uiautomator:uiautomator to v2.4.0-alpha01 (#1354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-06-27 04:09:18 +00:00
renovate[bot] b18b89db29 Update dependency androidx.test.ext:junit to v1.2.1 2024-06-27 03:54:12 +00:00
FooIbar cb2d99bdc1 Add automated baseline profile generation (#1349)
* Add automated baseline profile generation

* Create benchmark build type

And remove the `enabledRules` argument as it will be injected by
baseline profile gradle plugin.
2024-06-27 11:46:02 +08:00
renovate[bot]andFooIbar 4dd6d68163 Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha07 (#1351)
* Update dependency io.coil-kt.coil3:coil-bom to v3.0.0-alpha07

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-06-27 03:44:45 +00:00
renovate[bot] 28ecc9d479 Update dependency dev.chrisbanes.compose:compose-bom to v2024.06.00-alpha01 2024-06-26 23:16:46 +00:00
renovate[bot] c917117db3 Update kotlinx.serialization to v1.7.1 2024-06-25 17:19:22 +00:00
FooIbar 5c0d65dd60 Fix crop borders not working 2024-06-25 15:17:21 +08:00
FooIbar dfc59aa643 Target API 35 as it has reached platform stability
Still use 34 SDK in debug build as sources for 35 are not available yet.
2024-06-25 15:17:09 +08:00
renovate[bot] 6ab77e541f Update dependency com.android.application to v8.6.0-alpha07 2024-06-25 10:01:48 +08:00
renovate[bot] 6224f8f4ab Update Rust crate quick-xml to 0.34 2024-06-24 21:20:26 +00:00
renovate[bot] 3d269c4220 Lock file maintenance 2024-06-24 00:31:53 +00:00
FooIbar 176a802e96 Don't use Unicode ellipsis character for filename
It will be replaced by three dots.
2024-06-23 09:04:18 +08:00
renovate[bot] 417a06e140 Update dependency io.github.fornewid:material-motion-compose-core to v2.0.1 2024-06-22 21:19:47 +00:00
FooIbar 5c4f034532 Update Compose snapshot and vendor Reorderable
As `animateItemPlacement` has been removed.
2024-06-22 12:57:13 +08:00
FooIbar 1d50855f52 Workaround for displaying extremely long images (#1327)
* Workaround for displaying extremely long images

* Make hardware bitmap threshold configurable
2024-06-22 12:56:46 +08:00
renovate[bot] 0280450797 Update Rust crate quick-xml to 0.33 2024-06-21 16:58:19 +00:00
FooIbar d0c907c7ff Remove unused jitpack repo 2024-06-21 12:17:49 +08:00
FooIbar 1302e17c01 Migrate to ViewPager2 (#1335)
* Migrate to ViewPager2

* Remove code for unimplemented automatic background
2024-06-21 12:07:27 +08:00
FooIbar 33f83b7325 Switch PhotoView to active fork 2024-06-20 15:05:02 +08:00
FooIbar d2c49f5022 Upload all R8 reports 2024-06-19 12:26:32 +08:00
FooIbar 084883a308 Decouple CronetEngine 2024-06-19 12:14:20 +08:00
FooIbar 67a73da720 Revert "Update dependency com.android.application to v8.6.0-alpha06"
This reverts commit f2447fd122.
2024-06-19 01:21:01 +08:00
renovate[bot] f2447fd122 Update dependency com.android.application to v8.6.0-alpha06 2024-06-18 16:33:56 +00:00
FooIbar 8ddcb003e0 Move lint check to the check job (#1330)
* K2 lint

* Move lint check to the check job

* Fix CI
2024-06-18 16:25:38 +00:00
FooIbar 0bff011f72 Address deprecation 2024-06-18 18:15:29 +08:00
FooIbar f41d1d7306 Skip patching if the patch is already applied 2024-06-18 00:12:33 +08:00
renovate[bot] a30aa23b67 Update xmlutil to v0.90.1 2024-06-17 23:39:02 +08:00
FooIbar 583d30eb5f Update nettle to v3.10 2024-06-17 18:01:31 +08:00
FooIbar c53b0986a9 Use link options to exclude libarchive symbols 2024-06-17 18:00:56 +08:00
renovate[bot] 85703c8c47 Lock file maintenance 2024-06-17 07:57:38 +00:00
renovate[bot] 025ba0bf5b Lock file maintenance 2024-06-17 01:19:24 +00:00
renovate[bot] ac2def22d2 Update Rust crate android_logger to 0.14 2024-06-16 09:59:31 +00:00
FooIbar 895cfc7bc6 Minor refactor 2024-06-16 17:48:50 +08:00
FooIbar 49eb9d5077 Fix gid qualifier not working with language filter 2024-06-16 17:48:50 +08:00
FooIbar 5a3c165ff1 Update ktlint to 1.3.0 2024-06-16 17:48:50 +08:00
FooIbar 3946428df6 Migrate to the std implementation of OnceCell 2024-06-16 17:46:40 +08:00
FooIbar 471d9784d6 Use different schedule to avoid rate limits 2024-06-15 13:41:21 +08:00
FooIbar 0ba0afc0d0 Downgrade Compose to the last known good build 2024-06-14 20:44:18 +08:00
FooIbar 5049b791fb Fix FAB recomposing on item selected 2024-06-14 20:43:46 +08:00
renovate[bot] 9a3715c516 Update androidx.lifecycle to v2.8.2 2024-06-12 23:23:08 +00:00
renovate[bot] 4ee276f636 Update androidx.room to v2.7.0-alpha04 2024-06-12 20:27:55 +00:00
renovate[bot] d528b2d5d0 Update dependency io.github.petertrr:kotlin-multiplatform-diff to v0.6.0 2024-06-12 01:40:34 +00:00
FooIbar b3ee69d193 Clean up EhCookieStore 2024-06-11 14:21:51 +08:00
FooIbar f882b78ff6 Update NDK to r27 RC 1 2024-06-11 14:20:18 +08:00
renovate[bot] 7e58101a3f Update Rust crate quick-xml to 0.32 2024-06-10 15:25:59 +00:00
FooIbar 5450eb9b2b Revert "Update Compose snapshot"
This reverts commit 13cf42437f.
2024-06-10 13:04:40 +08:00
renovate[bot] 69695e3c99 Lock file maintenance 2024-06-10 01:02:53 +00:00
FooIbar 5b15059c04 Switch back to C implementation of sha1
Now that libnettle is built with hardware acceleration instructions
2024-06-09 23:11:57 +08:00
FooIbar f50d69cd60 Build libnettle with assembly code (#1292)
* Build libnettle with assembler code

* Add .gitattributes for shell script

* Patch nettle for CPU feature detection on Android

* Don't build libnettle at configuration time

* Fix build

* Refine
2024-06-09 13:49:36 +08:00
renovate[bot] df6f783a69 Update dependency com.android.application to v8.6.0-alpha05 2024-06-08 10:58:55 +00:00
Dude so hot f0291d94f9 Tell function return types to workaround compiler bug 2024-06-07 17:11:04 +08:00
Dude so hot 593f24b86f Refactor awaitInputTextWithCheckBox 2024-06-07 17:11:04 +08:00
Dude so hot 5bce898f99 Move goto dialog invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot 2b444c262d Move archive dialog invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot e40a7c75d9 Move page range invalidator to use ensure 2024-06-07 17:11:04 +08:00
Dude so hot 4a7c057260 Expr-ize 2024-06-07 17:11:04 +08:00
Dude so hot 229a00cd53 Let Dialog.awaitInputText use Raise to improve semantics 2024-06-07 17:11:04 +08:00
Dude so hot d2d0d880f1 Let Dialog.awaitResult use Raise to improve semantics 2024-06-07 17:11:04 +08:00
Dude so hot c5e17c1d5b Retry when FabLayoutState sync interrupted
Double click primary fab on select mode should "collapse to recompose secondary fabs" then immediately expand
2024-06-07 15:58:37 +08:00
Dude so hot 33ff2ebe06 Import arrow resilience 2024-06-07 15:58:37 +08:00
Dude so hot 5c13e188de Secondary fab recompose force fablayout collapse
Fix bug where fastly click primary fab twice on select mode, select state is cleared but secondary fabs not recomposed
2024-06-07 15:58:37 +08:00
FooIbar 60b03dcd0e Fix integrity check for non-H@H resampled images
Their URLs contain hashes for both original and resampled images.
2024-06-07 15:13:40 +08:00
renovate[bot] 4604a25f58 Update dependency io.github.fornewid:material-motion-compose-core to v2 2024-06-07 01:26:56 +00:00
FooIbar 99029b8b04 Prepare for next iteration 2024-06-06 22:07:52 +08:00
FooIbar 70f46442c6 Prepare for release 1.11.6-RC2
Releases / build (push) Has been cancelled
2024-06-06 22:07:23 +08:00
FooIbar 13cf42437f Update Compose snapshot
https://issuetracker.google.com/343750859
2024-06-06 21:54:22 +08:00
renovate[bot] 1e29f6dcb4 Update kotlinx.serialization to v1.7.0 2024-06-06 13:52:43 +00:00
renovate[bot] f25981792f Update dependency com.google.devtools.ksp to v2.0.0-1.0.22 2024-06-06 00:09:30 +00:00
FooIbar 934c8be3e4 Anti-tamper support 2024-06-04 15:13:09 +08:00
renovate[bot] b9994eacbc Lock file maintenance 2024-06-03 01:53:41 +00:00
FooIbar 704ab0ee7e Prepare for next iteration 2024-06-02 23:38:33 +08:00
187 changed files with 83818 additions and 4874 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh eol=lf
+82
View File
@@ -0,0 +1,82 @@
name: Baseline profile generation
on:
schedule:
- cron: '30 0 * * 0'
workflow_dispatch:
push:
paths:
- 'benchmark/**'
- '.github/workflows/baseline-profile.yml'
jobs:
baseline-profile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Free disk space
run: rm -rf ${ANDROID_SDK_ROOT}/ndk
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: CMake Cache
uses: actions/cache/restore@v4
with:
path: app/.cxx
key: cmake-
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Build
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Update baseline profile
if: github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config push.autoSetupRemote true
git switch -c chore/baseline-profile
if git commit -am "Update baseline profile"; then
git push
gh pr create -f
fi
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: reports
path: benchmark/build/reports
+12 -5
View File
@@ -43,11 +43,14 @@ jobs:
cache-read-only: true cache-read-only: true
- name: Install NDK - name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014" run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Spotless Check - name: Spotless Check
run: ./gradlew spotlessCheck run: ./gradlew spotlessCheck
- name: Lint Check
run: ./gradlew lintMarshmallowRelease
default: default:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -61,7 +64,7 @@ jobs:
java-version: 21 java-version: 21
- name: Install NDK - name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014" run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain - name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
@@ -87,6 +90,8 @@ jobs:
restore-keys: cmake-default- restore-keys: cmake-default-
- name: Build - name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleDefaultRelease run: ./gradlew assembleDefaultRelease
- name: Upload ARM64 - name: Upload ARM64
@@ -111,7 +116,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: default-mapping-${{ github.sha }} name: default-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultRelease/mapping.txt path: app/build/outputs/mapping/defaultRelease
- name: Upload native debug symbols - name: Upload native debug symbols
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -132,7 +137,7 @@ jobs:
java-version: 21 java-version: 21
- name: Install NDK - name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014" run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain - name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
@@ -158,6 +163,8 @@ jobs:
restore-keys: cmake-marshmallow- restore-keys: cmake-marshmallow-
- name: Build - name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleMarshmallowRelease run: ./gradlew assembleMarshmallowRelease
- name: Upload ARM64 - name: Upload ARM64
@@ -182,7 +189,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: marshmallow-mapping-${{ github.sha }} name: marshmallow-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowRelease/mapping.txt path: app/build/outputs/mapping/marshmallowRelease
- name: Upload native debug symbols - name: Upload native debug symbols
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Lock Threads'
on: on:
schedule: schedule:
- cron: '0 0 * * *' - cron: '0 8 * * *'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+3 -1
View File
@@ -19,7 +19,7 @@ jobs:
java-version: 21 java-version: 21
- name: Install NDK - name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014" run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837"
- name: Install Rust Toolchain - name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
@@ -38,6 +38,8 @@ jobs:
- name: Gradle Build - name: Gradle Build
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
with: with:
arguments: assembleRelease arguments: assembleRelease
+30
View File
@@ -1,5 +1,35 @@
# 变更日志 # 变更日志
## [1.11.6] - 2024-06-24
### 改进
* 某些设备上无法加载长图时可尝试减小 设置-高级 中的硬件位图阈值 [#1321](https://github.com/FooIbar/EhViewer/issues/1321)
* 清理下载冗余时显示确认对话框 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 修改收藏时显示收藏备注 [#1392](https://github.com/FooIbar/EhViewer/issues/1392)
### Bug 修复
* 图片完整性校验误报 [#1286](https://github.com/FooIbar/EhViewer/issues/1286)
* 某些情况下应用崩溃 [#1290](https://github.com/FooIbar/EhViewer/issues/1290) [#1302](https://github.com/FooIbar/EhViewer/issues/1302)
* 某些情况下渲染问题 [#1284](https://github.com/FooIbar/EhViewer/issues/1284)
* 无法下载标题过长的画廊 [#1340](https://github.com/FooIbar/EhViewer/issues/1340)
* 某些界面拖动滚动条时黑屏 [#1329](https://github.com/FooIbar/EhViewer/issues/1329)
* 搜索栏多行文本遮挡其他内容 [#1365](https://github.com/FooIbar/EhViewer/issues/1365)
* CIFS Documents Provider 兼容问题 [#1356](https://github.com/FooIbar/EhViewer/issues/1356)
* 清理下载冗余未按预期运行 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 读取压缩包失败时崩溃
* 某些设备上崩溃 [#1276](https://github.com/FooIbar/EhViewer/issues/1276)
* 检查更新失效
## [1.11.6-RC2] - 2024-06-06
### 改进
* 下载图片时执行完整性校验以防止文件损坏/被篡改
## [1.11.6-RC1] - 2024-06-02 ## [1.11.6-RC1] - 2024-06-02
### 改进 ### 改进
+36 -11
View File
@@ -1,3 +1,4 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
@@ -14,13 +15,15 @@ plugins {
alias(libs.plugins.spotless) alias(libs.plugins.spotless)
alias(libs.plugins.aboutlibrariesPlugin) alias(libs.plugins.aboutlibrariesPlugin)
alias(libs.plugins.composeCompilerReportGenerator) alias(libs.plugins.composeCompilerReportGenerator)
alias(libs.plugins.baselineprofile)
} }
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a") val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android { android {
compileSdk = 34 compileSdk = if (isRelease) 35 else 34
ndkVersion = "27.0.11718014-beta1" buildToolsVersion = "35.0.0"
ndkVersion = "27.0.11902837-rc1"
androidResources.generateLocaleConfig = true androidResources.generateLocaleConfig = true
splits { splits {
@@ -61,13 +64,16 @@ android {
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.') .readText().substringBefore('.')
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
?: System.getenv("GITHUB_TOKEN").orEmpty()
defaultConfig { defaultConfig {
applicationId = "moe.tarsin.ehviewer" applicationId = "moe.tarsin.ehviewer"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 35
versionCode = 180056 versionCode = 180057
versionName = "1.11.6" versionName = "1.11.6"
versionNameSuffix = "-RC1" // versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll( resourceConfigurations.addAll(
listOf( listOf(
"zh", "zh",
@@ -89,6 +95,7 @@ android {
buildConfigField("long", "COMMIT_TIME", commitTime) buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"") buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"") buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
ndk { ndk {
if (isRelease) { if (isRelease) {
abiFilters.addAll(supportedAbis) abiFilters.addAll(supportedAbis)
@@ -124,8 +131,9 @@ android {
} }
lint { lint {
checkReleaseBuilds = false
disable += setOf("MissingTranslation", "MissingQuantity") disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlinedApi") error += setOf("InlinedApi")
} }
packaging { packaging {
@@ -149,6 +157,13 @@ android {
abortOnError = false abortOnError = false
} }
} }
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
matchingFallbacks += listOf("release")
applicationIdSuffix = ".benchmark"
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
}
} }
buildFeatures { buildFeatures {
@@ -176,6 +191,10 @@ androidComponents {
} }
} }
baselineProfile {
mergeIntoMain = true
}
dependencies { dependencies {
// https://developer.android.com/jetpack/androidx/releases/activity // https://developer.android.com/jetpack/androidx/releases/activity
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -205,14 +224,14 @@ dependencies {
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerview)
implementation(libs.androidx.viewpager2)
// https://developer.android.com/jetpack/androidx/releases/room // https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging) implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.photoview) // Dead Dependency implementation(libs.photoview)
implementation(libs.directionalviewpager) // Dead Dependency
implementation(libs.material.motion.core) implementation(libs.material.motion.core)
implementation(libs.bundles.splitties) implementation(libs.bundles.splitties)
@@ -229,10 +248,11 @@ dependencies {
implementation(libs.insetter) // Dead Dependency implementation(libs.insetter) // Dead Dependency
implementation(libs.reorderable) // implementation(libs.reorderable)
implementation(platform(libs.arrow.stack)) implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines) implementation(libs.arrow.fx.coroutines)
implementation(libs.arrow.resilience)
// https://coil-kt.github.io/coil/changelog/ // https://coil-kt.github.io/coil/changelog/
implementation(platform(libs.coil.bom)) implementation(platform(libs.coil.bom))
@@ -252,6 +272,9 @@ dependencies {
implementation(libs.cronet.embedded) implementation(libs.cronet.embedded)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
} }
@@ -293,13 +316,15 @@ aboutLibraries {
duplicationRule = GROUP duplicationRule = GROUP
} }
val ktlintVersion = libs.ktlint.get().version
spotless { spotless {
kotlin { kotlin {
// https://github.com/diffplug/spotless/issues/111 // https://github.com/diffplug/spotless/issues/111
target("src/**/*.kt") target("src/**/*.kt")
ktlint() ktlint(ktlintVersion)
} }
kotlinGradle { kotlinGradle {
ktlint() ktlint(ktlintVersion)
} }
} }
+5
View File
@@ -11,6 +11,11 @@
public static final ** CREATOR; public static final ** CREATOR;
} }
# https://issuetracker.google.com/347963245
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# Ktor logger # Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder -dontwarn org.slf4j.impl.StaticLoggerBinder
-29
View File
@@ -1,29 +0,0 @@
From 2fa97f75b64c05da506968fa9a1711716f2d45a0 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:08:03 +0800
Subject: [PATCH 1/2] Hide symbols
---
CMakeLists.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ec97e4c7..4d859341 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2129,10 +2129,10 @@ SET(OLD_CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS}")
SET(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -fvisibility=hidden -Werror")
CHECK_C_SOURCE_COMPILES("void __attribute__((visibility(\"default\"))) foo(void);
int main() { return 0; }" HAVE_VISIBILITY_ATTR)
-IF (HAVE_VISIBILITY_ATTR)
+IF (0)
SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
ADD_DEFINITIONS(-D__LIBARCHIVE_ENABLE_VISIBILITY)
-ENDIF(HAVE_VISIBILITY_ATTR)
+ENDIF(0)
SET(CMAKE_REQUIRED_FLAGS "${OLD_CMAKE_REQUIRED_FLAGS}")
# Generate "config.h" from "build/cmake/config.h.in"
--
2.34.1
@@ -1,14 +1,14 @@
From 2dcc8be937499022c37678eb6bf4bd026b6fcbb0 Mon Sep 17 00:00:00 2001 From aee8f7147ddb262fd9e5feee8d5b17094cf3470f Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com> From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:09:25 +0800 Date: Tue, 26 Sep 2023 00:09:25 +0800
Subject: [PATCH 2/2] Insert link libs Subject: [PATCH] Insert link libs
--- ---
CMakeLists.txt | 2 +- CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-) 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4d859341..1cd1cd47 100644 index ec97e4c7..420f204c 100644
--- a/CMakeLists.txt --- a/CMakeLists.txt
+++ b/CMakeLists.txt +++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}") @@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
+33 -14
View File
@@ -1,5 +1,6 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(ehviewer C) project(ehviewer C)
include(ExternalProject)
include(FetchContent) include(FetchContent)
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
@@ -18,31 +19,47 @@ endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
option(BUILD_TESTING OFF) option(BUILD_TESTING OFF)
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
FetchContent_Declare( FetchContent_Declare(
liblzma liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.2 GIT_TAG v5.6.2
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd . PATCH_COMMAND git apply --check -R ${XZ_PATCH} || git apply ${XZ_PATCH}
) )
FetchContent_MakeAvailable(liblzma) FetchContent_MakeAvailable(liblzma)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api) include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle # Build GNUTLS libnettle
FetchContent_Declare( if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
execute_process(
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
OUTPUT_VARIABLE CURRENT_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE
)
else ()
set(CURRENT_DIR ${CMAKE_CURRENT_LIST_DIR})
endif ()
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
ExternalProject_Add(
nettle nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz PREFIX nettle
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
SOURCE_DIR nettle-src/nettle URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
) )
FetchContent_MakeAvailable(nettle) ExternalProject_Get_Property(nettle SOURCE_DIR)
set(NETTLE_DIR ${nettle_SOURCE_DIR}) include_directories(${SOURCE_DIR}/..)
add_subdirectory(nettle) add_library(libnettle STATIC IMPORTED)
set_property(TARGET libnettle PROPERTY IMPORTED_LOCATION ${SOURCE_DIR}/libnettle.a)
add_dependencies(libnettle nettle)
# Configure libnettle support for libarchive # Configure libnettle support for libarchive
include_directories(${CMAKE_CURRENT_BINARY_DIR}/nettle-src)
include_directories(.)
set(HAVE_LIBNETTLE 1) set(HAVE_LIBNETTLE 1)
set(HAVE_NETTLE_AES_H 1) set(HAVE_NETTLE_AES_H 1)
set(HAVE_NETTLE_HMAC_H 1) set(HAVE_NETTLE_HMAC_H 1)
@@ -58,6 +75,7 @@ SET(HAVE_LZMA_STREAM_ENCODER_MT 1)
SET(HAVE_LZMADEC_H 1) SET(HAVE_LZMADEC_H 1)
SET(HAVE_LIBLZMADEC 1) SET(HAVE_LIBLZMADEC 1)
option(ENABLE_OPENSSL OFF)
option(ENABLE_TAR OFF) option(ENABLE_TAR OFF)
option(ENABLE_CPIO OFF) option(ENABLE_CPIO OFF)
option(ENABLE_CAT OFF) option(ENABLE_CAT OFF)
@@ -65,13 +83,14 @@ option(ENABLE_UNZIP OFF)
option(ENABLE_TEST OFF) option(ENABLE_TEST OFF)
# Configure libarchive link's static lib # Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma") SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
FetchContent_Declare( FetchContent_Declare(
libarchive libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.4 GIT_TAG v3.7.4
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Hide-symbols.patch ${CMAKE_CURRENT_LIST_DIR}/0002-Insert-link-libs.patch || cd . PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
) )
FetchContent_MakeAvailable(libarchive) FetchContent_MakeAvailable(libarchive)
@@ -88,5 +107,5 @@ corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384") corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
# Build and link our app's native lib # Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c) add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics) target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
+2 -1
View File
@@ -49,7 +49,7 @@ typedef struct {
} entry; } entry;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool; static archive_ctx **ctx_pool = NULL;
#define CTX_POOL_SIZE 20 #define CTX_POOL_SIZE 20
static void *mempool = NULL; static void *mempool = NULL;
@@ -403,6 +403,7 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) { Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env); EH_UNUSED(env);
EH_UNUSED(thiz); EH_UNUSED(thiz);
if (!ctx_pool) return;
for (int i = 0; i < CTX_POOL_SIZE; i++) for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]); archive_release_ctx(ctx_pool[i]);
free(passwd); free(passwd);
+6
View File
@@ -0,0 +1,6 @@
TOOLCHAIN=$ANDROID_HOME/ndk/$1/toolchains/llvm/prebuilt/$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64/bin
TARGET=$2
./configure --host=$TARGET --disable-public-key --disable-dependency-tracking \
AR=$TOOLCHAIN/llvm-ar \
CC="$TOOLCHAIN/clang -target $TARGET" \
RANLIB=$TOOLCHAIN/llvm-ranlib
+39
View File
@@ -0,0 +1,39 @@
#include <unistd.h>
#include <jni.h>
#include <nettle/sha1.h>
#include "ehviewer.h"
#define BUFFER_SIZE 8192
typedef uint8_t byte;
const char hex_digits[] = "0123456789abcdef";
JNIEXPORT jstring JNICALL
Java_com_hippo_ehviewer_jni_HashKt_sha1(JNIEnv *env, jclass clazz, jint fd) {
EH_UNUSED(clazz);
struct sha1_ctx ctx;
sha1_init(&ctx);
size_t bytes_read;
byte buffer[BUFFER_SIZE];
while ((bytes_read = read(fd, buffer, BUFFER_SIZE)) > 0) {
sha1_update(&ctx, bytes_read, buffer);
}
byte digest[SHA1_DIGEST_SIZE];
sha1_digest(&ctx, SHA1_DIGEST_SIZE, digest);
byte byte;
char hex_digest[2 * SHA1_DIGEST_SIZE + 1];
for (int i = 0; i < SHA1_DIGEST_SIZE; i++) {
byte = digest[i];
hex_digest[2 * i] = hex_digits[byte >> 4 & 0xF];
hex_digest[2 * i + 1] = hex_digits[byte & 0xF];
}
hex_digest[2 * SHA1_DIGEST_SIZE] = '\0';
return (*env)->NewStringUTF(env, hex_digest);
}
-238
View File
@@ -1,238 +0,0 @@
cmake_minimum_required(VERSION 3.4.1)
project(nettle C)
set(LIBNETTLE_DEFINITIONS
-DHAVE_CONFIG_H)
set(LIBNETTLE_SOURCES
${NETTLE_DIR}/aes-decrypt-internal.c
${NETTLE_DIR}/aes-decrypt.c
${NETTLE_DIR}/aes-decrypt-table.c
${NETTLE_DIR}/aes128-decrypt.c
${NETTLE_DIR}/aes192-decrypt.c
${NETTLE_DIR}/aes256-decrypt.c
${NETTLE_DIR}/aes-encrypt-internal.c
${NETTLE_DIR}/aes-encrypt.c
${NETTLE_DIR}/aes-encrypt-table.c
${NETTLE_DIR}/aes128-encrypt.c
${NETTLE_DIR}/aes192-encrypt.c
${NETTLE_DIR}/aes256-encrypt.c
${NETTLE_DIR}/aes-invert-internal.c
${NETTLE_DIR}/aes-set-key-internal.c
${NETTLE_DIR}/aes-set-encrypt-key.c
${NETTLE_DIR}/aes-set-decrypt-key.c
${NETTLE_DIR}/aes128-set-encrypt-key.c
${NETTLE_DIR}/aes128-set-decrypt-key.c
${NETTLE_DIR}/aes128-meta.c
${NETTLE_DIR}/aes192-set-encrypt-key.c
${NETTLE_DIR}/aes192-set-decrypt-key.c
${NETTLE_DIR}/aes192-meta.c
${NETTLE_DIR}/aes256-set-encrypt-key.c
${NETTLE_DIR}/aes256-set-decrypt-key.c
${NETTLE_DIR}/aes256-meta.c
${NETTLE_DIR}/nist-keywrap.c
${NETTLE_DIR}/arcfour.c
${NETTLE_DIR}/arctwo.c
${NETTLE_DIR}/arctwo-meta.c
${NETTLE_DIR}/blowfish.c
${NETTLE_DIR}/blowfish-bcrypt.c
${NETTLE_DIR}/base16-encode.c
${NETTLE_DIR}/base16-decode.c
${NETTLE_DIR}/base16-meta.c
${NETTLE_DIR}/base64-encode.c
${NETTLE_DIR}/base64-decode.c
${NETTLE_DIR}/base64-meta.c
${NETTLE_DIR}/base64url-encode.c
${NETTLE_DIR}/base64url-decode.c
${NETTLE_DIR}/base64url-meta.c
${NETTLE_DIR}/buffer.c
${NETTLE_DIR}/buffer-init.c
${NETTLE_DIR}/camellia-crypt-internal.c
${NETTLE_DIR}/camellia-table.c
${NETTLE_DIR}/camellia-absorb.c
${NETTLE_DIR}/camellia-invert-key.c
${NETTLE_DIR}/camellia128-set-encrypt-key.c
${NETTLE_DIR}/camellia128-crypt.c
${NETTLE_DIR}/camellia128-set-decrypt-key.c
${NETTLE_DIR}/camellia128-meta.c
${NETTLE_DIR}/camellia192-meta.c
${NETTLE_DIR}/camellia256-set-encrypt-key.c
${NETTLE_DIR}/camellia256-crypt.c
${NETTLE_DIR}/camellia256-set-decrypt-key.c
${NETTLE_DIR}/camellia256-meta.c
${NETTLE_DIR}/cast128.c
${NETTLE_DIR}/cast128-meta.c
${NETTLE_DIR}/cbc.c
${NETTLE_DIR}/cbc-aes128-encrypt.c
${NETTLE_DIR}/cbc-aes192-encrypt.c
${NETTLE_DIR}/cbc-aes256-encrypt.c
${NETTLE_DIR}/ccm.c
${NETTLE_DIR}/ccm-aes128.c
${NETTLE_DIR}/ccm-aes192.c
${NETTLE_DIR}/ccm-aes256.c
${NETTLE_DIR}/cfb.c
${NETTLE_DIR}/siv-cmac.c
${NETTLE_DIR}/siv-cmac-aes128.c
${NETTLE_DIR}/siv-cmac-aes256.c
${NETTLE_DIR}/cnd-memcpy.c
${NETTLE_DIR}/chacha-crypt.c
${NETTLE_DIR}/chacha-core-internal.c
${NETTLE_DIR}/chacha-poly1305.c
${NETTLE_DIR}/chacha-poly1305-meta.c
${NETTLE_DIR}/chacha-set-key.c
${NETTLE_DIR}/chacha-set-nonce.c
${NETTLE_DIR}/ctr.c
${NETTLE_DIR}/ctr16.c
${NETTLE_DIR}/des.c
${NETTLE_DIR}/des3.c
${NETTLE_DIR}/eax.c
${NETTLE_DIR}/eax-aes128.c
${NETTLE_DIR}/eax-aes128-meta.c
${NETTLE_DIR}/ghash-set-key.c
${NETTLE_DIR}/ghash-update.c
${NETTLE_DIR}/gcm.c
${NETTLE_DIR}/gcm-aes.c
${NETTLE_DIR}/gcm-aes128.c
${NETTLE_DIR}/gcm-aes128-meta.c
${NETTLE_DIR}/gcm-aes192.c
${NETTLE_DIR}/gcm-aes192-meta.c
${NETTLE_DIR}/gcm-aes256.c
${NETTLE_DIR}/gcm-aes256-meta.c
${NETTLE_DIR}/gcm-camellia128.c
${NETTLE_DIR}/gcm-camellia128-meta.c
${NETTLE_DIR}/gcm-camellia256.c
${NETTLE_DIR}/gcm-camellia256-meta.c
${NETTLE_DIR}/cmac.c
${NETTLE_DIR}/cmac64.c
${NETTLE_DIR}/cmac-aes128.c
${NETTLE_DIR}/cmac-aes256.c
${NETTLE_DIR}/cmac-des3.c
${NETTLE_DIR}/cmac-aes128-meta.c
${NETTLE_DIR}/cmac-aes256-meta.c
${NETTLE_DIR}/cmac-des3-meta.c
${NETTLE_DIR}/gost28147.c
${NETTLE_DIR}/gosthash94.c
${NETTLE_DIR}/gosthash94-meta.c
${NETTLE_DIR}/hmac.c
${NETTLE_DIR}/hmac-gosthash94.c
${NETTLE_DIR}/hmac-md5.c
${NETTLE_DIR}/hmac-ripemd160.c
${NETTLE_DIR}/hmac-sha1.c
${NETTLE_DIR}/hmac-sha224.c
${NETTLE_DIR}/hmac-sha256.c
${NETTLE_DIR}/hmac-sha384.c
${NETTLE_DIR}/hmac-sha512.c
${NETTLE_DIR}/hmac-streebog.c
${NETTLE_DIR}/hmac-sm3.c
${NETTLE_DIR}/hmac-md5-meta.c
${NETTLE_DIR}/hmac-ripemd160-meta.c
${NETTLE_DIR}/hmac-sha1-meta.c
${NETTLE_DIR}/hmac-sha224-meta.c
${NETTLE_DIR}/hmac-sha256-meta.c
${NETTLE_DIR}/hmac-sha384-meta.c
${NETTLE_DIR}/hmac-sha512-meta.c
${NETTLE_DIR}/hmac-streebog-meta.c
${NETTLE_DIR}/hmac-sm3-meta.c
${NETTLE_DIR}/knuth-lfib.c
${NETTLE_DIR}/hkdf.c
${NETTLE_DIR}/md2.c
${NETTLE_DIR}/md2-meta.c
${NETTLE_DIR}/md4.c
${NETTLE_DIR}/md4-meta.c
${NETTLE_DIR}/md5.c
${NETTLE_DIR}/md5-compress.c
${NETTLE_DIR}/md5-compat.c
${NETTLE_DIR}/md5-meta.c
${NETTLE_DIR}/memeql-sec.c
${NETTLE_DIR}/memxor.c
${NETTLE_DIR}/memxor3.c
${NETTLE_DIR}/nettle-lookup-hash.c
${NETTLE_DIR}/nettle-meta-aeads.c
${NETTLE_DIR}/nettle-meta-armors.c
${NETTLE_DIR}/nettle-meta-ciphers.c
${NETTLE_DIR}/nettle-meta-hashes.c
${NETTLE_DIR}/nettle-meta-macs.c
${NETTLE_DIR}/pbkdf2.c
${NETTLE_DIR}/pbkdf2-hmac-gosthash94.c
${NETTLE_DIR}/pbkdf2-hmac-sha1.c
${NETTLE_DIR}/pbkdf2-hmac-sha256.c
${NETTLE_DIR}/pbkdf2-hmac-sha384.c
${NETTLE_DIR}/pbkdf2-hmac-sha512.c
${NETTLE_DIR}/poly1305-aes.c
${NETTLE_DIR}/poly1305-internal.c
${NETTLE_DIR}/realloc.c
${NETTLE_DIR}/ripemd160.c
${NETTLE_DIR}/ripemd160-compress.c
${NETTLE_DIR}/ripemd160-meta.c
${NETTLE_DIR}/salsa20-core-internal.c
${NETTLE_DIR}/salsa20-crypt-internal.c
${NETTLE_DIR}/salsa20-crypt.c
${NETTLE_DIR}/salsa20r12-crypt.c
${NETTLE_DIR}/salsa20-set-key.c
${NETTLE_DIR}/salsa20-set-nonce.c
${NETTLE_DIR}/salsa20-128-set-key.c
${NETTLE_DIR}/salsa20-256-set-key.c
${NETTLE_DIR}/sha1.c
${NETTLE_DIR}/sha1-compress.c
${NETTLE_DIR}/sha1-meta.c
${NETTLE_DIR}/sha256.c
${NETTLE_DIR}/sha256-compress-n.c
${NETTLE_DIR}/sha224-meta.c
${NETTLE_DIR}/sha256-meta.c
${NETTLE_DIR}/sha512.c
${NETTLE_DIR}/sha512-compress.c
${NETTLE_DIR}/sha384-meta.c
${NETTLE_DIR}/sha512-meta.c
${NETTLE_DIR}/sha512-224-meta.c
${NETTLE_DIR}/sha512-256-meta.c
${NETTLE_DIR}/sha3.c
${NETTLE_DIR}/sha3-permute.c
${NETTLE_DIR}/sha3-224.c
${NETTLE_DIR}/sha3-224-meta.c
${NETTLE_DIR}/sha3-256.c
${NETTLE_DIR}/sha3-256-meta.c
${NETTLE_DIR}/sha3-384.c
${NETTLE_DIR}/sha3-384-meta.c
${NETTLE_DIR}/sha3-512.c
${NETTLE_DIR}/sha3-512-meta.c
${NETTLE_DIR}/shake256.c
${NETTLE_DIR}/sm3.c
${NETTLE_DIR}/sm3-meta.c
${NETTLE_DIR}/serpent-set-key.c
${NETTLE_DIR}/serpent-encrypt.c
${NETTLE_DIR}/serpent-decrypt.c
${NETTLE_DIR}/serpent-meta.c
${NETTLE_DIR}/streebog.c
${NETTLE_DIR}/streebog-meta.c
${NETTLE_DIR}/twofish.c
${NETTLE_DIR}/twofish-meta.c
${NETTLE_DIR}/umac-nh.c
${NETTLE_DIR}/umac-nh-n.c
${NETTLE_DIR}/umac-l2.c
${NETTLE_DIR}/umac-l3.c
${NETTLE_DIR}/umac-poly64.c
${NETTLE_DIR}/umac-poly128.c
${NETTLE_DIR}/umac-set-key.c
${NETTLE_DIR}/umac32.c
${NETTLE_DIR}/umac64.c
${NETTLE_DIR}/umac96.c
${NETTLE_DIR}/umac128.c
${NETTLE_DIR}/version.c
${NETTLE_DIR}/write-be32.c
${NETTLE_DIR}/write-le32.c
${NETTLE_DIR}/write-le64.c
${NETTLE_DIR}/yarrow256.c
${NETTLE_DIR}/yarrow_key_event.c
${NETTLE_DIR}/xts.c
${NETTLE_DIR}/xts-aes128.c
${NETTLE_DIR}/xts-aes256.c
)
set(LIBNETTLE_INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}
${NETTLE_DIR}
)
add_library(nettle STATIC ${LIBNETTLE_SOURCES})
target_include_directories(nettle PUBLIC ${LIBNETTLE_INCLUDES})
target_compile_definitions(nettle PRIVATE ${LIBNETTLE_DEFINITIONS})
-280
View File
@@ -1,280 +0,0 @@
/* config.h. Generated from config.h.in by configure. */
/* config.h.in. Generated from configure.ac by autoheader. */
/* Define if building universal (internal helper macro) */
/* #undef AC_APPLE_UNIVERSAL_BUILD */
/* Define to one of `_getb67', `GETB67', `getb67' for Cray-2 and Cray-YMP
systems. This function is required for `alloca.c' support on those systems.
*/
/* #undef CRAY_STACKSEG_END */
/* Define to 1 if using `alloca.c'. */
/* #undef C_ALLOCA */
/* Define to 1 if you have `alloca', as a function or macro. */
#define HAVE_ALLOCA 1
/* Define to 1 if you have <alloca.h> and it should be used (not on Ultrix).
*/
#define HAVE_ALLOCA_H 1
/* Define if __builtin_bswap64 is available */
#define HAVE_BUILTIN_BSWAP64 1
/* Define if clock_gettime is available */
#define HAVE_CLOCK_GETTIME 1
/* Define to 1 if you have the <dlfcn.h> header file. */
#define HAVE_DLFCN_H 1
/* Define if fcntl file locking is available */
#define HAVE_FCNTL_LOCKING 1
/* Define if the compiler understands __attribute__ */
#define HAVE_GCC_ATTRIBUTE 1
/* Define to 1 if you have the `getline' function. */
#define HAVE_GETLINE 1
/* Define to 1 if you have the <inttypes.h> header file. */
#define HAVE_INTTYPES_H 1
/* Define to 1 if you have dlopen (with -ldl). */
#define HAVE_LIBDL 1
/* Define to 1 if you have the `gmp' library (-lgmp). */
/* #undef HAVE_LIBGMP */
/* Define if compiler and linker supports __attribute__ ifunc */
#define HAVE_LINK_IFUNC 1
/* Define to 1 if you have the <malloc.h> header file. */
#define HAVE_MALLOC_H 1
/* Define to 1 if you have the <memory.h> header file. */
#define HAVE_MEMORY_H 1
/* Define to 1 each of the following for which a native (ie. CPU specific)
implementation of the corresponding routine exists. */
/* #undef HAVE_NATIVE_memxor3 */
/* #undef HAVE_NATIVE_aes_decrypt */
/* #undef HAVE_NATIVE_aes_encrypt */
/* #undef HAVE_NATIVE_aes128_decrypt */
/* #undef HAVE_NATIVE_aes128_encrypt */
/* #undef HAVE_NATIVE_aes128_invert_key */
/* #undef HAVE_NATIVE_aes128_set_decrypt_key */
/* #undef HAVE_NATIVE_aes128_set_encrypt_key */
/* #undef HAVE_NATIVE_aes192_decrypt */
/* #undef HAVE_NATIVE_aes192_encrypt */
/* #undef HAVE_NATIVE_aes192_invert_key */
/* #undef HAVE_NATIVE_aes192_set_decrypt_key */
/* #undef HAVE_NATIVE_aes192_set_encrypt_key */
/* #undef HAVE_NATIVE_aes256_decrypt */
/* #undef HAVE_NATIVE_aes256_encrypt */
/* #undef HAVE_NATIVE_aes256_invert_key */
/* #undef HAVE_NATIVE_aes256_set_decrypt_key */
/* #undef HAVE_NATIVE_aes256_set_encrypt_key */
/* #undef HAVE_NATIVE_cbc_aes128_encrypt */
/* #undef HAVE_NATIVE_cbc_aes192_encrypt */
/* #undef HAVE_NATIVE_cbc_aes256_encrypt */
/* #undef HAVE_NATIVE_chacha_core */
/* #undef HAVE_NATIVE_chacha_2core */
#define HAVE_NATIVE_chacha_3core 1
/* #undef HAVE_NATIVE_chacha_4core */
/* #undef HAVE_NATIVE_fat_chacha_2core */
/* #undef HAVE_NATIVE_fat_chacha_3core */
/* #undef HAVE_NATIVE_fat_chacha_4core */
/* #undef HAVE_NATIVE_ecc_curve25519_modp */
/* #undef HAVE_NATIVE_ecc_curve448_modp */
/* #undef HAVE_NATIVE_ecc_secp192r1_modp */
/* #undef HAVE_NATIVE_ecc_secp192r1_redc */
/* #undef HAVE_NATIVE_ecc_secp224r1_modp */
/* #undef HAVE_NATIVE_ecc_secp224r1_redc */
/* #undef HAVE_NATIVE_ecc_secp256r1_modp */
/* #undef HAVE_NATIVE_ecc_secp256r1_redc */
/* #undef HAVE_NATIVE_ecc_secp384r1_modp */
/* #undef HAVE_NATIVE_ecc_secp384r1_redc */
/* #undef HAVE_NATIVE_ecc_secp521r1_modp */
/* #undef HAVE_NATIVE_ecc_secp521r1_redc */
/* #undef HAVE_NATIVE_poly1305_set_key */
/* #undef HAVE_NATIVE_poly1305_block */
/* #undef HAVE_NATIVE_poly1305_digest */
/* #undef HAVE_NATIVE_poly1305_blocks */
/* #undef HAVE_NATIVE_fat_poly1305_blocks */
/* #undef HAVE_NATIVE_ghash_set_key */
/* #undef HAVE_NATIVE_ghash_update */
/* #undef HAVE_NATIVE_salsa20_core */
#define HAVE_NATIVE_salsa20_2core 1
/* #undef HAVE_NATIVE_fat_salsa20_2core */
/* #undef HAVE_NATIVE_sha1_compress */
/* #undef HAVE_NATIVE_sha256_compress_n */
/* #undef HAVE_NATIVE_sha512_compress */
/* #undef HAVE_NATIVE_sha3_permute */
/* #undef HAVE_NATIVE_umac_nh */
/* #undef HAVE_NATIVE_umac_nh_n */
/* Define to 1 if you have the <openssl/ecdsa.h> header file. */
/* #undef HAVE_OPENSSL_ECDSA_H */
/* Define to 1 if you have the <openssl/evp.h> header file. */
/* #undef HAVE_OPENSSL_EVP_H */
/* Define to 1 if you have the `secure_getenv' function. */
#define HAVE_SECURE_GETENV 1
/* Define to 1 if you have the <stdint.h> header file. */
#define HAVE_STDINT_H 1
/* Define to 1 if you have the <stdlib.h> header file. */
#define HAVE_STDLIB_H 1
/* Define to 1 if you have the `strerror' function. */
#define HAVE_STRERROR 1
/* Define to 1 if you have the <strings.h> header file. */
#define HAVE_STRINGS_H 1
/* Define to 1 if you have the <string.h> header file. */
#define HAVE_STRING_H 1
/* Define to 1 if you have the <sys/stat.h> header file. */
#define HAVE_SYS_STAT_H 1
/* Define to 1 if you have the <sys/types.h> header file. */
#define HAVE_SYS_TYPES_H 1
/* Define to 1 if you have the <unistd.h> header file. */
#define HAVE_UNISTD_H 1
/* Define to 1 if you have the <valgrind/memcheck.h> header file. */
/* #undef HAVE_VALGRIND_MEMCHECK_H */
/* Define to the address where bug reports for this package should be sent. */
#define PACKAGE_BUGREPORT "nettle-bugs@lists.lysator.liu.se"
/* Define to the full name of this package. */
#define PACKAGE_NAME "nettle"
/* Define to the full name and version of this package. */
#define PACKAGE_STRING "nettle 3.9.1"
/* Define to the one symbol short name of this package. */
#define PACKAGE_TARNAME "nettle"
/* Define to the home page for this package. */
#define PACKAGE_URL ""
/* Define to the version of this package. */
#define PACKAGE_VERSION "3.9.1"
/* The size of `long', as computed by sizeof. */
#define SIZEOF_LONG __SIZEOF_LONG__
/* The size of `size_t', as computed by sizeof. */
#define SIZEOF_SIZE_T __SIZEOF_SIZE_T__
/* If using the C implementation of alloca, define if you know the
direction of stack growth for your system; otherwise it will be
automatically deduced at runtime.
STACK_DIRECTION > 0 => grows toward higher addresses
STACK_DIRECTION < 0 => grows toward lower addresses
STACK_DIRECTION = 0 => direction of growth unknown */
/* #undef STACK_DIRECTION */
/* Define to 1 if you have the ANSI C header files. */
#define STDC_HEADERS 1
/* Define to 1 if you can safely include both <sys/time.h> and <time.h>. */
#define TIME_WITH_SYS_TIME 1
/* Defined if public key features are enabled */
/* #undef WITH_HOGWEED */
/* Define if you have openssl's libcrypto (used for benchmarking) */
/* #undef WITH_OPENSSL */
/* Define WORDS_BIGENDIAN to 1 if your processor stores words with the most
significant byte first (like Motorola and SPARC, unlike Intel). */
#if defined AC_APPLE_UNIVERSAL_BUILD
# if defined __BIG_ENDIAN__
# define WORDS_BIGENDIAN 1
# endif
#else
# ifndef WORDS_BIGENDIAN
/* # undef WORDS_BIGENDIAN */
# endif
#endif
/* Define to empty if `const' does not conform to ANSI C. */
/* #undef const */
/* Define to `int' if <sys/types.h> doesn't define. */
/* #undef gid_t */
/* Define to `__inline__' or `__inline' if that's what the C compiler
calls it, or to nothing if 'inline' is not supported under any name. */
#ifndef __cplusplus
/* #undef inline */
#endif
/* Define to `unsigned int' if <sys/types.h> does not define. */
/* #undef size_t */
/* Define to `int' if <sys/types.h> doesn't define. */
/* #undef uid_t */
/* AIX requires this to be the first thing in the file. */
#ifndef __GNUC__
# if HAVE_ALLOCA_H
# include <alloca.h>
# else
# ifdef _AIX
#pragma alloca
# else
# ifndef alloca /* predefined by HP cc +Olibcalls */
char *alloca ();
# endif
# endif
/* Needed for alloca on windows */
# if HAVE_MALLOC_H
# include <malloc.h>
# endif
# endif
#else /* defined __GNUC__ */
# if HAVE_ALLOCA_H
# include <alloca.h>
# else
/* Needed for alloca on windows, also with gcc */
# if HAVE_MALLOC_H
# include <malloc.h>
# endif
# endif
#endif
#if HAVE_STRERROR
#define STRERROR strerror
#else
#define STRERROR(x) (sys_errlist[x])
#endif
#if __GNUC__ && HAVE_GCC_ATTRIBUTE
# define NORETURN __attribute__ ((__noreturn__))
# define PRINTF_STYLE(f, a) __attribute__ ((__format__ (__printf__, f, a)))
# define UNUSED __attribute__ ((__unused__))
#else
# define NORETURN
# define PRINTF_STYLE(f, a)
# define UNUSED
#endif
#if defined(__x86_64__) || defined(__arch64__)
# define HAVE_NATIVE_64_BIT 1
#else
/* Needs include of <limits.h> before use. */
# define HAVE_NATIVE_64_BIT (SIZEOF_LONG * CHAR_BIT >= 64)
#endif
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-64
View File
@@ -1,64 +0,0 @@
/* version.h
Information about library version.
Copyright (C) 2015 Red Hat, Inc.
Copyright (C) 2015 Niels Möller
This file is part of GNU Nettle.
GNU Nettle is free software: you can redistribute it and/or
modify it under the terms of either:
* the GNU Lesser General Public License as published by the Free
Software Foundation; either version 3 of the License, or (at your
option) any later version.
or
* the GNU General Public License as published by the Free
Software Foundation; either version 2 of the License, or (at your
option) any later version.
or both in parallel, as here.
GNU Nettle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received copies of the GNU General Public License and
the GNU Lesser General Public License along with this program. If
not, see http://www.gnu.org/licenses/.
*/
#ifndef NETTLE_VERSION_H_INCLUDED
#define NETTLE_VERSION_H_INCLUDED
#ifdef __cplusplus
extern "C" {
#endif
/* Individual version numbers in decimal */
#define NETTLE_VERSION_MAJOR 3
#define NETTLE_VERSION_MINOR 9
#define NETTLE_USE_MINI_GMP 0
/* We need a preprocessor constant for GMP_NUMB_BITS, simply using
sizeof(mp_limb_t) * CHAR_BIT is not good enough. */
#if NETTLE_USE_MINI_GMP
# define GMP_NUMB_BITS n/a
#endif
int
nettle_version_major (void);
int
nettle_version_minor (void);
#ifdef __cplusplus
}
#endif
#endif /* NETTLE_VERSION_H_INCLUDED */
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -16,8 +16,14 @@
package androidx.compose.foundation.gestures package androidx.compose.foundation.gestures
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.FloatDecayAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange
@@ -27,15 +33,23 @@ import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireLayoutDirection import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ExperimentalFoundationApi @ExperimentalFoundationApi
@@ -45,31 +59,31 @@ fun <T> Modifier.anchoredHorizontalDraggable(
enableDragFromEndToStart: Boolean = true, enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true, enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning, startDragImmediately: Boolean = state.isAnimationRunning,
flingBehavior: FlingBehavior? = null,
): Modifier = this then AnchoredHorizontalDraggableElement( ): Modifier = this then AnchoredHorizontalDraggableElement(
state = state, state = state,
canDrag = { (x, y) -> canDrag = { (x, y) ->
if (x > 0) { val enableDrag = if (x > 0) enableDragFromStartToEnd else enableDragFromEndToStart
enableDragFromStartToEnd enableDrag && x.absoluteValue > y.absoluteValue * 2f
} else {
enableDragFromEndToStart
} && x.absoluteValue > y.absoluteValue * 2f
}, },
enabled = enabled, enabled = enabled,
startDragImmediately = startDragImmediately, startDragImmediately = startDragImmediately,
flingBehavior = flingBehavior,
) )
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableElement<T>( private class AnchoredHorizontalDraggableElement<T>(
private val state: AnchoredDraggableState<T>, private val state: AnchoredDraggableState<T>,
private val canDrag: (Offset) -> Boolean, private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean, private val enabled: Boolean,
private val startDragImmediately: Boolean, private val startDragImmediately: Boolean,
private val flingBehavior: FlingBehavior?,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() { ) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode( override fun create() = AnchoredHorizontalDraggableNode(
state, state,
canDrag, canDrag,
enabled, enabled,
startDragImmediately, startDragImmediately,
flingBehavior,
) )
override fun update(node: AnchoredHorizontalDraggableNode<T>) { override fun update(node: AnchoredHorizontalDraggableNode<T>) {
@@ -78,6 +92,7 @@ private class AnchoredHorizontalDraggableElement<T>(
canDrag, canDrag,
enabled, enabled,
startDragImmediately, startDragImmediately,
flingBehavior,
) )
} }
@@ -86,6 +101,7 @@ private class AnchoredHorizontalDraggableElement<T>(
result = 31 * result + canDrag.hashCode() result = 31 * result + canDrag.hashCode()
result = 31 * result + enabled.hashCode() result = 31 * result + enabled.hashCode()
result = 31 * result + startDragImmediately.hashCode() result = 31 * result + startDragImmediately.hashCode()
result = 31 * result + flingBehavior.hashCode()
return result return result
} }
@@ -98,6 +114,7 @@ private class AnchoredHorizontalDraggableElement<T>(
if (canDrag != other.canDrag) return false if (canDrag != other.canDrag) return false
if (enabled != other.enabled) return false if (enabled != other.enabled) return false
if (startDragImmediately != other.startDragImmediately) return false if (startDragImmediately != other.startDragImmediately) return false
if (flingBehavior != other.flingBehavior) return false
return true return true
} }
@@ -108,26 +125,56 @@ private class AnchoredHorizontalDraggableElement<T>(
properties["canDrag"] = canDrag properties["canDrag"] = canDrag
properties["enabled"] = enabled properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately properties["startDragImmediately"] = startDragImmediately
properties["flingBehavior"] = flingBehavior
} }
} }
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableNode<T>( private class AnchoredHorizontalDraggableNode<T>(
private var state: AnchoredDraggableState<T>, private var state: AnchoredDraggableState<T>,
private var canDrag: (Offset) -> Boolean, private var canDrag: (Offset) -> Boolean,
enabled: Boolean, enabled: Boolean,
private var startDragImmediately: Boolean, private var startDragImmediately: Boolean,
) : HorizontalDragGestureNode( private var flingBehavior: FlingBehavior?,
enabled = enabled, ) : HorizontalDragGestureNode(enabled = enabled),
) { ObserverModifierNode {
lateinit var resolvedFlingBehavior: FlingBehavior
lateinit var density: Density
private val isReverseDirection: Boolean private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun canDrag(change: PointerInputChange): Boolean { override fun onAttach() {
return canDrag(change.positionChange().reverseIfNeeded()) updateFlingBehavior(flingBehavior)
} }
override fun onObservedReadsChanged() {
val newDensity = currentValueOf(LocalDensity)
if (density != newDensity) {
density = newDensity
updateFlingBehavior(flingBehavior)
}
}
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
// Fall back to default fling behavior if the new fling behavior is null
this.resolvedFlingBehavior =
if (newFlingBehavior == null) {
// Only register for LocalDensity snapshot updates if we are creating a decay
observeReads { density = currentValueOf(LocalDensity) }
anchoredDraggableFlingBehavior(
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
density = density,
state = state,
)
} else {
newFlingBehavior
}
}
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) { override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
state.anchoredDrag { state.anchoredDrag {
forEachDelta { dragDelta -> forEachDelta { dragDelta ->
@@ -141,10 +188,29 @@ private class AnchoredHorizontalDraggableNode<T>(
override fun onDragStopped(velocity: Velocity) { override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return if (!isAttached) return
coroutineScope.launch { coroutineScope.launch {
state.settle(velocity.reverseIfNeeded().x) fling(velocity.reverseIfNeeded().x)
} }
} }
private suspend fun fling(velocity: Float): Float {
var leftoverVelocity = velocity
state.anchoredDrag {
val scrollScope =
object : ScrollScope {
override fun scrollBy(pixels: Float): Float {
val newOffset = state.newOffsetForDelta(pixels)
val consumed = newOffset - state.offset
dragTo(newOffset)
return consumed
}
}
with(resolvedFlingBehavior) {
leftoverVelocity = scrollScope.performFling(velocity)
}
}
return leftoverVelocity
}
override fun startDragImmediately(): Boolean = startDragImmediately override fun startDragImmediately(): Boolean = startDragImmediately
fun update( fun update(
@@ -152,11 +218,15 @@ private class AnchoredHorizontalDraggableNode<T>(
canDrag: (Offset) -> Boolean, canDrag: (Offset) -> Boolean,
enabled: Boolean, enabled: Boolean,
startDragImmediately: Boolean, startDragImmediately: Boolean,
flingBehavior: FlingBehavior?,
) { ) {
this.flingBehavior = flingBehavior
var resetPointerInputHandling = false var resetPointerInputHandling = false
if (this.state != state) { if (this.state != state) {
this.state = state this.state = state
updateFlingBehavior(flingBehavior)
resetPointerInputHandling = true resetPointerInputHandling = true
} }
@@ -175,7 +245,118 @@ private class AnchoredHorizontalDraggableNode<T>(
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) = internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta) ((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor()) .coerceIn(anchors.minPosition(), anchors.maxPosition())
/**
* Compute the target anchor based on the [currentOffset], [velocity] and [positionalThreshold] and
* [velocityThreshold].
*
* @return The suggested target anchor
*/
@ExperimentalFoundationApi
private fun <T> DraggableAnchors<T>.computeTarget(
currentOffset: Float,
currentValue: T,
velocity: Float,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): T {
val currentAnchors = this
val currentAnchorPosition = currentAnchors.positionOf(currentValue)
val velocityThresholdPx = velocityThreshold()
return if (currentAnchorPosition == currentOffset || currentAnchorPosition.isNaN()) {
currentValue
} else {
if (abs(velocity) >= abs(velocityThresholdPx)) {
currentAnchors.closestAnchor(currentOffset, sign(velocity) > 0)!!
} else {
val neighborAnchor =
currentAnchors.closestAnchor(
currentOffset,
currentOffset - currentAnchorPosition > 0,
)!!
val neighborAnchorPosition = currentAnchors.positionOf(neighborAnchor)
val distance = abs(currentAnchorPosition - neighborAnchorPosition)
val relativeThreshold = abs(positionalThreshold(distance))
val relativePosition = abs(currentAnchorPosition - currentOffset)
if (relativePosition <= relativeThreshold) currentValue else neighborAnchor
}
}
}
/**
* Construct a [FlingBehavior] for use with [Modifier.anchoredDraggable].
*
* @param state The [AnchoredDraggableState] that will be used for the fling animation
* @param positionalThreshold A positional threshold that needs to be crossed in order to reach the
* next anchor when flinging, in pixels. This can be a derived from the distance that the lambda
* is invoked with.
* @param snapAnimationSpec The animation spec that will be used to snap to a new state.
*/
@ExperimentalFoundationApi
internal fun <T> anchoredDraggableFlingBehavior(
state: AnchoredDraggableState<T>,
density: Density,
positionalThreshold: (totalDistance: Float) -> Float,
snapAnimationSpec: AnimationSpec<Float>,
): TargetedFlingBehavior =
snapFlingBehavior(
decayAnimationSpec = NoOpDecayAnimationSpec,
snapAnimationSpec = snapAnimationSpec,
snapLayoutInfoProvider =
AnchoredDraggableLayoutInfoProvider(
state = state,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { 125.dp.toPx() } },
),
)
@Suppress("FunctionName")
@OptIn(ExperimentalFoundationApi::class)
private fun <T> AnchoredDraggableLayoutInfoProvider(
state: AnchoredDraggableState<T>,
positionalThreshold: (totalDistance: Float) -> Float,
velocityThreshold: () -> Float,
): SnapLayoutInfoProvider =
object : SnapLayoutInfoProvider {
// We never decay in AnchoredDraggable's fling
override fun calculateApproachOffset(velocity: Float, decayOffset: Float) = 0f
override fun calculateSnapOffset(velocity: Float): Float {
val currentOffset = state.requireOffset()
val target =
state.anchors.computeTarget(
currentOffset = currentOffset,
currentValue = state.currentValue,
velocity = velocity,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
)
return state.anchors.positionOf(target) - currentOffset
}
}
private val NoOpDecayAnimationSpec: DecayAnimationSpec<Float> =
object : FloatDecayAnimationSpec {
override val absVelocityThreshold = 0f
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getDurationNanos(initialValue: Float, initialVelocity: Float) = 0L
override fun getVelocityFromNanos(
playTimeNanos: Long,
initialValue: Float,
initialVelocity: Float,
) = 0f
override fun getTargetValue(initialValue: Float, initialVelocity: Float) = 0f
}.generateDecayAnimationSpec()
/** /**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout * This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
@@ -240,7 +421,8 @@ private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>, var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>, var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation, var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode { ) : Modifier.Node(),
LayoutModifierNode {
private var didLookahead: Boolean = false private var didLookahead: Boolean = false
override fun onDetach() { override fun onDetach() {
@@ -46,7 +46,9 @@ import kotlinx.coroutines.launch
*/ */
internal abstract class HorizontalDragGestureNode( internal abstract class HorizontalDragGestureNode(
enabled: Boolean, enabled: Boolean,
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode { ) : DelegatingNode(),
PointerInputModifierNode,
CompositionLocalConsumerModifierNode {
protected var enabled = enabled protected var enabled = enabled
private set private set
@@ -132,65 +134,63 @@ internal abstract class HorizontalDragGestureNode(
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds) pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
} }
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode { private fun initializePointerInputNode(): SuspendingPointerInputModifierNode = SuspendingPointerInputModifierNode {
return SuspendingPointerInputModifierNode { // re-create tracker when pointer input block restarts. This lazily creates the tracker
// re-create tracker when pointer input block restarts. This lazily creates the tracker // only when it is need.
// only when it is need. val velocityTracker = VelocityTracker()
val velocityTracker = VelocityTracker() val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit = { startEvent, initialDelta ->
{ startEvent, initialDelta -> if (!isListeningForEvents) {
if (!isListeningForEvents) { if (channel == null) {
if (channel == null) { channel = Channel(capacity = Channel.UNLIMITED)
channel = Channel(capacity = Channel.UNLIMITED)
}
startListeningForEvents()
} }
val xSign = sign(startEvent.position.x) startListeningForEvents()
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
channel?.trySend(DragStarted(adjustedStart))
} }
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
val onDragEnd: () -> Unit = { channel?.trySend(DragStarted(adjustedStart))
val maximumVelocity = currentValueOf(LocalViewConfiguration) }
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity( val onDragEnd: () -> Unit = {
Velocity(maximumVelocity, maximumVelocity), val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
channel?.trySend(DragCancelled)
}
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
) )
velocityTracker.resetTracking() } catch (cancellation: CancellationException) {
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
channel?.trySend(DragCancelled) channel?.trySend(DragCancelled)
} if (!isActive) throw cancellation
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
)
} catch (cancellation: CancellationException) {
channel?.trySend(DragCancelled)
if (!isActive) throw cancellation
}
} }
} }
} }
@@ -0,0 +1,64 @@
package androidx.compose.foundation.scrollbar
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
internal class LazyStaggeredGridScrollbarAdapter(
private val scrollState: LazyStaggeredGridState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (isVertical) viewportSize.height else viewportSize.width
}.toDouble()
private val isVertical by lazy {
scrollState.layoutInfo.orientation == Orientation.Vertical
}
private fun LazyStaggeredGridItemInfo.mainAxisSize() = with(size) {
if (isVertical) height else width
}
private fun LazyStaggeredGridItemInfo.mainAxisOffset() = with(offset) {
if (isVertical) y else x
}
override fun firstVisibleLine(): VisibleLine? {
val first = scrollState.layoutInfo.visibleItemsInfo.firstOrNull()
return first?.run { VisibleLine(index, mainAxisOffset()) }
}
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(lineIndex, scrollOffset)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
val first = firstOrNull() ?: return@with 0.0
val last = last()
(last.mainAxisOffset() + last.mainAxisSize() - first.mainAxisOffset()).toDouble() / size
}
override val lineSpacing get() = 0
}
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyStaggeredGridState,
): ScrollbarAdapter = remember(scrollState) {
LazyStaggeredGridScrollbarAdapter(scrollState)
}
@@ -0,0 +1,455 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.scrollbar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* [CompositionLocal] used to pass [ScrollbarStyle] down the tree.
* This value is typically set in some "Theme" composable function
* (DesktopTheme, MaterialTheme)
*/
val LocalScrollbarStyle = staticCompositionLocalOf { defaultScrollbarStyle() }
/**
* Defines visual style of scrollbars (thickness, shapes, colors, etc).
* Can be passed as a parameter of scrollbar through [LocalScrollbarStyle]
*/
@Immutable
data class ScrollbarStyle(
val minimalHeight: Dp,
val thickness: Dp,
val shape: Shape,
val hoverDurationMillis: Int,
val unhoverColor: Color,
val hoverColor: Color,
)
/**
* Simple default [ScrollbarStyle] without applying MaterialTheme.
*/
fun defaultScrollbarStyle() = ScrollbarStyle(
minimalHeight = 16.dp,
thickness = 8.dp,
shape = RoundedCornerShape(4.dp),
hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
hoverColor = Color.Black.copy(alpha = 0.50f),
)
/**
* Vertical scrollbar that can be attached to some scrollable
* component (ScrollableColumn, LazyColumn) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to
* communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the bottom of the container.
* It is usually used in pair with `LazyColumn(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun VerticalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(
adapter,
modifier,
reverseLayout,
style,
interactionSource,
isVertical = true,
)
/**
* Horizontal scrollbar that can be attached to some scrollable
* component (Modifier.verticalScroll(), LazyRow) and share common state with it.
*
* Can be placed independently.
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* HorizontalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxWidth(),
* )
* }
*
* @param adapter [ScrollbarAdapter] that will be used to
* communicate with scrollable component
* @param modifier the modifier to apply to this layout
* @param reverseLayout reverse the direction of scrolling and layout, when `true`
* and [LazyListState.firstVisibleItemIndex] == 0 then scrollbar
* will be at the end of the container.
* It is usually used in pair with `LazyRow(reverseLayout = true)`
* @param style [ScrollbarStyle] to define visual style of scrollbar
* @param interactionSource [MutableInteractionSource] that will be used to dispatch
* [DragInteraction.Start] when this Scrollbar is being dragged.
*/
@Composable
fun HorizontalScrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean = false,
style: ScrollbarStyle = LocalScrollbarStyle.current,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = Scrollbar(
adapter,
modifier,
if (LocalLayoutDirection.current == LayoutDirection.Rtl) !reverseLayout else reverseLayout,
style,
interactionSource,
isVertical = false,
)
@Composable
private fun Scrollbar(
adapter: ScrollbarAdapter,
modifier: Modifier = Modifier,
reverseLayout: Boolean,
style: ScrollbarStyle,
interactionSource: MutableInteractionSource,
isVertical: Boolean,
) = with(LocalDensity.current) {
val dragInteraction = remember<MutableState<DragInteraction.Start?>> { mutableStateOf(null) }
DisposableEffect(key1 = interactionSource) {
onDispose {
dragInteraction.value?.let { interaction ->
interactionSource.tryEmit(DragInteraction.Cancel(interaction))
dragInteraction.value = null
}
}
}
var containerSize by remember { mutableIntStateOf(0) }
val isHovered by interactionSource.collectIsHoveredAsState()
val isHighlighted by remember {
derivedStateOf {
isHovered || dragInteraction.value is DragInteraction.Start
}
}
val minimalHeight = style.minimalHeight.toPx()
val coroutineScope = rememberCoroutineScope()
val sliderAdapter = remember(
adapter,
containerSize,
minimalHeight,
reverseLayout,
isVertical,
coroutineScope,
) {
SliderAdapter(
adapter,
containerSize,
minimalHeight,
reverseLayout = reverseLayout,
isVertical = isVertical,
coroutineScope = coroutineScope,
)
}
val scrollThickness = style.thickness.roundToPx()
val measurePolicy = if (isVertical) {
remember(sliderAdapter, scrollThickness) {
verticalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
} else {
remember(sliderAdapter, scrollThickness) {
horizontalMeasurePolicy(sliderAdapter, { containerSize = it }, scrollThickness)
}
}
val color by animateColorAsState(
if (isHighlighted) style.hoverColor else style.unhoverColor,
animationSpec = TweenSpec(durationMillis = style.hoverDurationMillis),
label = "Scrollbar color",
)
val isVisible = sliderAdapter.thumbSize < containerSize
Layout(
{
Box(
Modifier
.background(if (isVisible) color else Color.Transparent, style.shape)
.scrollbarDrag(
interactionSource = interactionSource,
draggedInteraction = dragInteraction,
sliderAdapter = sliderAdapter,
),
)
},
modifier.hoverable(interactionSource = interactionSource),
measurePolicy,
)
}
/**
* Create and [remember] [ScrollbarAdapter] for
* scrollable container with the given instance [ScrollState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: ScrollState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* Create and [remember] [ScrollbarAdapter] for
* lazy scrollable container with the given instance [LazyListState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyListState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* Create and [remember] [ScrollbarAdapter] for lazy grid with
* the given instance of [LazyGridState].
*/
@Composable
fun rememberScrollbarAdapter(
scrollState: LazyGridState,
): ScrollbarAdapter = remember(scrollState) {
ScrollbarAdapter(scrollState)
}
/**
* ScrollbarAdapter for Modifier.verticalScroll and Modifier.horizontalScroll
*
* [scrollState] is instance of [ScrollState] which is used by scrollable component
*
* Example:
* val state = rememberScrollState(0)
*
* Box(Modifier.fillMaxSize()) {
* Box(modifier = Modifier.verticalScroll(state)) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: ScrollState,
): ScrollbarAdapter = ScrollableScrollbarAdapter(scrollState)
/**
* ScrollbarAdapter for lazy lists.
*
* [scrollState] is instance of [LazyListState] which is used by scrollable component
*
* Example:
* Box(Modifier.fillMaxSize()) {
* val state = rememberLazyListState()
*
* LazyColumn(state = state) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: LazyListState,
): ScrollbarAdapter = LazyListScrollbarAdapter(scrollState)
/**
* ScrollbarAdapter for lazy grids.
*
* [scrollState] is instance of [LazyGridState] which is used by scrollable component
*
* Example:
* Box(Modifier.fillMaxSize()) {
* val state = rememberLazyGridState()
*
* LazyVerticalGrid(columns = ..., state = state) {
* ...
* }
*
* VerticalScrollbar(
* adapter = rememberScrollbarAdapter(state)
* modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
* )
* }
*/
fun ScrollbarAdapter(
scrollState: LazyGridState,
): ScrollbarAdapter = LazyGridScrollbarAdapter(scrollState)
private val SliderAdapter.thumbPixelRange: IntRange
get() {
val start = position.roundToInt()
val endExclusive = start + thumbSize.roundToInt()
return (start until endExclusive)
}
private val IntRange.size get() = last + 1 - first
private fun verticalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int,
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxHeight)
val pixelRange = sliderAdapter.thumbPixelRange
val placeable = measurables.first().measure(
Constraints.fixed(
constraints.constrainWidth(scrollThickness),
pixelRange.size,
),
)
layout(placeable.width, constraints.maxHeight) {
placeable.place(0, pixelRange.first)
}
}
private fun horizontalMeasurePolicy(
sliderAdapter: SliderAdapter,
setContainerSize: (Int) -> Unit,
scrollThickness: Int,
) = MeasurePolicy { measurables, constraints ->
setContainerSize(constraints.maxWidth)
val pixelRange = sliderAdapter.thumbPixelRange
val placeable = measurables.first().measure(
Constraints.fixed(
pixelRange.size,
constraints.constrainHeight(scrollThickness),
),
)
layout(constraints.maxWidth, placeable.height) {
placeable.place(pixelRange.first, 0)
}
}
private fun Modifier.scrollbarDrag(
interactionSource: MutableInteractionSource,
draggedInteraction: MutableState<DragInteraction.Start?>,
sliderAdapter: SliderAdapter,
): Modifier = composed {
val currentInteractionSource by rememberUpdatedState(interactionSource)
val currentDraggedInteraction by rememberUpdatedState(draggedInteraction)
val currentSliderAdapter by rememberUpdatedState(sliderAdapter)
pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val interaction = DragInteraction.Start()
currentInteractionSource.tryEmit(interaction)
currentDraggedInteraction.value = interaction
currentSliderAdapter.onDragStarted()
val isSuccess = drag(down.id) { change ->
currentSliderAdapter.onDragDelta(change.positionChange())
change.consume()
}
val finishInteraction = if (isSuccess) {
DragInteraction.Stop(interaction)
} else {
DragInteraction.Cancel(interaction)
}
currentInteractionSource.tryEmit(finishInteraction)
currentDraggedInteraction.value = null
}
}
}
@@ -0,0 +1,474 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.scrollbar
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Offset
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Defines how to scroll the scrollable component and how to display a scrollbar for it.
*
* The values of this interface are typically in pixels, but do not have to be.
* It's possible to create an adapter with any scroll range of `Double` values.
*/
interface ScrollbarAdapter {
// We use `Double` values here in order to allow scrolling both very large (think LazyList with
// millions of items) and very small (think something whose natural coordinates are less than 1)
// content.
/**
* Scroll offset of the content inside the scrollable component.
*
* For example, a value of `100` could mean the content is scrolled by 100 pixels from the
* start.
*/
val scrollOffset: Double
/**
* The size of the scrollable content, on the scrollable axis.
*/
val contentSize: Double
/**
* The size of the viewport, on the scrollable axis.
*/
val viewportSize: Double
/**
* Instantly jump to [scrollOffset].
*
* @param scrollOffset target offset to jump to, value will be coerced to the valid
* scroll range.
*/
suspend fun scrollTo(scrollOffset: Double)
}
/**
* The maximum scroll offset of the scrollable content.
*/
val ScrollbarAdapter.maxScrollOffset: Double
get() = (contentSize - viewportSize).coerceAtLeast(0.0)
internal class ScrollableScrollbarAdapter(
private val scrollState: ScrollState,
) : ScrollbarAdapter {
override val scrollOffset: Double get() = scrollState.value.toDouble()
override suspend fun scrollTo(scrollOffset: Double) {
scrollState.scrollTo(scrollOffset.roundToInt())
}
override val contentSize: Double
// This isn't strictly correct, as the actual content can be smaller
// than the viewport when scrollState.maxValue is 0, but the scrollbar
// doesn't really care as long as contentSize <= viewportSize; it's
// just not showing itself
get() = scrollState.maxValue + viewportSize
override val viewportSize: Double
get() = scrollState.viewportSize.toDouble()
}
/**
* Base class for [LazyListScrollbarAdapter] and [LazyGridScrollbarAdapter],
* and in the future maybe other lazy widgets that lay out their content in lines.
*/
internal abstract class LazyLineContentAdapter : ScrollbarAdapter {
// Implement the adapter in terms of "lines", which means either rows,
// (for a vertically scrollable widget) or columns (for a horizontally
// scrollable one).
// For LazyList this translates directly to items; for LazyGrid, it
// translates to rows/columns of items.
class VisibleLine(
val index: Int,
val offset: Int,
)
/**
* Return the first visible line, if any.
*/
protected abstract fun firstVisibleLine(): VisibleLine?
/**
* Return the total number of lines.
*/
protected abstract fun totalLineCount(): Int
/**
* The sum of content padding (before+after) on the scrollable axis.
*/
protected abstract fun contentPadding(): Int
/**
* Scroll immediately to the given line, and offset it by [scrollOffset] pixels.
*/
protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int)
/**
* Scroll from the current position by the given amount of pixels.
*/
protected abstract suspend fun scrollBy(value: Float)
/**
* Return the average size (on the scrollable axis) of the visible lines.
*/
protected abstract fun averageVisibleLineSize(): Double
/**
* The spacing between lines.
*/
protected abstract val lineSpacing: Int
private val averageVisibleLineSize by derivedStateOf {
if (totalLineCount() == 0) {
0.0
} else {
averageVisibleLineSize()
}
}
private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacing
override val scrollOffset: Double
get() {
val firstVisibleLine = firstVisibleLine()
return if (firstVisibleLine == null) {
0.0
} else {
firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset
}
}
override val contentSize: Double
get() {
val totalLineCount = totalLineCount()
return averageVisibleLineSize * totalLineCount +
lineSpacing * (totalLineCount - 1).coerceAtLeast(0) +
contentPadding()
}
override suspend fun scrollTo(scrollOffset: Double) {
val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset
// if we scroll less than viewport we need to use scrollBy function to avoid
// undesirable scroll jumps (when an item size is different)
//
// if we scroll more than viewport we should immediately jump to this position
// without recreating all items between the current and the new position
if (abs(distance) <= viewportSize) {
scrollBy(distance.toFloat())
} else {
snapTo(scrollOffset)
}
}
private suspend fun snapTo(scrollOffset: Double) {
val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset)
val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
.coerceAtMost(totalLineCount() - 1)
val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
snapToLine(lineIndex = index, scrollOffset = offset)
}
}
internal class LazyListScrollbarAdapter(
private val scrollState: LazyListState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (orientation == Orientation.Vertical) {
viewportSize.height
} else {
viewportSize.width
}
}.toDouble()
/**
* A heuristic that tries to ignore the "currently stickied" header because it breaks the other
* computations in this adapter:
* - The currently stickied header always appears in the list of visible items, with its
* regular index. This makes [firstVisibleLine] always return its index, even if the list has
* been scrolled far beyond it.
* - [averageVisibleLineSize] calculates the average size in O(1) by assuming that items don't
* overlap, and the stickied item breaks this assumption.
*
* Attempts to return the index into `visibleItemsInfo` of the first non-currently-stickied (it
* could be sticky, but not stickied to the top of the list right now) item, if there is one.
*
* Note that this heuristic breaks down if the sticky header covers the entire list, so that
* it's the only visible item for some portion of the scroll range. But there's currently no
* known better way to solve it, and it's a relatively unusual case.
*/
private fun firstFloatingVisibleItemIndex() = with(scrollState.layoutInfo.visibleItemsInfo) {
when (size) {
0 -> null
1 -> 0
else -> {
val first = this[0]
val second = this[1]
// If either the indices or the offsets aren't continuous, then the first item is
// sticky, so we return 1
if ((first.index < second.index - 1) ||
(first.offset + first.size + lineSpacing > second.offset)
) {
1
} else {
0
}
}
}
}
override fun firstVisibleLine(): VisibleLine? {
val firstFloatingVisibleIndex = firstFloatingVisibleItemIndex() ?: return null
val firstFloatingItem = scrollState.layoutInfo.visibleItemsInfo[firstFloatingVisibleIndex]
return VisibleLine(
index = firstFloatingItem.index,
offset = firstFloatingItem.offset,
)
}
override fun totalLineCount() = scrollState.layoutInfo.totalItemsCount
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(lineIndex, scrollOffset)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize() = with(scrollState.layoutInfo.visibleItemsInfo) {
val firstFloatingIndex = firstFloatingVisibleItemIndex() ?: return@with 0.0
val first = this[firstFloatingIndex]
val last = last()
val count = size - firstFloatingIndex
(last.offset + last.size - first.offset - (count - 1) * lineSpacing).toDouble() / count
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
internal class LazyGridScrollbarAdapter(
private val scrollState: LazyGridState,
) : LazyLineContentAdapter() {
override val viewportSize: Double
get() = with(scrollState.layoutInfo) {
if (isVertical) {
viewportSize.height
} else {
viewportSize.width
}
}.toDouble()
private val isVertical by lazy {
scrollState.layoutInfo.orientation == Orientation.Vertical
}
private val unknownLine = with(LazyGridItemInfo) {
if (isVertical) UnknownRow else UnknownColumn
}
private fun LazyGridItemInfo.line() = if (isVertical) row else column
private fun LazyGridItemInfo.mainAxisSize() = with(size) {
if (isVertical) height else width
}
private fun LazyGridItemInfo.mainAxisOffset() = with(offset) {
if (isVertical) y else x
}
private fun slotsPerLine(): Int = scrollState.layoutInfo.visibleItemsInfo
.groupingBy { it.line() }.eachCount().maxOfOrNull { it.value } ?: 1
private fun lineOfIndex(index: Int) = index / slotsPerLine()
private fun indexOfFirstInLine(line: Int) = line * slotsPerLine()
override fun firstVisibleLine(): VisibleLine? = scrollState.layoutInfo.visibleItemsInfo
.firstOrNull { it.line() != unknownLine } // Skip exiting items
?.let { firstVisibleItem ->
VisibleLine(
index = firstVisibleItem.line(),
offset = firstVisibleItem.mainAxisOffset(),
)
}
override fun totalLineCount(): Int {
val itemCount = scrollState.layoutInfo.totalItemsCount
return if (itemCount == 0) {
0
} else {
lineOfIndex(itemCount - 1) + 1
}
}
override fun contentPadding() = with(scrollState.layoutInfo) {
beforeContentPadding + afterContentPadding
}
override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToItem(
index = indexOfFirstInLine(lineIndex),
scrollOffset = scrollOffset,
)
}
override suspend fun scrollBy(value: Float) {
scrollState.scrollBy(value)
}
override fun averageVisibleLineSize(): Double {
val visibleItemsInfo = scrollState.layoutInfo.visibleItemsInfo
val indexOfFirstKnownLineItem = visibleItemsInfo.indexOfFirst { it.line() != unknownLine }
if (indexOfFirstKnownLineItem == -1) {
return 0.0
}
val reallyVisibleItemsInfo = // Non-exiting visible items
visibleItemsInfo.subList(indexOfFirstKnownLineItem, visibleItemsInfo.size)
// Compute the size of the last line
val lastLine = reallyVisibleItemsInfo.last().line()
val lastLineSize = reallyVisibleItemsInfo
.asReversed()
.asSequence()
.takeWhile { it.line() == lastLine }
.maxOf { it.mainAxisSize() }
val first = reallyVisibleItemsInfo.first()
val last = reallyVisibleItemsInfo.last()
val lineCount = last.line() - first.line() + 1
val lineSpacingSum = (lineCount - 1) * lineSpacing
return (
last.mainAxisOffset() + lastLineSize - first.mainAxisOffset() - lineSpacingSum
).toDouble() / lineCount
}
override val lineSpacing get() = scrollState.layoutInfo.mainAxisItemSpacing
}
internal class SliderAdapter(
private val adapter: ScrollbarAdapter,
private val trackSize: Int,
private val minHeight: Float,
private val reverseLayout: Boolean,
private val isVertical: Boolean,
private val coroutineScope: CoroutineScope,
) {
private val contentSize get() = adapter.contentSize
private val visiblePart: Double
get() {
val contentSize = contentSize
return if (contentSize == 0.0) {
1.0
} else {
(adapter.viewportSize / contentSize).coerceAtMost(1.0)
}
}
val thumbSize
get() = (trackSize * visiblePart).coerceAtLeast(minHeight.toDouble())
private val scrollScale: Double
get() {
val extraScrollbarSpace = trackSize - thumbSize
val extraContentSpace = adapter.maxScrollOffset // == contentSize - viewportSize
return if (extraContentSpace == 0.0) 1.0 else extraScrollbarSpace / extraContentSpace
}
private val rawPosition: Double
get() = scrollScale * adapter.scrollOffset
val position: Double
get() = if (reverseLayout) trackSize - thumbSize - rawPosition else rawPosition
// How much of the current drag was ignored because we've reached the end of the scrollbar area
private var unscrolledDragDistance = 0.0
/** Called when the thumb dragging starts */
fun onDragStarted() {
unscrolledDragDistance = 0.0
}
private suspend fun setPosition(value: Double) {
val rawPosition = if (reverseLayout) {
trackSize - thumbSize - value
} else {
value
}
adapter.scrollTo(rawPosition / scrollScale)
}
private val dragMutex = Mutex()
/** Called on every movement while dragging the thumb */
fun onDragDelta(offset: Offset) {
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
// Mutex is used to ensure that all earlier drag deltas were applied
// before calculating a new raw position
dragMutex.withLock {
val dragDelta = if (isVertical) offset.y else offset.x
val maxScrollPosition = adapter.maxScrollOffset * scrollScale
val currentPosition = position
val targetPosition =
(currentPosition + dragDelta + unscrolledDragDistance).coerceIn(
0.0,
maxScrollPosition,
)
val sliderDelta = targetPosition - currentPosition
// Have to add to position for smooth content scroll if the items are of different size
val newPos = position + sliderDelta
setPosition(newPos)
unscrolledDragDistance += dragDelta - sliderDelta
}
}
}
}
@@ -82,7 +82,7 @@ fun SearchBarInputField(
trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } }, trailingIcon = trailingIcon?.let { trailing -> { Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() } } },
shape = SearchBarDefaults.inputFieldShape, shape = SearchBarDefaults.inputFieldShape,
colors = colors, colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(), contentPadding = TextFieldDefaults.contentPaddingWithLabel(),
container = {}, container = {},
) )
}, },
@@ -1,13 +1,13 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") @file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3 package androidx.compose.material3
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
@@ -53,8 +53,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256) private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
@Suppress("NotCloseable") @Suppress("NotCloseable")
@Stable @Stable
@@ -65,11 +63,7 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState( val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue, initialValue = initialValue,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange, confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
) )
/** /**
@@ -230,10 +224,8 @@ class DrawerState2(
fun rememberDrawerState2( fun rememberDrawerState2(
initialValue: DrawerValue, initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true }, confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 { ): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) { DrawerState2(initialValue, confirmStateChange)
DrawerState2(initialValue, confirmStateChange)
}
} }
@Composable @Composable
@@ -266,6 +258,10 @@ fun ModalSideDrawer(
enableDragFromStartToEnd = drawerState.isOpen, enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed, enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled, enabled = gesturesEnabled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
), ),
) { ) {
val radius by remember { val radius by remember {
@@ -18,7 +18,7 @@ package androidx.compose.material3.fork
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@@ -38,21 +38,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
private val SnapAnimationSpec = SpringSpec<Float>() private val SnapAnimationSpec = SpringSpec<Float>()
// TODO: Update to M3 spec once it's available
private val DecayAnimationSpec = exponentialDecay<Float>()
/** /**
* State of the [SwipeToDismissBox] composable. * State of the [SwipeToDismissBox] composable.
* *
* @param initialValue The initial value of the state. * @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 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 confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from * while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or * the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value. * subtracted from/to the origin offset. It should always be a positive value.
@@ -61,16 +56,11 @@ class SwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue, initialValue: SwipeToDismissBoxValue,
internal val density: Density, internal val density: Density,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true }, confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float,
) { ) {
internal val anchoredDraggableState = internal val anchoredDraggableState =
AnchoredDraggableState( AnchoredDraggableState(
initialValue = initialValue, initialValue = initialValue,
snapAnimationSpec = SnapAnimationSpec,
decayAnimationSpec = DecayAnimationSpec,
confirmValueChange = confirmValueChange, confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
) )
internal val offset: Float get() = anchoredDraggableState.offset internal val offset: Float get() = anchoredDraggableState.offset
@@ -150,7 +140,6 @@ class SwipeToDismissBoxState(
*/ */
fun Saver( fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean, confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
positionalThreshold: (totalDistance: Float) -> Float,
density: Density, density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>( ) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue }, save = { it.currentValue },
@@ -159,7 +148,6 @@ class SwipeToDismissBoxState(
it, it,
density, density,
confirmValueChange, confirmValueChange,
positionalThreshold,
) )
}, },
) )
@@ -171,7 +159,6 @@ class SwipeToDismissBoxState(
* *
* @param initialValue The initial value of the state. * @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from * while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or * the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value. * subtracted from/to the origin offset. It should always be a positive value.
@@ -180,18 +167,15 @@ class SwipeToDismissBoxState(
fun rememberSwipeToDismissBoxState( fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled, initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true }, confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float =
SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState { ): SwipeToDismissBoxState {
val density = LocalDensity.current val density = LocalDensity.current
return rememberSaveable( return rememberSaveable(
saver = SwipeToDismissBoxState.Saver( saver = SwipeToDismissBoxState.Saver(
confirmValueChange = confirmValueChange, confirmValueChange = confirmValueChange,
density = density, density = density,
positionalThreshold = positionalThreshold,
), ),
) { ) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold) SwipeToDismissBoxState(initialValue, density, confirmValueChange)
} }
} }
@@ -224,6 +208,11 @@ fun SwipeToDismissBox(
enableDragFromStartToEnd = enableDismissFromStartToEnd, enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart, enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled, enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = state.anchoredDraggableState,
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
animationSpec = SnapAnimationSpec,
),
), ),
propagateMinConstraints = true, propagateMinConstraints = true,
) { ) {
@@ -251,5 +240,3 @@ fun SwipeToDismissBox(
) )
} }
} }
private val DismissVelocityThreshold = 125.dp
@@ -912,16 +912,12 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/** /**
* Convert screen to source x coordinate. * Convert screen to source x coordinate.
*/ */
private fun viewToSourceX(vx: Float): Float { private fun viewToSourceX(vx: Float): Float = if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
return if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
}
/** /**
* Convert screen to source y coordinate. * Convert screen to source y coordinate.
*/ */
private fun viewToSourceY(vy: Float): Float { private fun viewToSourceY(vy: Float): Float = if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
return if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
}
/** /**
* Convert screen coordinate to source coordinate. * Convert screen coordinate to source coordinate.
@@ -929,9 +925,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param vxy view X/Y coordinate. * @param vxy view X/Y coordinate.
* @return a coordinate representing the corresponding source coordinate. * @return a coordinate representing the corresponding source coordinate.
*/ */
fun viewToSourceCoord(vxy: PointF): PointF? { fun viewToSourceCoord(vxy: PointF): PointF? = viewToSourceCoord(vxy.x, vxy.y, PointF())
return viewToSourceCoord(vxy.x, vxy.y, PointF())
}
/** /**
* Convert screen coordinate to source coordinate. * Convert screen coordinate to source coordinate.
@@ -952,16 +946,12 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/** /**
* Convert source to view x coordinate. * Convert source to view x coordinate.
*/ */
private fun sourceToViewX(sx: Float): Float { private fun sourceToViewX(sx: Float): Float = if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
return if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
}
/** /**
* Convert source to view y coordinate. * Convert source to view y coordinate.
*/ */
private fun sourceToViewY(sy: Float): Float { private fun sourceToViewY(sy: Float): Float = if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
return if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
}
/** /**
* Convert source coordinate to view coordinate. * Convert source coordinate to view coordinate.
@@ -969,9 +959,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sxy source coordinates to convert. * @param sxy source coordinates to convert.
* @return view coordinates. * @return view coordinates.
*/ */
fun sourceToViewCoord(sxy: PointF?): PointF? { fun sourceToViewCoord(sxy: PointF?): PointF? = sourceToViewCoord(sxy!!.x, sxy.y, PointF())
return sourceToViewCoord(sxy!!.x, sxy.y, PointF())
}
/** /**
* Convert source coordinate to view coordinate. * Convert source coordinate to view coordinate.
@@ -1053,9 +1041,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/** /**
* Adjust a requested scale to be within the allowed limits. * Adjust a requested scale to be within the allowed limits.
*/ */
private fun limitedScale(targetScale: Float): Float { private fun limitedScale(targetScale: Float): Float = targetScale.coerceIn(minScale(), maxScale)
return targetScale.coerceIn(minScale(), maxScale)
}
/** /**
* Apply a selected type of easing. * Apply a selected type of easing.
@@ -1067,12 +1053,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param duration Anm duration * @param duration Anm duration
* @return Current value * @return Current value
*/ */
private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float { private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float = when (type) {
return when (type) { EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration)
EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration) EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration)
EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration) else -> throw IllegalStateException("Unexpected easing type: $type")
else -> throw IllegalStateException("Unexpected easing type: $type")
}
} }
/** /**
@@ -1199,9 +1183,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* *
* @return the minimum scale as a source/view pixels ratio. * @return the minimum scale as a source/view pixels ratio.
*/ */
fun getMinScale(): Float { fun getMinScale(): Float = minScale()
return minScale()
}
/** /**
* Set the minimum scale allowed. A value of 1 means 1:1 pixels at minimum scale. You may wish to set this according * Set the minimum scale allowed. A value of 1 means 1:1 pixels at minimum scale. You may wish to set this according
@@ -1325,9 +1307,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sCenter Target center point * @param sCenter Target center point
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim. * @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
*/ */
fun animateCenter(sCenter: PointF): AnimationBuilder? { fun animateCenter(sCenter: PointF): AnimationBuilder? = if (isReady) AnimationBuilder(sCenter) else null
return if (isReady) AnimationBuilder(sCenter) else null
}
/** /**
* Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image * Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image
@@ -1337,9 +1317,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sCenter Target source center. * @param sCenter Target source center.
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim. * @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
*/ */
fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? { fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? = if (isReady) AnimationBuilder(scale, sCenter) else null
return if (isReady) AnimationBuilder(scale, sCenter) else null
}
/** /**
* An event listener, allowing subclasses and activities to be notified of significant events. * An event listener, allowing subclasses and activities to be notified of significant events.
@@ -27,7 +27,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import coil3.EventListener import coil3.EventListener
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.asCoilImage import coil3.asImage
import coil3.gif.AnimatedImageDecoder import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory import coil3.network.ktor.KtorNetworkFetcherFactory
@@ -43,13 +43,14 @@ import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.dailycheck.checkDawn import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadsFilterMode import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.ktbuilder.diskCache import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.imageLoader import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine import com.hippo.ehviewer.ktor.Cronet
import com.hippo.ehviewer.legacy.cleanObsoleteCache import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver import com.hippo.ehviewer.ui.lockObserver
@@ -58,6 +59,7 @@ import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastS import com.hippo.ehviewer.util.isAtLeastS
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
@@ -77,7 +79,9 @@ import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), SingletonImageLoader.Factory { class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() { override fun onCreate() {
// Initialize Settings on first access // Initialize Settings on first access
lifecycleScope.launchIO { lifecycleScope.launchIO {
@@ -168,8 +172,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
add(KtorNetworkFetcherFactory { ktorClient }) add(KtorNetworkFetcherFactory { ktorClient })
add(MergeInterceptor) add(MergeInterceptor)
add(DownloadThumbInterceptor) add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor) add(CropBorderInterceptor)
add(HardwareBitmapInterceptor)
if (isAtLeastP) { if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false)) add(AnimatedImageDecoder.Factory(false))
} else { } else {
@@ -179,7 +185,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
diskCache { imageCache } diskCache { imageCache }
crossfade(300) crossfade(300)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed) val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true)) if (drawable != null) error(drawable.asImage(true))
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
logger(DebugLogger()) logger(DebugLogger())
} else { } else {
@@ -195,7 +201,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
companion object { companion object {
val ktorClient by lazy { val ktorClient by lazy {
HttpClient(CronetEngine) { HttpClient(Cronet) {
engine {
client = cronetHttpClient
}
install(HttpCookies) { install(HttpCookies) {
storage = EhCookieStore storage = EhCookieStore
} }
@@ -203,7 +212,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
} }
val noRedirectKtorClient by lazy { val noRedirectKtorClient by lazy {
HttpClient(CronetEngine) { HttpClient(ktorClient.engine) {
followRedirects = false followRedirects = false
install(HttpCookies) { install(HttpCookies) {
storage = EhCookieStore storage = EhCookieStore
@@ -33,9 +33,9 @@ import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.dao.Schema17to18 import com.hippo.ehviewer.dao.Schema17to18
import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import okio.Path
import okio.Path.Companion.toOkioPath
import splitties.arch.room.roomDb import splitties.arch.room.roomDb
object EhDB { object EhDB {
@@ -273,10 +273,10 @@ object EhDB {
db.filterDao().update(filter) db.filterDao().update(filter)
} }
fun exportDB(context: Context, file: UniFile) { fun exportDB(context: Context, file: Path) {
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() } db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
val dbFile = context.getDatabasePath(DB_NAME) val dbFile = context.getDatabasePath(DB_NAME)
dbFile.asUniFile() sendTo file dbFile.toOkioPath() sendTo file
} }
suspend fun importDB(context: Context, uri: Uri) { suspend fun importDB(context: Context, uri: Uri) {
@@ -100,6 +100,7 @@ fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
object Settings : DataStorePreferences(null) { object Settings : DataStorePreferences(null) {
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations" private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
@Suppress("ktlint:standard:backing-property-naming")
private val _favFlow = MutableSharedFlow<Unit>() private val _favFlow = MutableSharedFlow<Unit>()
val favChangesFlow = _favFlow.debounce(1000) val favChangesFlow = _favFlow.debounce(1000)
var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow) var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow)
@@ -140,6 +141,7 @@ object Settings : DataStorePreferences(null) {
var readCacheSize by intPref("read_cache_size_2", 640) var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0) var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101) var commentThreshold by intPref("comment_threshold", -101)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var forceEhThumb by boolPref("force_eh_thumb", false) var forceEhThumb by boolPref("force_eh_thumb", false)
var showComments by boolPref("show_gallery_comments", true) var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() } var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
@@ -225,7 +227,7 @@ object Settings : DataStorePreferences(null) {
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> { private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() } override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming") @Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray() private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray() override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) { override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
@@ -237,7 +239,7 @@ object Settings : DataStorePreferences(null) {
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> { private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() } override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming") @Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray() private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray() override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) { override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
@@ -29,13 +29,9 @@ private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/" private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/" private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
fun getImageKey(gid: Long, index: Int): String { fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
return "image:$gid:$index"
}
fun getThumbKey(url: String): String { fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
return url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
}
val GalleryPreview.url val GalleryPreview.url
get() = if (exThumb) { get() = if (exThumb) {
@@ -1,24 +1,19 @@
package com.hippo.ehviewer.client package com.hippo.ehviewer.client
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastJoinToString
import io.ktor.client.plugins.cookies.CookiesStorage import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.http.Cookie import io.ktor.http.Cookie
import io.ktor.http.Url import io.ktor.http.Url
import io.ktor.http.parseClientCookiesHeader import io.ktor.http.parseClientCookiesHeader
import io.ktor.http.renderCookieHeader
import io.ktor.http.renderSetCookieHeader import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookiesStorage { object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance() private val manager = CookieManager.getInstance()
fun removeAllCookies() = manager.removeAllCookies(null) fun removeAllCookies() = manager.removeAllCookies(null)
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean { fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
val url = EhUrl.HOST_E containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH) } ?: false
}
const val KEY_IPB_MEMBER_ID = "ipb_member_id" const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash" const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
@@ -53,11 +48,7 @@ object EhCookieStore : CookiesStorage {
fun flush() = manager.flush() fun flush() = manager.flush()
fun getCookieHeader(url: String): String { fun getCookieHeader(url: String): String? = manager.getCookie(url)
return load(Url(url)).fastJoinToString("; ") {
renderCookieHeader(it)
}
}
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873 // See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) { override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
@@ -297,6 +297,10 @@ object EhEngine {
}.executeSafely { } }.executeSafely { }
} }
suspend fun getFavoriteNote(gid: Long, token: String) =
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? { suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token, or) val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token) val referer = EhUrl.getGalleryDetailUrl(gid, token)
@@ -26,8 +26,7 @@ import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.StatusCodeException import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo import com.hippo.ehviewer.util.copyTo
import com.hippo.unifile.asUniFile import com.hippo.ehviewer.util.sha1
import com.hippo.unifile.sha1
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet import io.ktor.client.request.prepareGet
@@ -45,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.BufferedSource import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import okio.buffer import okio.buffer
import okio.source import okio.source
import splitties.init.appCtx import splitties.init.appCtx
@@ -63,9 +63,7 @@ object EhTagDatabase : CoroutineScope {
var initialized by mutableStateOf(false) var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? { fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
}
private fun internalSuggestFlow( private fun internalSuggestFlow(
tags: Map<String, String>, tags: Map<String, String>,
@@ -131,24 +129,16 @@ object EhTagDatabase : CoroutineScope {
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean = private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase) removeSpace().contains(other.removeSpace(), ignoreCase)
private fun getMetadata(context: Context): Array<String>? { private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
return context.resources.getStringArray(R.array.tag_translation_metadata) .takeIf { it.size == 4 }
.takeIf { it.size == 4 }
}
fun isTranslatable(context: Context): Boolean { fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
return context.resources.getBoolean(R.bool.tag_translatable)
}
private fun getFileContent(file: File): String? { private fun getFileContent(file: File): String? = runCatching {
return runCatching { file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) } }.getOrNull()
}.getOrNull()
}
private fun checkData(sha1: String?, data: File): Boolean { private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
return sha1 != null && sha1 == data.asUniFile().sha1()
}
private suspend fun save(client: HttpClient, url: String, file: File) { private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching { runCatching {
@@ -88,21 +88,18 @@ object EhUtils {
fun getCategory(type: String?): Int { fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) { for (entry in CATEGORY_STRINGS) {
for (str in entry.first) for (str in entry.first) {
if (str.equals(type, ignoreCase = true)) { if (str.equals(type, ignoreCase = true)) {
return entry.second return entry.second
} }
}
} }
return UNKNOWN return UNKNOWN
} }
fun getCategory(type: Int): String { fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
}
private fun differenceDegrees(a: Float, b: Float): Float { private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
return 180.0f - abs(abs(a - b) - 180.0f)
}
private fun sanitizeDegreesDouble(degrees: Float): Float { private fun sanitizeDegreesDouble(degrees: Float): Float {
val deg = degrees % 360.0f val deg = degrees % 360.0f
@@ -159,13 +156,11 @@ object EhUtils {
Settings.needSignIn = true Settings.needSignIn = true
} }
fun getSuitableTitle(gi: GalleryInfo): String { fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
return if (Settings.showJpnTitle) { if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn } else {
} else { if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title }.orEmpty()
}.orEmpty()
}
fun extractTitle(fullTitle: String?): String? { fun extractTitle(fullTitle: String?): String? {
var title: String = fullTitle ?: return null var title: String = fullTitle ?: return null
@@ -88,7 +88,8 @@ data class BaseGalleryInfo(
@Ignore @Ignore
override var favoriteNote: String? = null, override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable { ) : GalleryInfo,
Parcelable {
constructor() : this(0, "") constructor() : this(0, "")
} }
@@ -55,7 +55,8 @@ data class GalleryDetail(
var comments: GalleryCommentList, var comments: GalleryCommentList,
val previewPages: Int, val previewPages: Int,
val previewList: List<GalleryPreview>, val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable { ) : GalleryInfo by galleryInfo,
Parcelable {
fun fillInfo() { fun fillInfo() {
val index = LANGUAGES.indexOf(language) val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index] if (index != -1) simpleLanguage = S_LANGS[index]
@@ -134,14 +134,10 @@ interface GalleryInfo {
} }
} }
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo { fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
return when (this) { is BaseGalleryInfo -> this
is BaseGalleryInfo -> this is GalleryDetail -> galleryInfo
is GalleryDetail -> galleryInfo else -> unreachable()
else -> unreachable()
}
} }
fun GalleryInfo.asGalleryDetail(): GalleryDetail? { fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
return this as? GalleryDetail
}
@@ -22,4 +22,5 @@ import kotlinx.parcelize.Parcelize
data class GalleryTagGroup( data class GalleryTagGroup(
val groupName: String, val groupName: String,
private val tagList: MutableList<String> = mutableListOf(), private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by tagList ) : Parcelable,
MutableList<String> by tagList
@@ -42,6 +42,7 @@ data class ListUrlBuilder(
var category: Int = EhUtils.NONE, var category: Int = EhUtils.NONE,
private var mKeyword: String? = null, private var mKeyword: String? = null,
var hash: String? = null, var hash: String? = null,
var language: Int = -1,
var advanceSearch: Int = -1, var advanceSearch: Int = -1,
var minRating: Int = -1, var minRating: Int = -1,
var pageFrom: Int = -1, var pageFrom: Int = -1,
@@ -83,18 +84,16 @@ data class ListUrlBuilder(
mNext = q.name.substringAfterLast('@', "").ifEmpty { null } mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
} }
fun toQuickSearch(name: String): QuickSearch { fun toQuickSearch(name: String): QuickSearch = QuickSearch(
return QuickSearch( name = name,
name = name, mode = mode,
mode = mode, category = category,
category = category, keyword = mKeyword,
keyword = mKeyword, advanceSearch = advanceSearch,
advanceSearch = advanceSearch, minRating = minRating,
minRating = minRating, pageFrom = pageFrom,
pageFrom = pageFrom, pageTo = pageTo,
pageTo = pageTo, )
)
}
fun equalsQuickSearch(q: QuickSearch?): Boolean { fun equalsQuickSearch(q: QuickSearch?): Boolean {
if (null == q) { if (null == q) {
@@ -259,85 +258,89 @@ data class ListUrlBuilder(
} }
} }
fun build(): String { fun build(): String = when (mode) {
return when (mode) { MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) { val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category if (category > 0) {
if (category > 0) { addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString()) }
val query = mKeyword?.let { keyword ->
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
keyword
} else {
val tag = GalleryInfo.S_LANG_TAGS[language]
if (keyword.isNotEmpty()) {
"$tag $keyword"
} else {
tag
}
} }
val query = mKeyword?.let { }
val index = Settings.languageFilter.value addQueryParameterIfNotBlank("f_search", query)
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang -> addQueryParameterIfNotBlank("prev", mPrev)
"$it $lang" addQueryParameterIfNotBlank("next", mNext)
} ?: it addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
} }
addQueryParameterIfNotBlank("f_search", query) if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev) addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext) addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo) addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString()) addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
}
if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString() }.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
}.buildString()
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
} }
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
} }
@IntDef( @IntDef(
@@ -20,7 +20,5 @@ package com.hippo.ehviewer.client.parser
import org.jsoup.Jsoup import org.jsoup.Jsoup
object EventPaneParser { object EventPaneParser {
fun parse(body: String): String? { fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
return Jsoup.parse(body).getElementById("eventpane")?.html()
}
} }
@@ -1,6 +1,7 @@
package com.hippo.ehviewer.client.parser package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.unescapeXml
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
@@ -17,13 +18,16 @@ data class FavParserResult(
} }
object FavoritesParser { object FavoritesParser {
fun parse(body: ByteBuffer): FavParserResult { private val NoteRegex = Regex("<textarea[^>]*>([^<]*)</textarea>")
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav) fun parse(body: ByteBuffer): FavParserResult = runSuspendCatching {
}.getOrElse { unmarshalParsingAs<FavParserResult>(body, ::parseFav)
throw ParseException("Parse favorites error", it) }.getOrElse {
} throw ParseException("Parse favorites error", it)
} }
fun parseNote(body: String) = NoteRegex.find(body)?.run { groupValues[1].unescapeXml() }
?: throw ParseException("Can't parse favorite note")
} }
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -21,15 +21,13 @@ import com.hippo.ehviewer.util.ExceptionUtils
import org.jsoup.Jsoup import org.jsoup.Jsoup
object ForumsParser { object ForumsParser {
fun parse(body: String): String { fun parse(body: String): String = runCatching {
return runCatching { val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val d = Jsoup.parse(body, EhUrl.URL_FORUMS) val userlinks = d.getElementById("userlinks")
val userlinks = d.getElementById("userlinks") val child = userlinks!!.child(0).child(0).child(0)
val child = userlinks!!.child(0).child(0).child(0) child.attr("href")
child.attr("href") }.getOrElse {
}.getOrElse { ExceptionUtils.throwIfFatal(it)
ExceptionUtils.throwIfFatal(it) throw NotLoggedInException()
throw NotLoggedInException()
}
} }
} }
@@ -283,56 +283,50 @@ object GalleryDetailParser {
} }
} }
private fun parseTagGroup(element: Element): GalleryTagGroup? { private fun parseTagGroup(element: Element): GalleryTagGroup? = try {
return try { var nameSpace = element.child(0).text()
var nameSpace = element.child(0).text() // Remove last ':'
// Remove last ':' nameSpace = nameSpace.substring(0, nameSpace.length - 1)
nameSpace = nameSpace.substring(0, nameSpace.length - 1) val group = GalleryTagGroup(nameSpace)
val group = GalleryTagGroup(nameSpace) val tags = element.child(1).children()
val tags = element.child(1).children() tags.forEach {
tags.forEach { var tag = it.text()
var tag = it.text() // Sometimes parody tag is followed with '|' and english translate, just remove them
// Sometimes parody tag is followed with '|' and english translate, just remove them val index = tag.indexOf('|')
val index = tag.indexOf('|') if (index >= 0) {
if (index >= 0) { tag = tag.substring(0, index).trim()
tag = tag.substring(0, index).trim()
}
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
} }
if (group.size > 0) group else null if (it.className() == "gtw") {
} catch (e: Throwable) { tag = "_$tag" // weak tag
ExceptionUtils.throwIfFatal(e) }
logcat(e) group.add(tag)
null
} }
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
} }
/** /**
* Parse tag groups with html parser * Parse tag groups with html parser
*/ */
private fun parseTagGroups(document: Document): List<GalleryTagGroup> { private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
return try { val taglist = document.getElementById("taglist")!!
val taglist = document.getElementById("taglist")!! val tagGroups = taglist.child(0).child(0).children()
val tagGroups = taglist.child(0).child(0).children() parseTagGroups(tagGroups)
parseTagGroups(tagGroups) } catch (e: Throwable) {
} catch (e: Throwable) { ExceptionUtils.throwIfFatal(e)
ExceptionUtils.throwIfFatal(e) logcat(e)
logcat(e) emptyList()
emptyList()
}
} }
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> { private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> = try {
return try { trs.mapNotNull { parseTagGroup(it) }
trs.mapNotNull { parseTagGroup(it) } } catch (e: Throwable) {
} catch (e: Throwable) { ExceptionUtils.throwIfFatal(e)
ExceptionUtils.throwIfFatal(e) logcat(e)
logcat(e) emptyList()
emptyList()
}
} }
private suspend fun parseComment(element: Element): GalleryComment? { private suspend fun parseComment(element: Element): GalleryComment? {
@@ -449,22 +443,16 @@ object GalleryDetailParser {
/** /**
* Parse preview pages with regular expressions * Parse preview pages with regular expressions
*/ */
fun parsePreviewPages(body: String): Int { fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
return PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull() ?: throw ParseException("Parse preview page count error")
?: throw ParseException("Parse preview page count error")
}
/** /**
* Parse pages with regular expressions * Parse pages with regular expressions
*/ */
fun parsePages(body: String): Int { fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
return PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull() ?: throw ParseException("Parse pages error")
?: throw ParseException("Parse pages error")
}
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> { fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
return runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
}
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> { private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body)) check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
@@ -22,14 +22,12 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup import org.jsoup.Jsoup
object GalleryNotAvailableParser { object GalleryNotAvailableParser {
fun parse(body: String): String? { fun parse(body: String): String? = runCatching {
return runCatching { val e = Jsoup.parse(body).getElementsByClass("d").first()
val e = Jsoup.parse(body).getElementsByClass("d").first() e!!.child(0).html().replace("<br>", "\n")
e!!.child(0).html().replace("<br>", "\n") }.getOrElse {
}.getOrElse { ExceptionUtils.throwIfFatal(it)
ExceptionUtils.throwIfFatal(it) logcat(it)
logcat(it) null
null
}
} }
} }
@@ -32,13 +32,11 @@ object GalleryTokenApiParser {
* ] * ]
* } * }
*/ */
fun parse(body: String): String { fun parse(body: String): String = runCatching {
return runCatching { body.parseAs<Result>().tokenList[0].token
body.parseAs<Result>().tokenList[0].token }.getOrElse {
}.getOrElse { ExceptionUtils.throwIfFatal(it)
ExceptionUtils.throwIfFatal(it) throw EhException(body.parseAs<Error>().error)
throw EhException(body.parseAs<Error>().error)
}
} }
@Serializable @Serializable
@@ -37,23 +37,13 @@ object ParserUtils {
minute() minute()
} }
fun formatDate(time: Long): String { fun formatDate(time: Long): String = formatter.format(time.toLocalDateTime())
return formatter.format(time.toLocalDateTime())
}
fun parseDate(str: String): Long { fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
return formatter.parse(str).toEpochMillis()
}
fun trim(str: String?): String { fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
return str?.unescapeXml()?.trim() ?: ""
}
fun parseInt(str: String?, defValue: Int): Int { fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
return trim(str).replace(",", "").toIntOrDefault(defValue)
}
fun parseLong(str: String?, defValue: Long): Long { fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
return trim(str).replace(",", "").toLongOrDefault(defValue)
}
} }
@@ -22,31 +22,29 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup import org.jsoup.Jsoup
object ProfileParser { object ProfileParser {
fun parse(body: String): Result { fun parse(body: String): Result = runCatching {
return runCatching { val d = Jsoup.parse(body)
val d = Jsoup.parse(body) val profilename = d.getElementById("profilename")
val profilename = d.getElementById("profilename") val displayName = profilename!!.child(0).text()
val displayName = profilename!!.child(0).text() val avatar = runCatching {
val avatar = runCatching { val avatar =
val avatar = profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src") if (avatar.isEmpty()) {
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat { "No avatar" }
null null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
} }
Result(displayName, avatar)
}.getOrElse { }.getOrElse {
ExceptionUtils.throwIfFatal(it) ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error") logcat { "No avatar" }
null
} }
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
} }
class Result(val displayName: String?, val avatar: String?) class Result(val displayName: String?, val avatar: String?)
@@ -25,11 +25,9 @@ object SignInParser {
"|<span class=\"postcolor\">(.+?)</span>", "|<span class=\"postcolor\">(.+?)</span>",
) )
fun parse(body: String): String { fun parse(body: String): String = NAME_PATTERN.find(body)?.let {
return NAME_PATTERN.find(body)?.let { it.groupValues[1]
it.groupValues[1] } ?: ERROR_PATTERN.find(body)?.let {
} ?: ERROR_PATTERN.find(body)?.let { throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] }) } ?: throw ParseException("Can't parse sign in")
} ?: throw ParseException("Can't parse sign in")
}
} }
@@ -21,7 +21,5 @@ import com.hippo.ehviewer.client.parseAs
object VoteTagParser { object VoteTagParser {
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"} // {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
fun parse(body: String): String? { fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
return runCatching { body.parseAs<Error>().error }.getOrNull()
}
} }
@@ -1,20 +1,16 @@
package com.hippo.ehviewer.coil package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage import coil3.BitmapImage
import coil3.Extras import coil3.Extras
import coil3.Image import coil3.Image
import coil3.asCoilImage
import coil3.getExtra import coil3.getExtra
import coil3.intercept.Interceptor import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.ImageResult import coil3.request.ImageResult
import coil3.request.SuccessResult import coil3.request.SuccessResult
import coil3.request.allowHardware
import com.hippo.ehviewer.image.detectBorder import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.util.isAtLeastO
private const val CROP_THRESHOLD = 0.75f private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2 private const val RATIO_THRESHOLD = 2
@@ -30,59 +26,33 @@ val ImageRequest.maybeCropBorder: Boolean
class BitmapImageWithRect( class BitmapImageWithRect(
val rect: IntRect, val rect: IntRect,
val image: Image, val image: BitmapImage,
) : Image by image ) : Image by image
object CropBorderInterceptor : Interceptor { object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult { override suspend fun intercept(chain: Chain): ImageResult {
val req = chain.request val result = chain.proceed()
if (req.maybeCropBorder) { if (chain.request.maybeCropBorder && result is SuccessResult) {
val hw = req.allowHardware && isAtLeastO val image = result.image
val result = if (hw) { if (image is BitmapImage) {
val nonHw = req.newBuilder().allowHardware(false).build() val bitmap = image.bitmap
chain.withRequest(nonHw).proceed() val ratio = if (image.height > image.width) {
} else { image.height / image.width
chain.proceed() } else {
} image.width / image.height
if (result is SuccessResult) { }
val image = result.image if (ratio < RATIO_THRESHOLD) {
if (image is BitmapImage) { val array = detectBorder(bitmap)
val bitmap = image.bitmap val minWidth = image.width * CROP_THRESHOLD
val ratio = if (image.height > image.width) { val minHeight = image.height * CROP_THRESHOLD
image.height / image.width val rect = IntRect(array[0], array[1], array[2], array[3])
} else { if (rect.width > minWidth && rect.height > minHeight) {
image.width / image.height val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
} }
val rect = if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
IntRect(array[0], array[1], array[2], array[3]).takeIf {
it.width > minWidth && it.height > minHeight
}
} else {
null
}
val maybeHwImage = if (hw) {
// Upload to Graphical Buffer to accelerate render
// May fail if the image is too long
bitmap.copy(Bitmap.Config.HARDWARE, false)?.run {
bitmap.recycle()
asCoilImage()
} ?: image
} else {
image
}
val new = if (rect != null) {
BitmapImageWithRect(rect, maybeHwImage)
} else {
maybeHwImage
}
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
} }
} }
return result
} }
return chain.proceed() return result
} }
} }
@@ -7,28 +7,20 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile import com.hippo.files.isFile
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor { object DownloadThumbInterceptor : Interceptor {
// TODO: Remove this after a few releases
private const val LEGACY_THUMB_FILE = ".thumb"
private const val THUMB_FILE = "thumb.jpg" private const val THUMB_FILE = "thumb.jpg"
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) { if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl) val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) { if (location != null) {
val thumb = withIOContext { val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
val legacyThumb = downloadLocation / location / LEGACY_THUMB_FILE
if (legacyThumb.isFile) {
legacyThumb.apply { renameTo(THUMB_FILE) }
} else {
downloadLocation / location / THUMB_FILE
}
}
if (withIOContext { thumb.isFile }) { if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build() val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed() val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result if (result is SuccessResult) return result
} }
@@ -38,11 +30,9 @@ object DownloadThumbInterceptor : Interceptor {
withIOContext { withIOContext {
// Accessing the recreated file immediately after deleting it throws // Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file. // FileNotFoundException, so we just overwrite the existing file.
if (thumb.ensureFile()) { val key = requireNotNull(chain.request.memoryCacheKey)
val key = requireNotNull(chain.request.memoryCacheKey) imageCache.read(key) {
imageCache.read(key) { data sendTo thumb
data.asUniFile() sendTo thumb
}
} }
} }
} }
@@ -2,7 +2,7 @@ package com.hippo.ehviewer.coil
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import coil3.asCoilImage import coil3.asImage
import coil3.decode.DecodeResult import coil3.decode.DecodeResult
import coil3.decode.Decoder import coil3.decode.Decoder
import coil3.network.NetworkHeaders import coil3.network.NetworkHeaders
@@ -42,7 +42,7 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
httpHeaders(header) httpHeaders(header)
} }
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true) private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
private val stubResult = DecodeResult(stubImage, false) private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult } private val stubFactory = Decoder { stubResult }
@@ -1,24 +1,57 @@
package com.hippo.ehviewer.coil package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
import coil3.intercept.Interceptor import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain import coil3.intercept.Interceptor.Chain
import coil3.request.ErrorResult import coil3.request.ImageRequest
import coil3.request.ImageResult import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware import coil3.request.allowHardware
/** private val hardwareThresholdKey = Extras.Key(default = 16384)
* Allocating hardware bitmaps may fail on extremely long images.
* fun ImageRequest.Builder.hardwareThreshold(size: Int) = apply {
* Retry with [allowHardware] disabled. extras[hardwareThresholdKey] = size
*/ }
val ImageRequest.hardwareThreshold: Int
get() = getExtra(hardwareThresholdKey)
@RequiresApi(Build.VERSION_CODES.O)
object HardwareBitmapInterceptor : Interceptor { object HardwareBitmapInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult { override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed() val result = chain.proceed()
return if (chain.request.allowHardware && result is ErrorResult && result.throwable is OutOfMemoryError) { val request = result.request
val req = chain.request.newBuilder().allowHardware(false).build() if (!request.allowHardware && result is SuccessResult) {
chain.withRequest(req).proceed() var rect: IntRect? = null
} else { val image = result.image
result val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
}
}
} }
return result
} }
} }
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult { override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() } val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) { synchronized(pendingContinuationMapLock) {
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
} }
try { try {
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply { return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache // Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch { notifyScope.launch {
synchronized(pendingContinuationMapLock) { synchronized(pendingContinuationMapLock) {
@@ -1,11 +0,0 @@
package com.hippo.ehviewer.cronet
import io.ktor.utils.io.pool.DirectByteBufferPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
val pool = DirectByteBufferPool(32)
// Limit thread to 1 since we are async & non-blocking
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.cronet package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings import com.hippo.ehviewer.Settings
import okio.Path.Companion.toOkioPath import java.io.File
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import splitties.init.appCtx import splitties.init.appCtx
@@ -10,8 +10,8 @@ val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
setUserAgent(Settings.userAgent) setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer // Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() } val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() }
setStoragePath(cache.absolutePath) setStoragePath(cache.path)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096) enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443) addQuicHint("e-hentai.org", 443, 443)
@@ -37,8 +37,7 @@ import splitties.init.appCtx
private const val CHANNEL_ID = "DailyCheckNotification" private const val CHANNEL_ID = "DailyCheckNotification"
class DailyCheckWork(context: Context, workerParams: WorkerParameters) : class DailyCheckWork(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withIOContext { override suspend fun doWork(): Result = withIOContext {
checkDawn().onFailure { checkDawn().onFailure {
return@withIOContext Result.retry() return@withIOContext Result.retry()
@@ -72,7 +72,8 @@ data class DownloadInfo(
@Embedded @Embedded
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid), val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo { ) : GalleryInfo by galleryInfo,
AbstractDownloadInfo by downloadInfo {
companion object { companion object {
const val STATE_INVALID = -1 const val STATE_INVALID = -1
@@ -38,17 +38,18 @@ import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderQueen import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.spider.putToDownloadDir import com.hippo.ehviewer.spider.downloadDirname
import com.hippo.ehviewer.spider.readComicInfo import com.hippo.ehviewer.spider.readComicInfo
import com.hippo.ehviewer.spider.readCompatFromUniFile import com.hippo.ehviewer.spider.readCompatFromPath
import com.hippo.ehviewer.spider.toSimpleTags import com.hippo.ehviewer.spider.toSimpleTags
import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.insertWith import com.hippo.ehviewer.util.insertWith
import com.hippo.ehviewer.util.mapNotNull import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile import com.hippo.files.delete
import com.hippo.unifile.asUniFile import com.hippo.files.find
import com.hippo.unifile.asUniFileOrNull import com.hippo.files.toOkioPath
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -61,6 +62,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import logcat.LogPriority import logcat.LogPriority
import okio.Path
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import splitties.preferences.edit import splitties.preferences.edit
object DownloadManager : OnSpiderListener, CoroutineScope { object DownloadManager : OnSpiderListener, CoroutineScope {
@@ -172,7 +176,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
} }
} else { } else {
// It is new download info // It is new download info
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir()) info = DownloadInfo(galleryInfo, galleryInfo.downloadDirname())
info.label = label info.label = label
info.state = DownloadInfo.STATE_WAIT info.state = DownloadInfo.STATE_WAIT
// Add to all download list and map // Add to all download list and map
@@ -541,7 +545,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
} }
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) -> }.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
info.downloadDir?.run { info.downloadDir?.run {
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) } val comicInfo = find(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) { if (comicInfo != null) {
val galleryInfo = if (updateGallery) { val galleryInfo = if (updateGallery) {
info.pages = comicInfo.pageCount info.pages = comicInfo.pageCount
@@ -564,8 +568,8 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
null null
} }
} else if (info.pages == 0) { } else if (info.pages == 0) {
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let { val galleryInfo = find(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run { readCompatFromPath(it)?.run {
info.pages = pages info.pages = pages
info.galleryInfo info.galleryInfo
} }
@@ -605,6 +609,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
override fun onGet509(index: Int) { override fun onGet509(index: Int) {
launch { launch {
mDownloadListener?.onGet509() mDownloadListener?.onGet509()
stopAllDownload()
} }
} }
@@ -626,7 +631,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
info.finished = finished info.finished = finished
info.downloaded = downloaded info.downloaded = downloaded
info.total = total info.total = total
mDownloadListener?.onGetPage(info) mDownloadListener?.onDownload(info)
mutableNotifyFlow.emit(info) mutableNotifyFlow.emit(info)
} ?: logcat(TAG, LogPriority.ERROR) { "Current task is null, but it should not be" } } ?: logcat(TAG, LogPriority.ERROR) { "Current task is null, but it should not be" }
} }
@@ -702,11 +707,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
*/ */
fun onDownload(info: DownloadInfo) fun onDownload(info: DownloadInfo)
/**
* Update page downloaded
*/
fun onGetPage(info: DownloadInfo)
/** /**
* Download done * Download done
*/ */
@@ -813,18 +813,21 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator() private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
} }
var downloadLocation: UniFile var downloadLocation: Path
get() = with(Settings) { get() = with(Settings) {
val uri = Uri.Builder().apply { if (downloadScheme != null) {
scheme(downloadScheme) Uri.Builder().apply {
encodedAuthority(downloadAuthority) scheme(downloadScheme)
encodedPath(downloadPath) encodedAuthority(downloadAuthority)
encodedQuery(downloadQuery) encodedPath(downloadPath)
encodedFragment(downloadFragment) encodedQuery(downloadQuery)
}.build() encodedFragment(downloadFragment)
uri.asUniFileOrNull() ?: AppConfig.defaultDownloadDir?.asUniFile() ?: UniFile.Stub }.build().toOkioPath()
} else {
AppConfig.defaultDownloadDir?.toOkioPath() ?: "".toPath()
}
} }
set(value) = with(value.uri) { set(value) = with(value.toUri()) {
Settings.edit { Settings.edit {
downloadScheme = scheme downloadScheme = scheme
downloadAuthority = encodedAuthority downloadAuthority = encodedAuthority
@@ -835,5 +838,5 @@ var downloadLocation: UniFile
} }
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it } val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.run { findFile("$gid.cbz") ?: findFile("$gid.zip") } val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid") val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
@@ -21,8 +21,6 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.SystemClock
import androidx.annotation.IntDef
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
@@ -36,55 +34,57 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.getParcelableExtraCompat import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.unsafeLazy
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineScope { class DownloadService :
Service(),
DownloadManager.DownloadListener,
CoroutineScope {
override val coroutineContext = Dispatchers.IO + SupervisorJob() override val coroutineContext = Dispatchers.IO + SupervisorJob()
private val deferredMgr = async { DownloadManager.apply { setDownloadListener(this@DownloadService) } } private val deferredMgr = async { DownloadManager }
private var mNotifyManager: NotificationManagerCompat? = null private val notifyManager by unsafeLazy { NotificationManagerCompat.from(this) }
private var mDownloadingBuilder: NotificationCompat.Builder? = null private val downloadingNotification by unsafeLazy { initDownloadingNotification() }
private var mDownloadedBuilder: NotificationCompat.Builder? = null private val downloadedNotification by lazy { initDownloadedNotification() }
private var m509dBuilder: NotificationCompat.Builder? = null private val error509Notification by lazy { init509Notification() }
private var mDownloadingDelay: NotificationDelay? = null private val channelId by unsafeLazy { "$packageName.download" }
private var mDownloadedDelay: NotificationDelay? = null
private var m509Delay: NotificationDelay? = null
private var mChannelID: String? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() notifyManager.createNotificationChannel(
mChannelID = "$packageName.download" NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
mNotifyManager = NotificationManagerCompat.from(this)
mNotifyManager!!.createNotificationChannel(
NotificationChannelCompat.Builder(mChannelID!!, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.download_service)).build(), .setName(getString(R.string.download_service)).build(),
) )
ensureDownloadingBuilder() downloadingNotification.builder.run {
mDownloadingBuilder!!.setContentTitle(getString(R.string.download_service)) setContentTitle(getString(R.string.download_service))
.setContentText(null) .setContentText(null)
.setSubText(null) .setSubText(null)
.setProgress(0, 0, true) .setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, mDownloadingBuilder!!.build()) startForeground(ID_DOWNLOADING, build())
}
launch {
deferredMgr.await().setDownloadListener(this@DownloadService)
}
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy()
val scope = this val scope = this
launch { launch {
deferredMgr.await().setDownloadListener(null) deferredMgr.await().setDownloadListener(null)
// Wait for the last notification to be posted
delay(DELAY)
scope.cancel() scope.cancel()
} }
mNotifyManager = null
mDownloadingBuilder = null
mDownloadedBuilder = null
m509dBuilder = null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -152,17 +152,14 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
} }
} }
override fun onBind(intent: Intent) = error("No bindService") override fun onBind(intent: Intent) = null
private fun ensureDownloadingBuilder() { private fun initDownloadingNotification(): NotificationHandler {
if (mDownloadingBuilder != null) {
return
}
val stopAllIntent = Intent(this, DownloadService::class.java) val stopAllIntent = Intent(this, DownloadService::class.java)
stopAllIntent.action = ACTION_STOP_ALL stopAllIntent.action = ACTION_STOP_ALL
val piStopAll = val piStopAll =
PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE) PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE)
mDownloadingBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!) val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
@@ -173,15 +170,11 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
piStopAll, piStopAll,
) )
.setShowWhen(false) .setShowWhen(false)
.setChannelId(mChannelID!!) return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADING)
mDownloadingDelay = .apply { launch { run() } }
NotificationDelay(this, mNotifyManager!!, mDownloadingBuilder!!, ID_DOWNLOADING)
} }
private fun ensureDownloadedBuilder() { private fun initDownloadedNotification(): NotificationHandler {
if (mDownloadedBuilder != null) {
return
}
val clearIntent = Intent(this, DownloadService::class.java) val clearIntent = Intent(this, DownloadService::class.java)
clearIntent.action = ACTION_CLEAR clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE) val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
@@ -191,29 +184,26 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
activityIntent.action = ACTION_START_DOWNLOADSCENE activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle) activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
val piActivity = PendingIntent.getActivity( val piActivity = PendingIntent.getActivity(
this@DownloadService, this,
0, 0,
activityIntent, activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
mDownloadedBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!) val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(getString(R.string.stat_download_done_title)) .setContentTitle(getString(R.string.stat_download_done_title))
.setDeleteIntent(piClear) .setDeleteIntent(piClear)
.setOngoing(false) .setOngoing(false)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(piActivity) .setContentIntent(piActivity)
mDownloadedDelay = return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADED)
NotificationDelay(this, mNotifyManager!!, mDownloadedBuilder!!, ID_DOWNLOADED) .apply { launch { run() } }
} }
private fun ensure509Builder() { private fun init509Notification(): NotificationHandler {
if (m509dBuilder != null) { val builder = NotificationCompat.Builder(applicationContext, channelId)
return
}
m509dBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
.setSmallIcon(R.drawable.ic_baseline_warning_24) .setSmallIcon(R.drawable.ic_baseline_warning_24)
.setContentText(getString(R.string.stat_509_alert_title)) .setContentTitle(getString(R.string.stat_509_alert_title))
.setContentText(getString(R.string.stat_509_alert_text)) .setContentText(getString(R.string.stat_509_alert_text))
.setStyle( .setStyle(
NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)), NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)),
@@ -221,89 +211,61 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
.setAutoCancel(true) .setAutoCancel(true)
.setOngoing(false) .setOngoing(false)
.setCategory(NotificationCompat.CATEGORY_ERROR) .setCategory(NotificationCompat.CATEGORY_ERROR)
m509Delay = NotificationDelay(this, mNotifyManager!!, m509dBuilder!!, ID_509) return NotificationHandler(this, notifyManager, builder, ID_509)
.apply { launch { run() } }
} }
override fun onGet509() { override fun onGet509() {
launch { error509Notification.run {
deferredMgr.await().stopAllDownload() builder.setWhen(System.currentTimeMillis())
if (mNotifyManager == null) { show()
return@launch
}
ensure509Builder()
m509dBuilder!!.setWhen(System.currentTimeMillis())
m509Delay!!.show()
} }
} }
override fun onStart(info: DownloadInfo) { override fun onStart(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
ensureDownloadingBuilder()
val bundle = Bundle() val bundle = Bundle()
bundle.putLong(KEY_GID, info.gid) bundle.putLong(KEY_GID, info.gid)
val activityIntent = Intent(this, MainActivity::class.java) val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle) activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
val piActivity = PendingIntent.getActivity( val piActivity = PendingIntent.getActivity(
this@DownloadService, this,
0, 0,
activityIntent, activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info)) downloadingNotification.run {
.setContentText(null) builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setSubText(null) .setContentText(null)
.setProgress(0, 0, true) .setSubText(null)
.setContentIntent(piActivity) .setProgress(0, 0, true)
mDownloadingDelay!!.startForeground() .setContentIntent(piActivity)
} startForeground()
private fun onUpdate(info: DownloadInfo) {
if (mNotifyManager == null) {
return
} }
ensureDownloadingBuilder()
var speed = info.speed
if (speed < 0) {
speed = 0
}
var text = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
text = if (remaining >= 0) {
getString(
R.string.download_speed_text_2,
text,
ReadableTime.getShortTimeInterval(remaining),
)
} else {
getString(R.string.download_speed_text, text)
}
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
mDownloadingDelay!!.startForeground()
} }
override fun onDownload(info: DownloadInfo) { override fun onDownload(info: DownloadInfo) {
onUpdate(info) val speed = info.speed.coerceAtLeast(0)
} val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
override fun onGetPage(info: DownloadInfo) { val text = if (remaining >= 0) {
onUpdate(info) val interval = ReadableTime.getShortTimeInterval(remaining)
getString(R.string.download_speed_text_2, speedText, interval)
} else {
getString(R.string.download_speed_text, speedText)
}
downloadingNotification.run {
builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
startForeground()
}
} }
override fun onFinish(info: DownloadInfo) { override fun onFinish(info: DownloadInfo) {
if (mNotifyManager == null) { downloadingNotification.cancel()
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
ensureDownloadedBuilder()
val finish = info.state == DownloadInfo.STATE_FINISH val finish = info.state == DownloadInfo.STATE_FINISH
val gid = info.gid val gid = info.gid
val index = sItemStateArray.indexOfKey(gid) val index = sItemStateArray.indexOfKey(gid)
@@ -392,21 +354,18 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
} else { } else {
style = null style = null
} }
mDownloadedBuilder!!.setContentText(text) downloadedNotification.run {
.setStyle(style) builder.setContentText(text)
.setWhen(System.currentTimeMillis()) .setStyle(style)
.setNumber(sDownloadedCount) .setWhen(System.currentTimeMillis())
mDownloadedDelay!!.show() .setNumber(sDownloadedCount)
show()
}
checkStopSelf() checkStopSelf()
} }
override fun onCancel(info: DownloadInfo) { override fun onCancel(info: DownloadInfo) {
if (mNotifyManager == null) { downloadingNotification.cancel()
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
checkStopSelf() checkStopSelf()
} }
@@ -419,105 +378,49 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
} }
} }
private class NotificationDelay( private class NotificationHandler(
private var mService: Service, private val service: Service,
private val mNotifyManager: NotificationManagerCompat, private val notifyManager: NotificationManagerCompat,
private val mBuilder: NotificationCompat.Builder, val builder: NotificationCompat.Builder,
private val mId: Int, private val id: Int,
) : Runnable { ) {
private var mLastTime: Long = 0 private val channel = Channel<Ops>(Channel.CONFLATED)
private var mPosted = false
// false for show, true for cancel
@Ops
private var mOps = 0
fun show() { fun show() {
if (mPosted) { channel.trySend(Ops.Notify)
mOps = OPS_NOTIFY
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
if (ActivityCompat.checkSelfPermission(
mService,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
mNotifyManager.notify(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_NOTIFY
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
mLastTime = now
}
} }
fun cancel() { fun cancel() {
if (mPosted) { channel.trySend(Ops.Cancel)
mOps = OPS_CANCEL
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mNotifyManager.cancel(mId)
} else {
// Too quick, post delay
mOps = OPS_CANCEL
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
} }
fun startForeground() { fun startForeground() {
if (mPosted) { channel.trySend(Ops.StartForeground)
mOps = OPS_START_FOREGROUND
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mService.startForeground(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_START_FOREGROUND
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
} }
override fun run() { suspend fun run() {
mPosted = false channel.receiveAsFlow().sample(DELAY).collect {
when (mOps) { when (it) {
OPS_NOTIFY -> { Ops.Notify -> {
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
mService, service,
Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
return notifyManager.notify(id, builder.build())
}
} }
mNotifyManager.notify(mId, mBuilder.build())
}
OPS_CANCEL -> mNotifyManager.cancel(mId) Ops.Cancel -> notifyManager.cancel(id)
OPS_START_FOREGROUND -> mService.startForeground(mId, mBuilder.build()) Ops.StartForeground -> service.startForeground(id, builder.build())
}
} }
} }
@IntDef(OPS_NOTIFY, OPS_CANCEL, OPS_START_FOREGROUND) private enum class Ops {
@Retention(AnnotationRetention.SOURCE) Notify,
private annotation class Ops Cancel,
companion object { StartForeground,
private const val OPS_NOTIFY = 0
private const val OPS_CANCEL = 1
private const val OPS_START_FOREGROUND = 2
private const val DELAY: Long = 1000 // 1s
} }
} }
@@ -544,6 +447,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
private const val ID_DOWNLOADING = 1 private const val ID_DOWNLOADING = 1
private const val ID_DOWNLOADED = 2 private const val ID_DOWNLOADED = 2
private const val ID_509 = 3 private const val ID_509 = 3
private const val DELAY = 1000L // 1s
private val sItemStateArray = LongSparseArray<Boolean>() private val sItemStateArray = LongSparseArray<Boolean>()
private val sItemTitleArray = LongSparseArray<String>() private val sItemTitleArray = LongSparseArray<String>()
private var sFailedCount = 0 private var sFailedCount = 0
@@ -28,8 +28,9 @@ import com.hippo.ehviewer.jni.openArchive
import com.hippo.ehviewer.jni.providePassword import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile import com.hippo.ehviewer.util.displayPath
import com.hippo.unifile.displayPath import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@@ -41,21 +42,22 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock import moe.tarsin.coroutines.withLock
import okio.Path
typealias PasswdInvalidator = (String) -> Boolean typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String typealias PasswdProvider = suspend (PasswdInvalidator) -> String
class ArchivePageLoader( class ArchivePageLoader(
private val file: UniFile, private val file: Path,
gid: Long = 0, gid: Long = 0,
startPage: Int = 0, startPage: Int = 0,
passwdProvider: PasswdProvider? = null, passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) { ) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) { private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" } logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r") pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name!!.endsWith(".zip")) size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size == 0) { if (size == 0) {
return@launch return@launch
} }
@@ -81,7 +83,7 @@ class ArchivePageLoader(
super.stop() super.stop()
closeArchive() closeArchive()
pfd.close() pfd.close()
logcat(DEBUG_TAG) { "Close archive ${file.uri.displayPath} successfully!" } logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
} }
private val mJobMap = hashMapOf<Int, Job>() private val mJobMap = hashMapOf<Int, Job>()
@@ -149,19 +151,15 @@ class ArchivePageLoader(
FileUtils.getNameFromFilename(file.name)!! FileUtils.getNameFromFilename(file.name)!!
} }
override fun getImageExtension(index: Int): String { override fun getImageExtension(index: Int): String = getExtension(index)
return getExtension(index)
}
override fun save(index: Int, file: UniFile): Boolean { override fun save(index: Int, file: Path): Boolean = runCatching {
return runCatching { file.openFileDescriptor("w").use {
file.openFileDescriptor("w").use { extractToFd(index, it.fd)
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
} }
}.getOrElse {
logcat(it)
false
} }
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {} override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
@@ -22,10 +22,11 @@ import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.unifile.UniFile import okio.Path
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) : class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
PageLoader2(mGalleryInfo.gid, startPage), OnSpiderListener { PageLoader2(mGalleryInfo.gid, startPage),
OnSpiderListener {
private lateinit var mSpiderQueen: SpiderQueen private lateinit var mSpiderQueen: SpiderQueen
override fun start() { override fun start() {
super.start() super.start()
@@ -45,13 +46,9 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
EhUtils.getSuitableTitle(mGalleryInfo) EhUtils.getSuitableTitle(mGalleryInfo)
} }
override fun getImageExtension(index: Int): String? { override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
return mSpiderQueen.getExtension(index)
}
override fun save(index: Int, file: UniFile): Boolean { override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
return mSpiderQueen.save(index, file)
}
override val size: Int override val size: Int
get() = mSpiderQueen.size get() = mSpiderQueen.size
@@ -64,9 +61,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
mSpiderQueen.forceRequest(index, orgImg) mSpiderQueen.forceRequest(index, orgImg)
} }
override suspend fun awaitReady(): Boolean { override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
return super.awaitReady() && mSpiderQueen.awaitReady()
}
override val isReady: Boolean override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
@@ -18,7 +18,6 @@ package com.hippo.ehviewer.gallery
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@@ -26,8 +25,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okio.Path
abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoader(), CoroutineScope { abstract class PageLoader2(private val gid: Long, var startPage: Int) :
PageLoader(),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job() override val coroutineContext = Dispatchers.IO + Job()
private val progressJob = launch(start = CoroutineStart.LAZY) { private val progressJob = launch(start = CoroutineStart.LAZY) {
if (startPage == -1) { if (startPage == -1) {
@@ -66,9 +68,5 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}") FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
} }
abstract fun save(index: Int, file: UniFile): Boolean abstract fun save(index: Int, file: Path): Boolean
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
(dir / filename).takeIf { save(index, it) }
}
} }
@@ -30,10 +30,12 @@ import coil3.imageLoader
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ErrorResult import coil3.request.ErrorResult
import coil3.request.SuccessResult import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Dimension import coil3.size.Dimension
import coil3.size.Precision import coil3.size.Precision
import com.hippo.ehviewer.Settings import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap import com.hippo.ehviewer.jni.mmap
@@ -42,9 +44,11 @@ import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.ktbuilder.imageRequest import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastP import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastU import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: AutoCloseable) { class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
@@ -75,12 +79,14 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
companion object { companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage { private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest { val req = appCtx.imageRequest {
onLeft { data(it.source) } onLeft { data(it.source) }
onRight { data(it.source.uri) } onRight { data(it.source.toUri()) }
size(Dimension(targetWidth), Dimension.Undefined) size(Dimension(targetWidth), Dimension.Undefined)
precision(Precision.INEXACT) precision(Precision.INEXACT)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value) maybeCropBorder(Settings.cropBorder.value)
memoryCachePolicy(CachePolicy.DISABLED) memoryCachePolicy(CachePolicy.DISABLED)
} }
@@ -93,7 +99,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
suspend fun decode(src: ImageSource): Image? { suspend fun decode(src: ImageSource): Image? {
return runCatching { return runCatching {
val image = when (src) { val image = when (src) {
is UniFileSource -> { is PathSource -> {
if (isAtLeastP && !isAtLeastU) { if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use { src.source.openFileDescriptor("rw").use {
val fd = it.fd val fd = it.fd
@@ -133,8 +139,8 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
sealed interface ImageSource : AutoCloseable sealed interface ImageSource : AutoCloseable
interface UniFileSource : ImageSource { interface PathSource : ImageSource {
val source: UniFile val source: Path
val type: String val type: String
} }
@@ -0,0 +1,9 @@
package com.hippo.ehviewer.ktor
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineFactory
object Cronet : HttpClientEngineFactory<CronetConfig> {
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
CronetEngine(CronetConfig().apply(block))
}
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.ktor
import android.content.Context
import io.ktor.client.engine.HttpClientEngineConfig
import org.chromium.net.CronetEngine
class CronetConfig : HttpClientEngineConfig() {
var client: CronetEngine? = null
fun config(context: Context, block: CronetEngine.Builder.() -> Unit) {
client = CronetEngine.Builder(context).apply(block).build()
}
}
@@ -1,11 +1,6 @@
package com.hippo.ehviewer.ktor 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.HttpClientEngineBase
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.callContext import io.ktor.client.engine.callContext
import io.ktor.client.request.HttpRequestData import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData import io.ktor.client.request.HttpResponseData
@@ -18,12 +13,15 @@ import io.ktor.util.date.GMTDate
import io.ktor.util.flattenForEach import io.ktor.util.flattenForEach
import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writer import io.ktor.utils.io.writer
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job import kotlinx.coroutines.job
@@ -33,9 +31,12 @@ import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo import org.chromium.net.UrlResponseInfo
import org.chromium.net.apihelpers.UploadDataProviders import org.chromium.net.apihelpers.UploadDataProviders
object CronetEngine : HttpClientEngineBase("Cronet") { class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
override val config = HttpClientEngineConfig() // Limit thread to 1 since we are async & non-blocking
override val dispatcher = cronetDispatcher override val dispatcher = Dispatchers.Default.limitedParallelism(1)
private val executor = dispatcher.asExecutor()
private val pool = DirectByteBufferPool(32)
private val client = config.client ?: error("Cronet client is not configured")
@InternalAPI @InternalAPI
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data) override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
@@ -94,12 +95,12 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
} }
} }
cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply { client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply {
setHttpMethod(data.method.value) setHttpMethod(data.method.value)
data.headers.flattenForEach { key, value -> addHeader(key, value) } data.headers.flattenForEach { key, value -> addHeader(key, value) }
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) } data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) } data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, cronetHttpClientExecutor) } data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, executor) }
}.build().apply { }.build().apply {
start() start()
callContext.job.invokeOnCompletion { cancel() } callContext.job.invokeOnCompletion { cancel() }
@@ -7,17 +7,11 @@ import okio.source
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo { fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
val source = inputStream.source().buffer() val source = inputStream.source().buffer()
fun read(): String { fun read(): String = source.readUtf8LineStrict()
return source.readUtf8LineStrict()
}
fun readInt(): Int { fun readInt(): Int = read().toInt()
return read().toInt()
}
fun readLong(): Long { fun readLong(): Long = read().toLong()
return read().toLong()
}
repeat(2) { read() } // We assert that only info v2 repeat(2) { read() } // We assert that only info v2
val gid = readLong() val gid = readLong()
val token = read() val token = read()
@@ -5,11 +5,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.SimpleTagsConverter import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.unifile.UniFile import com.hippo.files.openInputStream
import com.hippo.unifile.openInputStream import com.hippo.files.openOutputStream
import com.hippo.unifile.openOutputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -18,15 +15,19 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.XmlUtilInternal
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newGenericWriter import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlElement import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.xmlStreaming import nl.adaptivity.xmlutil.xmlStreaming
import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml" const val COMIC_INFO_FILE = "ComicInfo.xml"
private const val TAG_ORIGINAL = "original" private const val TAG_ORIGINAL = "original"
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
@OptIn(XmlUtilInternal::class)
private val xml = XML { private val xml = XML {
recommended { recommended {
ignoreUnknownChildren() ignoreUnknownChildren()
@@ -97,22 +98,18 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
teams, teams,
).flatten().ifEmpty { null } ).flatten().ifEmpty { null }
fun ComicInfo.write(file: UniFile) { fun ComicInfo.write(file: Path) {
file.openOutputStream().use { file.openOutputStream().bufferedWriter().use {
OutputStreamWriter(it, Charsets.UTF_8).use { writer -> xmlStreaming.newWriter(it).use { writer ->
xmlStreaming.newGenericWriter(writer).use { xmlWriter -> xml.encodeToWriter(writer, ComicInfo.serializer(), this)
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
}
} }
} }
} }
fun readComicInfo(file: UniFile): ComicInfo? = runCatching { fun readComicInfo(file: Path): ComicInfo? = runCatching {
file.openInputStream().use { file.openInputStream().bufferedReader().use {
InputStreamReader(it, Charsets.UTF_8).use { reader -> xmlStreaming.newReader(it).use { reader ->
xmlStreaming.newGenericReader(reader).use { xmlReader -> xml.decodeFromReader(ComicInfo.serializer(), reader)
xml.decodeFromReader(ComicInfo.serializer(), xmlReader)
}
} }
} }
}.getOrNull() }.getOrNull()
@@ -18,12 +18,10 @@ object DownloadInfoMagics {
} }
} }
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> { fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
return if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) { val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR) a to b
a to b } else {
} else { encoded to null
encoded to null
}
} }
} }
@@ -36,13 +36,18 @@ import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.download.DownloadManager import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadLocation import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.image.UniFileSource import com.hippo.ehviewer.image.PathSource
import com.hippo.ehviewer.jni.archiveFdBatch import com.hippo.ehviewer.jni.archiveFdBatch
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile import com.hippo.ehviewer.util.sha1
import com.hippo.unifile.asUniFile import com.hippo.files.delete
import com.hippo.unifile.openOutputStream import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
@@ -52,16 +57,23 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo import io.ktor.utils.io.jvm.nio.copyTo
import kotlin.io.path.readText import kotlin.io.path.readText
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import okio.Path
class SpiderDen(val info: GalleryInfo) { class SpiderDen(val info: GalleryInfo) {
private val gid = info.gid private val gid = info.gid
var downloadDir: UniFile? = null var downloadDir: Path? = null
private set private set
private var tempDownloadDir: UniFile? = null private var tempDownloadDir: Path? = null
private val saveAsCbz = Settings.saveAsCbz private val saveAsCbz = Settings.saveAsCbz
private val archiveName = "$gid.cbz" private val archiveName = "$gid.cbz"
// Search in both directories to maintain compatibility
private val fileCache by lazy {
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
.associateBy { it.name } as MutableMap
}
private val imageDir private val imageDir
get() = tempDownloadDir.takeIf { saveAsCbz } ?: downloadDir get() = tempDownloadDir.takeIf { saveAsCbz } ?: downloadDir
@@ -78,10 +90,10 @@ class SpiderDen(val info: GalleryInfo) {
mode = value mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) { if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) { if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() } downloadDir = getGalleryDownloadDir(info)
} }
if (saveAsCbz && tempDownloadDir == null) { if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir?.takeIf { it.ensureDir() } tempDownloadDir = info.tempDownloadDir!!
} }
} }
} }
@@ -91,14 +103,12 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.read(key) { true } ?: false return sCache.read(key) { true } ?: false
} }
// Search in both directories to maintain compatibility private fun findImageFile(index: Int) = synchronized(fileCache) {
private fun findImageFile(index: Int): UniFile? { val head = perFilename(index)
return tempDownloadDir?.findImageFile(index) ?: downloadDir?.findImageFile(index) fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
} }
private fun containInDownloadDir(index: Int): Boolean { private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
return findImageFile(index) != null
}
private fun copyFromCacheToDownloadDir(index: Int): Boolean { private fun copyFromCacheToDownloadDir(index: Int): Boolean {
val dir = imageDir ?: return false val dir = imageDir ?: return false
@@ -106,8 +116,8 @@ class SpiderDen(val info: GalleryInfo) {
return runCatching { return runCatching {
sCache.read(key) { sCache.read(key) {
val extension = metadata.toFile().readText() val extension = metadata.toFile().readText()
val file = dir.createFile(perFilename(index, extension)) ?: return false val file = dir.findDownloadFileForIndex(index, extension)
data.asUniFile() sendTo file data sendTo file
true true
} ?: false } ?: false
}.onFailure { }.onFailure {
@@ -115,19 +125,17 @@ class SpiderDen(val info: GalleryInfo) {
}.getOrDefault(false) }.getOrDefault(false)
} }
operator fun contains(index: Int): Boolean { operator fun contains(index: Int): Boolean = when (mode) {
return when (mode) { SpiderQueen.MODE_READ -> {
SpiderQueen.MODE_READ -> { containInCache(index) || containInDownloadDir(index)
containInCache(index) || containInDownloadDir(index) }
}
SpiderQueen.MODE_DOWNLOAD -> { SpiderQueen.MODE_DOWNLOAD -> {
containInDownloadDir(index) || copyFromCacheToDownloadDir(index) containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
} }
else -> { else -> {
false false
}
} }
} }
@@ -136,16 +144,20 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key) return sCache.remove(key)
} }
private fun removeFromDownloadDir(index: Int): Boolean { private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
return findImageFile(index)?.delete() ?: false delete()
synchronized(fileCache) {
fileCache.remove(name)
}
} }
fun remove(index: Int): Boolean { fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
return removeFromCache(index) or removeFromDownloadDir(index)
}
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? { private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
return imageDir?.createFile(perFilename(index, extension)) val name = perFilename(index, extension)
return synchronized(fileCache) {
fileCache.getOrPut(name) { resolve(name) }
}
} }
suspend fun makeHttpCallAndSaveImage( suspend fun makeHttpCallAndSaveImage(
@@ -170,10 +182,10 @@ class SpiderDen(val info: GalleryInfo) {
private suspend inline fun saveResponseMeta( private suspend inline fun saveResponseMeta(
index: Int, index: Int,
ext: String, ext: String,
crossinline fops: suspend (UniFile) -> Unit, crossinline fops: suspend (Path) -> Unit,
): Boolean { ): Boolean {
findDownloadFileForIndex(index, ext)?.run { imageDir?.run {
fops(this) fops(findDownloadFileForIndex(index, ext))
return true return true
} }
@@ -182,7 +194,7 @@ class SpiderDen(val info: GalleryInfo) {
val key = getImageKey(gid, index) val key = getImageKey(gid, index)
return sCache.suspendEdit(key) { return sCache.suspendEdit(key) {
metadata.toFile().writeText(ext) metadata.toFile().writeText(ext)
fops(data.asUniFile()) fops(data)
} }
} }
return false return false
@@ -195,16 +207,19 @@ class SpiderDen(val info: GalleryInfo) {
outFile.openOutputStream().use { outFile.openOutputStream().use {
response.bodyAsChannel().copyTo(it.channel) response.bodyAsChannel().copyTo(it.channel)
} }
val expected = FileHashRegex.findAll(url).last().groupValues[1]
val actual = outFile.sha1()
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
} }
} }
fun saveToUniFile(index: Int, file: UniFile): Boolean { fun saveToPath(index: Int, file: Path): Boolean {
val key = getImageKey(gid, index) val key = getImageKey(gid, index)
// Read from diskCache first // Read from diskCache first
sCache.read(key) { sCache.read(key) {
runCatching { runCatching {
data.asUniFile() sendTo file data sendTo file
return true return true
}.onFailure { }.onFailure {
logcat(it) logcat(it)
@@ -230,13 +245,13 @@ class SpiderDen(val info: GalleryInfo) {
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) } ?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
} }
fun getImageSource(index: Int): UniFileSource? { fun getImageSource(index: Int): PathSource? {
if (mode == SpiderQueen.MODE_READ) { if (mode == SpiderQueen.MODE_READ) {
val key = getImageKey(gid, index) val key = getImageKey(gid, index)
val snapshot = sCache.openSnapshot(key) val snapshot = sCache.openSnapshot(key)
if (snapshot != null) { if (snapshot != null) {
return object : UniFileSource, AutoCloseable by snapshot { return object : PathSource, AutoCloseable by snapshot {
override val source = snapshot.data.asUniFile() override val source = snapshot.data
override val type by lazy { override val type by lazy {
snapshot.metadata.toNioPath().readText() snapshot.metadata.toNioPath().readText()
} }
@@ -244,7 +259,7 @@ class SpiderDen(val info: GalleryInfo) {
} }
} }
val source = findImageFile(index) ?: return null val source = findImageFile(index) ?: return null
return object : UniFileSource { return object : PathSource {
override val source = source override val source = source
override val type by lazy { override val type by lazy {
FileUtils.getExtensionFromFilename(source.name)!! FileUtils.getExtensionFromFilename(source.name)!!
@@ -257,7 +272,7 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun archive() { suspend fun archive() {
if (saveAsCbz) { if (saveAsCbz) {
downloadDir?.run { downloadDir?.run {
findFile(archiveName) ?: createFile(archiveName)?.let { file -> resolve(archiveName).let { file ->
runCatching { runCatching {
archiveTo(file) archiveTo(file)
}.onFailure { }.onFailure {
@@ -272,23 +287,23 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun postArchive(): Boolean { suspend fun postArchive(): Boolean {
val dir = downloadDir val dir = downloadDir
val archived = saveAsCbz && dir?.findFile(archiveName) != null val archived = saveAsCbz && dir?.find(archiveName) != null
if (archived) { if (archived) {
dir.listFiles().parMap(concurrency = 10) { dir.list().parMap(concurrency = 10) {
if (it.name?.matches(filenamePattern) == true) { if (it.name.matches(FileNameRegex)) {
it.delete() it.delete()
} }
} }
dir.findFile(SpiderQueen.SPIDER_INFO_FILENAME)?.delete() (dir / SpiderQueen.SPIDER_INFO_FILENAME).delete()
tempDownloadDir?.delete() tempDownloadDir?.delete()
} }
return archived return archived
} }
suspend fun exportAsCbz(file: UniFile) = suspend fun exportAsCbz(file: Path) =
downloadDir!!.findFile(archiveName)?.sendTo(file) ?: archiveTo(file) downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
private suspend fun archiveTo(file: UniFile) = resourceScope { private suspend fun archiveTo(file: Path) = resourceScope {
val comicInfo = closeable { val comicInfo = closeable {
val f = downloadDir!! / COMIC_INFO_FILE val f = downloadDir!! / COMIC_INFO_FILE
if (!f.exists()) { if (!f.exists()) {
@@ -308,18 +323,17 @@ class SpiderDen(val info: GalleryInfo) {
} }
suspend fun initDownloadDirIfExist() { suspend fun initDownloadDirIfExist() {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.isDirectory } downloadDir = getGalleryDownloadDir(info).takeIf { it.isDirectory }
tempDownloadDir = info.tempDownloadDir?.takeIf { it.isDirectory } tempDownloadDir = info.tempDownloadDir?.takeIf { it.isDirectory }
} }
suspend fun initDownloadDir() { suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(gid) ?: (downloadLocation / info.putToDownloadDir()) downloadDir = getGalleryDownloadDir(info)
check(downloadDir!!.ensureDir())
} }
suspend fun writeComicInfo(fetchMetadata: Boolean = true) { suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
downloadDir?.run { downloadDir?.run {
createFile(COMIC_INFO_FILE)?.also { resolve(COMIC_INFO_FILE).also {
runCatching { runCatching {
if (info !is GalleryDetail && fetchMetadata) { if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info)) EhEngine.fillGalleryListByApi(listOf(info))
@@ -339,25 +353,22 @@ class SpiderDen(val info: GalleryInfo) {
} }
} }
private val filenamePattern = Regex("^\\d{8}\\.\\w{3,4}") private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
fun perFilename(index: Int, extension: String = ""): String { fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
return "%08d.%s".format(index + 1, extension)
}
private fun UniFile.findImageFile(index: Int): UniFile? { suspend fun GalleryInfo.downloadDirname(): String {
val head = perFilename(index) var dirname = EhDB.getDownloadDirname(gid)
return findFirst { name -> name.startsWith(head) } if (dirname == null) {
} val title = getSuitableTitle(this)
dirname = FileUtils.sanitizeFilename("$gid-$title")
suspend fun GalleryInfo.putToDownloadDir(): String { EhDB.putDownloadDirname(gid, dirname)
val title = getSuitableTitle(this) }
val dirname = FileUtils.sanitizeFilename("$gid-$title")
EhDB.putDownloadDirname(gid, dirname)
return dirname return dirname
} }
suspend fun getGalleryDownloadDir(gid: Long): UniFile? { suspend fun getGalleryDownloadDir(info: GalleryInfo): Path {
val dirname = EhDB.getDownloadDirname(gid) ?: return null val dirname = info.downloadDirname()
return downloadLocation / dirname return downloadLocation / dirname
} }
@@ -4,15 +4,15 @@ import com.hippo.ehviewer.coil.edit
import com.hippo.ehviewer.coil.read import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.ktbuilder.diskCache import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.legacy.readLegacySpiderInfo import com.hippo.ehviewer.legacy.readLegacySpiderInfo
import com.hippo.unifile.UniFile import com.hippo.files.openInputStream
import com.hippo.unifile.openInputStream import com.hippo.files.openOutputStream
import com.hippo.unifile.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.encodeToByteArray
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import okio.Path.Companion.toOkioPath import okio.Path.Companion.toOkioPath
import splitties.init.appCtx import splitties.init.appCtx
@@ -35,7 +35,7 @@ private val cbor = Cbor {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
fun SpiderInfo.write(file: UniFile) { fun SpiderInfo.write(file: Path) {
file.openOutputStream().use { file.openOutputStream().use {
it.write(cbor.encodeToByteArray(this)) it.write(cbor.encodeToByteArray(this))
} }
@@ -58,24 +58,20 @@ private val spiderInfoCache by lazy {
} }
} }
fun readFromCache(gid: Long): SpiderInfo? { fun readFromCache(gid: Long): SpiderInfo? = spiderInfoCache.read(gid.toString()) {
return spiderInfoCache.read(gid.toString()) { runCatching {
runCatching { cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes()) }.onFailure {
}.onFailure { logcat(it)
logcat(it)
}.getOrNull()
}
}
fun readCompatFromUniFile(file: UniFile): SpiderInfo? {
return runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
}.getOrNull() }.getOrNull()
} }
fun readCompatFromPath(file: Path): SpiderInfo? = runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
}.getOrNull()
const val TOKEN_FAILED = "failed" const val TOKEN_FAILED = "failed"
@@ -38,7 +38,7 @@ import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.image.Image import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.displayString import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile import com.hippo.files.find
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -61,6 +61,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx import splitties.init.appCtx
class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope { class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope {
@@ -302,12 +303,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
mWorkerScope.launch(index, force, orgImg) mWorkerScope.launch(index, force, orgImg)
} }
fun save(index: Int, file: UniFile): Boolean { fun save(index: Int, file: Path): Boolean {
val state = getPageState(index) val state = getPageState(index)
return if (STATE_FINISHED != state) { return if (STATE_FINISHED != state) {
false false
} else { } else {
mSpiderDen.saveToUniFile(index, file) mSpiderDen.saveToPath(index, file)
} }
} }
@@ -320,16 +321,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
} }
} }
private fun readSpiderInfoFromLocal(): SpiderInfo? { private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
return mSpiderDen.downloadDir?.run { find(SPIDER_INFO_FILENAME)?.let { file ->
findFile(SPIDER_INFO_FILENAME)?.let { file -> readCompatFromPath(file)?.takeIf {
readCompatFromUniFile(file)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token
it.gid == galleryInfo.gid && it.token == galleryInfo.token
}
} }
} }
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
} }
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) { private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) {
spiderInfo.previewPages = parsePreviewPages(body) spiderInfo.previewPages = parsePreviewPages(body)
@@ -349,21 +348,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
} }
} }
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? { private suspend fun readSpiderInfoFromInternet(): SpiderInfo? = runSuspendCatching {
return runSuspendCatching { ehRequest(
ehRequest( getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false), referer,
referer, ).fetchUsingAsText {
).fetchUsingAsText { val pages = parsePages(this)
val pages = parsePages(this) val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages) readPreviews(this, 0, spiderInfo)
readPreviews(this, 0, spiderInfo) spiderInfo
spiderInfo }
} }.onFailure {
}.onFailure { logcat(it)
logcat(it) }.getOrNull()
}.getOrNull()
}
suspend fun getPTokenFromMultiPageViewer(index: Int): String? { suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = mSpiderInfo val spiderInfo = mSpiderInfo
@@ -417,13 +414,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
@Synchronized @Synchronized
private fun writeSpiderInfoToLocal() { private fun writeSpiderInfoToLocal() {
if (!isReady) return if (!isReady) return
mSpiderDen.downloadDir?.run { createFile(SPIDER_INFO_FILENAME)?.also { mSpiderInfo.write(it) } } mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
mSpiderInfo.saveToCache() mSpiderInfo.saveToCache()
} }
private fun isStateDone(state: Int): Boolean { private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
return state == STATE_FINISHED || state == STATE_FAILED
}
fun updatePageState(index: Int, @State state: Int, error: String? = null) { fun updatePageState(index: Int, @State state: Int, error: String? = null) {
var oldState: Int var oldState: Int
@@ -57,13 +57,16 @@ import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.tools.DialogState import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.findActivity import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastT import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.mapToLongArray import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.requestPermission import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.toEpochMillis import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toLocalDateTime import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.unifile.UniFile import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -77,20 +80,20 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx import splitties.init.appCtx
private fun removeNoMediaFile(downloadDir: UniFile) { private fun removeNoMediaFile(downloadDir: Path) {
val noMedia = downloadDir / ".nomedia" (downloadDir / ".nomedia").delete()
noMedia.delete()
} }
private fun ensureNoMediaFile(downloadDir: UniFile) { private fun ensureNoMediaFile(downloadDir: Path) {
downloadDir.createFile(".nomedia") ?: return (downloadDir / ".nomedia").openOutputStream().close()
} }
private val lck = Mutex() private val lck = Mutex()
suspend fun keepNoMediaFileStatus(downloadDir: UniFile = downloadLocation) { suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
if (downloadDir.isDirectory) { if (downloadDir.isDirectory) {
lck.withLock { lck.withLock {
if (Settings.mediaScan) { if (Settings.mediaScan) {
@@ -205,10 +208,14 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
add(localFav) add(localFav)
addAll(cloudFav) addAll(cloudFav)
} }
if (galleryInfo.favoriteSlot >= 0 && galleryInfo.favoriteNote == null) {
galleryInfo.favoriteNote = bgWork { EhEngine.getFavoriteNote(galleryInfo.gid, galleryInfo.token) }
}
val (slot, note) = awaitSelectItemWithIconAndTextField( val (slot, note) = awaitSelectItemWithIconAndTextField(
items, items,
title = R.string.add_favorites_dialog_title, title = R.string.add_favorites_dialog_title,
hint = R.string.favorite_note, hint = R.string.favorite_note,
initialNote = galleryInfo.favoriteNote.orEmpty(),
maxChar = MAX_FAVNOTE_CHAR, maxChar = MAX_FAVNOTE_CHAR,
) )
return doModifyFavorites(galleryInfo, if (isFavorited) slot - 2 else slot - 1, localFavorited, note) return doModifyFavorites(galleryInfo, if (isFavorited) slot - 2 else slot - 1, localFavorited, note)
@@ -247,6 +254,7 @@ private suspend fun doModifyFavorites(
in 0..9 -> { in 0..9 -> {
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note) EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note)
galleryInfo.favoriteNote = note
true true
} }
@@ -439,9 +447,7 @@ suspend fun DialogState.awaitSelectDate(): String? {
title = R.string.go_to, title = R.string.go_to,
yearRange = initial.year..yesterday.year, yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates { selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean { override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in dateRange
return utcTimeMillis in dateRange
}
}, },
) )
val date = dateMillis?.run { toLocalDateTime().date.toString() } val date = dateMillis?.run { toLocalDateTime().date.toString() }
@@ -135,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.calculateFraction import com.hippo.ehviewer.util.calculateFraction
import com.hippo.ehviewer.util.displayString import com.hippo.ehviewer.util.displayString
@@ -142,8 +143,9 @@ import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard import com.hippo.ehviewer.util.getUrlFromClipboard
import com.hippo.ehviewer.util.isAtLeastQ import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.unifile.asUniFile import com.hippo.ehviewer.util.sha1
import com.hippo.unifile.sha1 import com.hippo.files.isDirectory
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState import com.ramcosta.composedestinations.utils.currentDestinationAsState
@@ -226,7 +228,7 @@ class MainActivity : EhActivity() {
} }
suspend fun DialogState.checkDownloadLocation() { suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.ensureDir() } val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) { if (!valid) {
awaitPermissionOrCancel( awaitPermissionOrCancel(
confirmText = R.string.open_settings, confirmText = R.string.open_settings,
@@ -242,17 +244,19 @@ class MainActivity : EhActivity() {
} }
} }
LaunchedEffect(Unit) { if (!AppConfig.isBenchmark) {
runCatching { dialogState.checkDownloadLocation() } LaunchedEffect(Unit) {
runCatching { dialogState.checkAppLinkVerify() } runCatching { dialogState.checkDownloadLocation() }
runSuspendCatching { runCatching { dialogState.checkAppLinkVerify() }
withIOContext { runSuspendCatching {
AppUpdater.checkForUpdate()?.let { withIOContext {
dialogState.showNewVersion(this@MainActivity, it) AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
} }
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
} }
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
} }
} }
@@ -277,7 +281,7 @@ class MainActivity : EhActivity() {
} else if (type != null && type.startsWith("image/")) { } else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM) val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) { if (null != uri) {
val hash = withIOContext { uri.asUniFile().sha1() } val hash = withIOContext { uri.toOkioPath().sha1() }
navigator.navigate( navigator.navigate(
ListUrlBuilder( ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH, mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
@@ -5,8 +5,6 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -27,7 +25,6 @@ inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit
dialogState.Intercept() dialogState.Intercept()
CompositionLocalProvider( CompositionLocalProvider(
LocalDialogState provides dialogState, LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) { ) {
content() content()
} }
@@ -41,7 +38,6 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
dialogState.Intercept() dialogState.Intercept()
CompositionLocalProvider( CompositionLocalProvider(
LocalDialogState provides dialogState, LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) { ) {
content() content()
} }
@@ -4,6 +4,7 @@ import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper import android.graphics.drawable.DrawableWrapper
import android.text.Html import android.text.Html
import android.widget.TextView import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader import coil3.imageLoader
import coil3.request.crossfade import coil3.request.crossfade
import com.hippo.ehviewer.ktbuilder.imageRequest import com.hippo.ehviewer.ktbuilder.imageRequest
@@ -22,7 +22,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import io.ktor.http.Url
@Destination<RootGraph> @Destination<RootGraph>
@Composable @Composable
@@ -37,16 +36,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
view.destroy() view.destroy()
return return
} }
var getId = false if (EhCookieStore.hasSignedIn()) {
var getHash = false
EhCookieStore.load(Url(EhUrl.HOST_E)).forEach {
if (EhCookieStore.KEY_IPB_MEMBER_ID == it.name) {
getId = true
} else if (EhCookieStore.KEY_IPB_PASS_HASH == it.name) {
getHash = true
}
}
if (getId && getHash) {
present = true present = true
coroutineScope.launchIO { coroutineScope.launchIO {
withNonCancellableContext { postLogin() } withNonCancellableContext { postLogin() }
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -34,13 +33,15 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp import androidx.compose.ui.util.lerp
import arrow.resilience.Schedule
import arrow.resilience.retry
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.snackBarPadding import com.hippo.ehviewer.ui.tools.snackBarPadding
import kotlin.time.Duration.Companion.microseconds
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest import moe.tarsin.coroutines.onEachLatest
@@ -66,35 +67,32 @@ class FabLayoutState(
val expandProgress = Animatable(if (initialValue == FabLayoutValue.Expand) 1f else 0f) val expandProgress = Animatable(if (initialValue == FabLayoutValue.Expand) 1f else 0f)
.apply { updateBounds(0f, 1f) } .apply { updateBounds(0f, 1f) }
private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) { private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) = mutatorMutex.mutate(priority) {
mutatorMutex.mutate(priority) {
if (expandProgress.value != 0f) expandProgress.animateTo(0f, animationSpec)
if (value == FabLayoutValue.Hidden) {
appearProgress.animateTo(0f, animationSpec)
}
}
}
private suspend fun upTo(value: FabLayoutValue, priority: MutatePriority) {
mutatorMutex.mutate(priority) {
if (appearProgress.value != 1f) appearProgress.animateTo(1f, animationSpec)
if (value == FabLayoutValue.Expand) {
expandProgress.animateTo(1f, animationSpec)
}
}
}
suspend fun waitCollapse() {
if (expandProgress.value != 0f) { if (expandProgress.value != 0f) {
snapshotFlow { expandProgress.value }.first { it == 0f } expandProgress.animateTo(0f, animationSpec)
}
if (value == FabLayoutValue.Hidden) {
appearProgress.animateTo(0f, animationSpec)
} }
} }
private suspend fun upTo(value: FabLayoutValue, priority: MutatePriority) = mutatorMutex.mutate(priority) {
if (appearProgress.value != 1f) {
appearProgress.animateTo(1f, animationSpec)
}
if (value == FabLayoutValue.Expand) {
expandProgress.animateTo(1f, animationSpec)
}
}
suspend fun show(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Primary, priority) suspend fun show(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Primary, priority)
suspend fun hide(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Hidden, priority) suspend fun hide(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Hidden, priority)
suspend fun collapse(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Primary, priority) suspend fun collapse(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Primary, priority)
suspend fun expand(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Expand, priority) suspend fun expand(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Expand, priority)
} }
private val fabSyncSchedule = Schedule.linear<Throwable>(100.microseconds)
fun interface FabBuilder { fun interface FabBuilder {
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit) fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that) fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
@@ -118,12 +116,14 @@ fun FabLayout(
} }
val state = rememberFabLayoutState(newState) val state = rememberFabLayoutState(newState)
LaunchedEffect(newState) { LaunchedEffect(newState) {
when (newState) { fabSyncSchedule.retry {
FabLayoutValue.Expand -> state.expand() when (newState) {
FabLayoutValue.Hidden -> state.hide() FabLayoutValue.Expand -> state.expand()
FabLayoutValue.Primary -> { FabLayoutValue.Hidden -> state.hide()
state.collapse() FabLayoutValue.Primary -> {
state.show() state.collapse()
state.show()
}
} }
} }
} }
@@ -131,7 +131,7 @@ fun FabLayout(
val secondaryFab by delegateSnapshotUpdate { val secondaryFab by delegateSnapshotUpdate {
record { buildFab(builder) } record { buildFab(builder) }
transform { onEachLatest { state.waitCollapse() } } transform { onEachLatest { state.collapse(MutatePriority.PreventUserInput) } }
} }
val density = LocalDensity.current val density = LocalDensity.current
@@ -151,9 +151,9 @@ fun GalleryList(
columns = StaggeredGridCells.Fixed(thumbColumns), columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = contentModifier, modifier = contentModifier,
state = thumbListState, state = thumbListState,
contentPadding = contentPadding + PaddingValues(marginH, marginV),
verticalItemSpacing = gridInterval, verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval), horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = contentPadding + PaddingValues(marginH, marginV),
) { ) {
items( items(
count = data.itemCount, count = data.itemCount,
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -32,9 +31,9 @@ import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import arrow.core.raise.ensure
import com.hippo.ehviewer.R import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS
@@ -62,9 +61,11 @@ private val categoryTable = arrayOf(
fun SearchFilter( fun SearchFilter(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
category: Int, category: Int,
onCategoryChanged: (Int) -> Unit, onCategoryChange: (Int) -> Unit,
language: Int,
onLanguageChange: (Int) -> Unit,
advancedOption: AdvancedSearchOption, advancedOption: AdvancedSearchOption,
onAdvancedOptionChanged: (AdvancedSearchOption) -> Unit, onAdvancedOptionChange: (AdvancedSearchOption) -> Unit,
) = Column(modifier) { ) = Column(modifier) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -82,7 +83,7 @@ fun SearchFilter(
items(categories, { it.first }) { items(categories, { it.first }) {
FilterChip( FilterChip(
selected = isCategoryChecked(it.first), selected = isCategoryChecked(it.first),
onClick = { onCategoryChanged(category xor it.first) }, onClick = { onCategoryChange(category xor it.first) },
label = { Text(text = stringResource(id = it.second)) }, label = { Text(text = stringResource(id = it.second)) },
modifier = Modifier.thenIf(animateItems) { animateItem() }, modifier = Modifier.thenIf(animateItems) { animateItem() },
) )
@@ -109,14 +110,11 @@ fun SearchFilter(
} }
} }
} }
var languageFilter by Settings.languageFilter.asMutableState()
DropdownFilterChip( DropdownFilterChip(
label = languageStr, label = languageStr,
menuItems = languages, menuItems = languages,
selectedItemIndex = languageFilter + 1, selectedItemIndex = language + 1,
onSelectedItemIndexChange = { onSelectedItemIndexChange = { onLanguageChange(it - 1) },
languageFilter = it - 1
},
) )
val minRatingItems = stringArrayResource(id = R.array.search_min_rating) val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr) val minRatingStr = stringResource(id = R.string.search_sr)
@@ -125,7 +123,7 @@ fun SearchFilter(
menuItems = minRatingItems.asList(), menuItems = minRatingItems.asList(),
selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0), selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0),
onSelectedItemIndexChange = { onSelectedItemIndexChange = {
onAdvancedOptionChanged(advancedOption.copy(minRating = if (it == 0) 0 else it + 1)) onAdvancedOptionChange(advancedOption.copy(minRating = if (it == 0) 0 else it + 1))
}, },
) )
val pageErr1 = stringResource(R.string.search_sp_err1) val pageErr1 = stringResource(R.string.search_sp_err1)
@@ -153,13 +151,13 @@ fun SearchFilter(
val (from, to) = dialogState.awaitResult( val (from, to) = dialogState.awaitResult(
initial = advancedOption.fromPage to advancedOption.toPage, initial = advancedOption.fromPage to advancedOption.toPage,
title = R.string.key_pages, title = R.string.key_pages,
invalidator = { (from, to) -> invalidator = { (min, _max) ->
if (to != 0 && to < 10) { // 0 means max pages not filled, though +inf
pageErr1 val max = if (_max == 0) Int.MAX_VALUE else _max
} else if (from != 0 && to != 0 && to - from < 20) { if (min != 0) {
pageErr2 ensure(max - min >= 20) { pageErr2 }
} else { } else {
null ensure(max >= 10) { pageErr1 }
} }
}, },
) { error -> ) { error ->
@@ -202,13 +200,13 @@ fun SearchFilter(
} }
} }
} }
onAdvancedOptionChanged(advancedOption.copy(fromPage = from, toPage = to)) onAdvancedOptionChange(advancedOption.copy(fromPage = from, toPage = to))
} }
}, },
label = { Text(text = pagesText) }, label = { Text(text = pagesText) },
) )
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0 fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit)) fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChange(copy(advanceSearch = advanceSearch xor bit))
FilterChip( FilterChip(
selected = checked(AdvanceTable.SH), selected = checked(AdvanceTable.SH),
onClick = { advancedOption.inv(AdvanceTable.SH) }, onClick = { advancedOption.inv(AdvanceTable.SH) },
@@ -20,23 +20,25 @@ import com.hippo.ehviewer.gallery.PageLoader2
import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.isAtLeastQ import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastT import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.requestPermission import com.hippo.ehviewer.util.requestPermission
import com.hippo.unifile.asUniFile import com.hippo.files.toOkioPath
import com.hippo.unifile.displayPath
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import java.io.File import java.io.File
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.systemservices.clipboardManager import splitties.systemservices.clipboardManager
context(PageLoader2) context(PageLoader2)
private fun Context.provideImage(index: Int): Uri? { private fun Context.provideImage(index: Int): Uri? {
val dir = AppConfig.externalTempDir ?: return null val dir = AppConfig.externalTempDir ?: return null
val name = saveToDir(index, dir.asUniFile())?.name ?: return null val name = getImageFilename(index) ?: return null
return FileProvider.getUriForFile(this, "$APPLICATION_ID.fileprovider", File(dir, name)) val file = File(dir, name).takeIf { save(index, it.toOkioPath()) } ?: return null
return FileProvider.getUriForFile(this, "$APPLICATION_ID.fileprovider", file)
} }
context(SnackbarHostState, Context, PageLoader2) context(SnackbarHostState, Context, PageLoader2)
@@ -95,7 +97,7 @@ suspend fun save(page: ReaderPage) {
val values = ContentValues() val values = ContentValues()
val realPath: String val realPath: String
values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename) values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
values.put(MediaStore.Images.Media.DATE_ADDED, Clock.System.now().toEpochMilliseconds()) values.put(MediaStore.MediaColumns.DATE_ADDED, Clock.System.now().epochSeconds)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType) values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (isAtLeastQ) { if (isAtLeastQ) {
realPath = Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME realPath = Environment.DIRECTORY_PICTURES + File.separator + AppConfig.APP_DIRNAME
@@ -113,7 +115,7 @@ suspend fun save(page: ReaderPage) {
} }
val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (imageUri != null) { if (imageUri != null) {
if (!save(page.index, imageUri.asUniFile())) { if (!save(page.index, imageUri.toOkioPath())) {
try { try {
contentResolver.delete(imageUri, null, null) contentResolver.delete(imageUri, null, null)
} catch (e: Exception) { } catch (e: Exception) {
@@ -146,7 +148,7 @@ suspend fun saveTo(page: ReaderPage) {
page.runSuspendCatching { page.runSuspendCatching {
val uri = awaitActivityResult(ActivityResultContracts.CreateDocument(mimeType), filename) val uri = awaitActivityResult(ActivityResultContracts.CreateDocument(mimeType), filename)
if (uri != null) { if (uri != null) {
save(index, uri.asUniFile()) save(index, uri.toOkioPath())
showSnackbar(getString(R.string.image_saved, uri.displayPath)) showSnackbar(getString(R.string.image_saved, uri.displayPath))
} }
}.onFailure { }.onFailure {
@@ -98,10 +98,8 @@ fun rememberSliderPagerDoubleSyncState(
lazyListState: LazyListState, lazyListState: LazyListState,
pagerState: PagerState, pagerState: PagerState,
pageLoader: PageLoader2, pageLoader: PageLoader2,
): SliderPagerDoubleSync { ): SliderPagerDoubleSync = remember {
return remember { SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
}
} }
private fun smoothScroll(distance: Int) = private fun smoothScroll(distance: Int) =
@@ -15,8 +15,10 @@ data class DownloadsFilterState(
) : Parcelable ) : Parcelable
fun DownloadsFilterState.take(info: DownloadInfo) = fun DownloadsFilterState.take(info: DownloadInfo) =
mode.take(info, label) && (state == -1 || info.state == state) && mode.take(info, label) &&
(state == -1 || info.state == state) &&
with(info) { with(info) {
title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) || title.containsIgnoreCase(keyword) ||
titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
} }
@@ -48,7 +48,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -76,7 +75,6 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -153,7 +151,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate } val ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
val allName = stringResource(R.string.download_all) val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name) val defaultName = stringResource(R.string.default_download_label_name)
val unknownName = stringResource(R.string.unknown_artists) val unknownName = stringResource(R.string.unknown_artists)
@@ -172,8 +169,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}, },
) )
val hint = stringResource(R.string.search_bar_hint, title) val hint = stringResource(R.string.search_bar_hint, title)
val sortModes = stringArrayResource(id = R.array.download_sort_modes)
val downloadStates = stringArrayResource(id = R.array.download_state)
val list = remember(filterState, invalidateKey) { val list = remember(filterState, invalidateKey) {
downloadInfoList.filterTo(mutableStateListOf()) { info -> downloadInfoList.filterTo(mutableStateListOf()) { info ->
filterState.take(info) filterState.take(info)
@@ -227,10 +222,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
launch { launch {
val text = awaitInputText(title = newLabel, hint = labelsStr) { text -> val text = awaitInputText(title = newLabel, hint = labelsStr) { text ->
when { when {
text.isBlank() -> labelEmpty text.isBlank() -> raise(labelEmpty)
text == defaultName -> defaultInvalid text == defaultName -> raise(defaultInvalid)
DownloadManager.containLabel(text) -> labelExists DownloadManager.containLabel(text) -> raise(labelExists)
else -> null
} }
} }
DownloadManager.addLabel(text) DownloadManager.addLabel(text)
@@ -337,7 +331,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
val index by rememberUpdatedState(itemIndex) val index by rememberUpdatedState(itemIndex)
val item by rememberUpdatedState(label) val item by rememberUpdatedState(label)
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) } val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
LaunchedEffect(dismissState) { LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect { snapshotFlow { dismissState.currentValue }.collect {
if (it == SwipeToDismissBoxValue.EndToStart) { if (it == SwipeToDismissBoxValue.EndToStart) {
@@ -357,7 +351,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
} }
} }
} }
ReorderableItem(reorderableLabelState, enabled = editEnable, key = id) { isDragging -> ReorderableItem(reorderableLabelState, id, enabled = editEnable, animated = animateItems) { isDragging ->
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
backgroundContent = {}, backgroundContent = {},
@@ -390,10 +384,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
launch { launch {
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text -> val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when { when {
text.isBlank() -> labelEmpty text.isBlank() -> raise(labelEmpty)
text == defaultName -> defaultInvalid text == defaultName -> raise(defaultInvalid)
DownloadManager.containLabel(text) -> labelExists DownloadManager.containLabel(text) -> raise(labelExists)
else -> null
} }
} }
DownloadManager.renameLabel(item, new) DownloadManager.renameLabel(item, new)
@@ -544,9 +537,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
FastScrollLazyVerticalStaggeredGrid( FastScrollLazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(thumbColumns), columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(), modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
contentPadding = realPadding,
verticalItemSpacing = gridInterval, verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval), horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = realPadding,
) { ) {
items(list, key = { it.gid }) { info -> items(list, key = { it.gid }) { info ->
GalleryInfoGridItem( GalleryInfoGridItem(
@@ -653,8 +646,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
} }
onClick(Icons.AutoMirrored.Default.Sort) { onClick(Icons.AutoMirrored.Default.Sort) {
val oldMode = SortMode.from(sortMode) val oldMode = SortMode.from(sortMode)
val sortModes = resources.getStringArray(R.array.download_sort_modes).toList()
val (selected, checked) = awaitSelectItemWithCheckBox( val (selected, checked) = awaitSelectItemWithCheckBox(
sortModes.toList(), sortModes,
R.string.sort_by, R.string.sort_by,
R.string.group_by_download_label, R.string.group_by_download_label,
SortMode.All.indexOfFirst { it.field == oldMode.field && it.order == oldMode.order }, SortMode.All.indexOfFirst { it.field == oldMode.field && it.order == oldMode.order },
@@ -666,8 +660,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
invalidateKey = !invalidateKey invalidateKey = !invalidateKey
} }
onClick(Icons.Default.FilterList) { onClick(Icons.Default.FilterList) {
val downloadStates = resources.getStringArray(R.array.download_state).toList()
val state = awaitSingleChoice( val state = awaitSingleChoice(
downloadStates.toList(), downloadStates,
filterState.state + 1, filterState.state + 1,
R.string.download_filter, R.string.download_filter,
) - 1 ) - 1
@@ -150,7 +150,8 @@ import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.findActivity import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastQ import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.requestPermission import com.hippo.ehviewer.util.requestPermission
import com.hippo.unifile.asUniFile import com.hippo.files.delete
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -184,21 +185,19 @@ data class TokenArgs(
) : GalleryDetailScreenArgs ) : GalleryDetailScreenArgs
@StringRes @StringRes
private fun getRatingText(rating: Float): Int { private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
return when ((rating * 2).roundToInt()) { 0 -> R.string.rating0
0 -> R.string.rating0 1 -> R.string.rating1
1 -> R.string.rating1 2 -> R.string.rating2
2 -> R.string.rating2 3 -> R.string.rating3
3 -> R.string.rating3 4 -> R.string.rating4
4 -> R.string.rating4 5 -> R.string.rating5
5 -> R.string.rating5 6 -> R.string.rating6
6 -> R.string.rating6 7 -> R.string.rating7
7 -> R.string.rating7 8 -> R.string.rating8
8 -> R.string.rating8 9 -> R.string.rating9
9 -> R.string.rating9 10 -> R.string.rating10
10 -> R.string.rating10 else -> R.string.rating_none
else -> R.string.rating_none
}
} }
private fun List<GalleryTagGroup>.getArtistTag(): String? { private fun List<GalleryTagGroup>.getArtistTag(): String? {
@@ -588,7 +587,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name), AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
) )
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
r.addRequestHeader("Cookie", EhCookieStore.getCookieHeader(url)) EhCookieStore.getCookieHeader(url)?.let { r.addRequestHeader("Cookie", it) }
downloadManager.enqueue(r) downloadManager.enqueue(r)
showSnackbar(downloadTorrentStarted) showSnackbar(downloadTorrentStarted)
} }
@@ -618,14 +617,12 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
) )
} }
Spacer(modifier = Modifier.size(keylineMargin)) Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String { fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
return getString( R.string.rating_text,
R.string.rating_text, getString(getRatingText(rating)),
getString(getRatingText(rating)), rating,
rating, ratingCount,
ratingCount, )
)
}
var ratingText by rememberSaveable { var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount)) mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
} }
@@ -1079,7 +1076,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
) )
val dirname = downloadInfo?.dirname val dirname = downloadInfo?.dirname
if (uri != null && dirname != null) { if (uri != null && dirname != null) {
val file = uri.asUniFile() val file = uri.toOkioPath()
val msg = runCatching { val msg = runCatching {
bgWork { bgWork {
withIOContext { withIOContext {
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.view.ViewConfiguration import android.view.ViewConfiguration
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -39,12 +40,10 @@ import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -77,16 +76,18 @@ import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp import androidx.compose.ui.util.lerp
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings import com.hippo.ehviewer.Settings
@@ -134,8 +135,8 @@ import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
import com.hippo.ehviewer.ui.tools.snackBarPadding import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.util.FavouriteStatusRouter import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.pickVisualMedia import com.hippo.ehviewer.util.pickVisualMedia
import com.hippo.unifile.asUniFile import com.hippo.ehviewer.util.sha1
import com.hippo.unifile.sha1 import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -176,6 +177,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
var searchBarExpanded by rememberSaveable { mutableStateOf(false) } var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) } var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) } var showSearchLayout by rememberSaveable { mutableStateOf(false) }
val animateItems by Settings.animateItems.collectAsState()
var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY } var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY }
var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() } var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() }
@@ -202,7 +204,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
onBack = { showSearchLayout = false }, onBack = { showSearchLayout = false },
) )
val density = LocalDensity.current val density = LocalDensity.current
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
val listState = rememberLazyGridState() val listState = rememberLazyGridState()
val gridState = rememberLazyStaggeredGridState() val gridState = rememberLazyStaggeredGridState()
val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST } val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST }
@@ -253,6 +254,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
} }
}.flow.cachedIn(viewModelScope) }.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems() }.collectAsLazyPagingItems()
ReportDrawnWhen { data.loadState.refresh !is LoadState.Loading }
FavouriteStatusRouter.Observe(data) FavouriteStatusRouter.Observe(data)
val listMode by Settings.listMode.collectAsState() val listMode by Settings.listMode.collectAsState()
@@ -262,6 +264,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val toplists = remember { entries zip values } val toplists = remember { entries zip values }
val quickSearchName = getSuitableTitleForUrlBuilder(urlBuilder, false) val quickSearchName = getSuitableTitleForUrlBuilder(urlBuilder, false)
var saveProgress by Settings.qSSaveProgress.asMutableState() var saveProgress by Settings.qSSaveProgress.asMutableState()
var languageFilter by Settings.languageFilter.asMutableState()
fun getFirstVisibleItemIndex() = if (listMode == 0) { fun getFirstVisibleItemIndex() = if (listMode == 0) {
listState.firstVisibleItemIndex listState.firstVisibleItemIndex
@@ -340,20 +343,15 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
checkBoxText = R.string.save_progress, checkBoxText = R.string.save_progress,
) { input, checked -> ) { input, checked ->
var text = input.trim() var text = input.trim()
nameEmpty.takeIf { ensure(text.isNotBlank()) { nameEmpty }
text.isEmpty() if (checked) text += "@$next"
} ?: dupName.takeIf { ensure(quickSearchList.none { it.name == text }) { dupName }
if (checked) text += "@$next" val quickSearch = urlBuilder.toQuickSearch(text)
quickSearchList.fastAny { it.name == text } quickSearch.position = quickSearchList.size
} ?: run { // Insert to DB first to update the id
val quickSearch = urlBuilder.toQuickSearch(text) EhDB.insertQuickSearch(quickSearch)
quickSearch.position = quickSearchList.size quickSearchList.add(quickSearch)
// Insert to DB first to update the id saveProgress = checked
EhDB.insertQuickSearch(quickSearch)
quickSearchList.add(quickSearch)
saveProgress = checked
null
}
} }
} }
} }
@@ -370,9 +368,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val quickSearchListState = rememberLazyListState() val quickSearchListState = rememberLazyListState()
val hapticFeedback = rememberHapticFeedback() val hapticFeedback = rememberHapticFeedback()
val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to -> val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to ->
val fromIndex = from.index - 1 quickSearchList.apply { add(to.index, removeAt(from.index)) }
val toIndex = to.index - 1
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE) hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
} }
var fromIndex by remember { mutableIntStateOf(-1) } var fromIndex by remember { mutableIntStateOf(-1) }
@@ -381,15 +377,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
state = quickSearchListState, state = quickSearchListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(), contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) { ) {
// Fix the first item's reorder animation
stickyHeader {
HorizontalDivider()
}
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item -> itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
val index by rememberUpdatedState(itemIndex) val index by rememberUpdatedState(itemIndex)
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging -> ReorderableItem(reorderableLazyListState, item.id!!, animated = animateItems) { isDragging ->
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it // Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) } val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
LaunchedEffect(dismissState) { LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect { value -> snapshotFlow { dismissState.currentValue }.collect { value ->
if (value == SwipeToDismissBoxValue.EndToStart) { if (value == SwipeToDismissBoxValue.EndToStart) {
@@ -427,10 +419,14 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
if (urlBuilder.mode == MODE_WHATS_HOT) { if (urlBuilder.mode == MODE_WHATS_HOT) {
val builder = ListUrlBuilder(item) val builder = ListUrlBuilder(item).apply {
language = languageFilter
}
navigator.navigate(builder.asDst()) navigator.navigate(builder.asDst())
} else { } else {
urlBuilder = ListUrlBuilder(item) urlBuilder = ListUrlBuilder(item).apply {
language = languageFilter
}
data.refresh() data.refresh()
} }
showSearchLayout = false showSearchLayout = false
@@ -515,6 +511,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
builder.mode = newMode builder.mode = newMode
builder.keyword = query builder.keyword = query
builder.category = category builder.category = category
builder.language = languageFilter
builder.advanceSearch = advancedSearchOption.advanceSearch builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating builder.minRating = advancedSearchOption.minRating
builder.pageFrom = advancedSearchOption.fromPage builder.pageFrom = advancedSearchOption.fromPage
@@ -525,7 +522,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
return@launchIO return@launchIO
} }
builder.mode = MODE_IMAGE_SEARCH builder.mode = MODE_IMAGE_SEARCH
builder.hash = imageUri!!.asUniFile().sha1() builder.hash = imageUri!!.toOkioPath().sha1()
} }
when (oldMode) { when (oldMode) {
MODE_TOPLIST, MODE_WHATS_HOT -> { MODE_TOPLIST, MODE_WHATS_HOT -> {
@@ -578,11 +575,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
filter = { filter = {
SearchFilter( SearchFilter(
category = category, category = category,
onCategoryChanged = { category = it }, onCategoryChange = { category = it },
language = languageFilter,
onLanguageChange = { languageFilter = it },
advancedOption = advancedSearchOption, advancedOption = advancedSearchOption,
onAdvancedOptionChanged = { onAdvancedOptionChange = { advancedSearchOption = it },
advancedSearchOption = it
},
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -735,11 +732,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0 val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0
val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES) val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES)
val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText -> val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText ->
when (oriText.trim().toIntOrNull()?.let { it - 1 }) { val goto = ensureNotNull(oriText.trim().toIntOrNull()) { invalidNum } - 1
null -> invalidNum ensure(goto in 0..<TOPLIST_PAGES) { outOfRange }
!in 0..<TOPLIST_PAGES -> outOfRange
else -> null
}
}.trim().toInt() - 1 }.trim().toInt() - 1
urlBuilder.setJumpTo(text) urlBuilder.setJumpTo(text)
} else { } else {
@@ -120,8 +120,8 @@ fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator:
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)), modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
state = state, state = state,
contentPadding = paddingValues, contentPadding = paddingValues,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items( items(
count = data.itemCount, count = data.itemCount,
@@ -227,12 +227,14 @@ fun SearchBarScreen(
onExpandedChange = onExpandedChange, onExpandedChange = onExpandedChange,
modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2), modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2),
label = title.ifNotNullThen { label = title.ifNotNullThen {
Text(title!!, overflow = TextOverflow.Ellipsis) Text(title!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
}.takeUnless { }.takeUnless {
val contentActive by activeState.state val contentActive by activeState.state
expanded || contentActive || searchFieldState.text.isNotEmpty() expanded || contentActive || searchFieldState.text.isNotEmpty()
}, },
placeholder = searchFieldHint.ifNotNullThen { Text(searchFieldHint!!) }, placeholder = searchFieldHint.ifNotNullThen {
Text(searchFieldHint!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
},
leadingIcon = { leadingIcon = {
if (expanded) { if (expanded) {
IconButton(onClick = { hideSearchView() }) { IconButton(onClick = { hideSearchView() }) {
@@ -298,22 +300,20 @@ fun SearchBarScreen(
} }
} }
fun wrapTagKeyword(keyword: String, translate: Boolean = false): String { fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (keyword.endsWith(':')) {
return if (keyword.endsWith(':')) { keyword
keyword } else {
val tag = keyword.substringAfter(':')
val prefix = keyword.dropLast(tag.length + 1)
if (translate) {
val namespacePrefix = TagNamespace(prefix).toPrefix()
val newPrefix = EhTagDatabase.getTranslation(tag = prefix) ?: prefix
val newTag = EhTagDatabase.getTranslation(namespacePrefix, tag) ?: tag
"$newPrefix:$newTag"
} else if (keyword.contains(' ')) {
"$prefix:\"$tag$\""
} else { } else {
val tag = keyword.substringAfter(':') "$keyword$"
val prefix = keyword.dropLast(tag.length + 1)
if (translate) {
val namespacePrefix = TagNamespace(prefix).toPrefix()
val newPrefix = EhTagDatabase.getTranslation(tag = prefix) ?: prefix
val newTag = EhTagDatabase.getTranslation(namespacePrefix, tag) ?: tag
"$newPrefix:$newTag"
} else if (keyword.contains(' ')) {
"$prefix:\"$tag$\""
} else {
"$keyword$"
}
} }
} }
@@ -154,9 +154,7 @@ suspend fun DialogState.showNewVersion(context: Context, release: Release) {
} }
if (Settings.backupBeforeUpdate) { if (Settings.backupBeforeUpdate) {
val time = ReadableTime.getFilenamableTime() val time = ReadableTime.getFilenamableTime()
downloadLocation.createFile("$time.db")?.let { EhDB.exportDB(context, (downloadLocation / "$time.db"))
EhDB.exportDB(context, it)
}
} }
// TODO: Download in the background and show progress in notification // TODO: Download in the background and show progress in notification
val file = File(AppConfig.tempDir, "update.apk").apply { delete() } val file = File(AppConfig.tempDir, "update.apk").apply { delete() }
@@ -44,11 +44,13 @@ import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.ReadableTime import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getAppLanguage import com.hippo.ehviewer.util.getAppLanguage
import com.hippo.ehviewer.util.getLanguages import com.hippo.ehviewer.util.getLanguages
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastV import com.hippo.ehviewer.util.isAtLeastV
import com.hippo.ehviewer.util.setAppLanguage import com.hippo.ehviewer.util.setAppLanguage
import com.hippo.unifile.asUniFile import com.hippo.files.toOkioPath
import com.jamal.composeprefs3.ui.prefs.DropDownPref import com.jamal.composeprefs3.ui.prefs.DropDownPref
import com.jamal.composeprefs3.ui.prefs.SwitchPref import com.jamal.composeprefs3.ui.prefs.SwitchPref
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
@@ -129,7 +131,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
Crash.collectInfo(zipOs.writer()) Crash.collectInfo(zipOs.writer())
Runtime.getRuntime().exec("logcat -d").inputStream.use { it.copyTo(zipOs) } Runtime.getRuntime().exec("logcat -d").inputStream.use { it.copyTo(zipOs) }
} }
launchSnackBar(getString(R.string.settings_advanced_dump_logcat_to, uri.toString())) launchSnackBar(getString(R.string.settings_advanced_dump_logcat_to, uri.displayPath))
} }
}.onFailure { }.onFailure {
launchSnackBar(dumpLogError) launchSnackBar(dumpLogError)
@@ -155,6 +157,15 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
useSelectedAsSummary = true, useSelectedAsSummary = true,
entries = languages, entries = languages,
) )
if (isAtLeastO) {
IntSliderPreference(
maxValue = 16384,
step = 3,
title = stringResource(id = R.string.settings_advanced_hardware_bitmap_threshold),
summary = stringResource(id = R.string.settings_advanced_hardware_bitmap_threshold_summary),
value = Settings::hardwareBitmapThreshold,
)
}
SwitchPreference( SwitchPreference(
title = stringResource(id = R.string.preload_thumb_aggressively), title = stringResource(id = R.string.preload_thumb_aggressively),
value = Settings::preloadThumbAggressively, value = Settings::preloadThumbAggressively,
@@ -190,8 +201,8 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
uri?.let { uri?.let {
context.runCatching { context.runCatching {
grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
EhDB.exportDB(context, uri.asUniFile()) EhDB.exportDB(context, uri.toOkioPath())
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.toString())) launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.displayPath))
}.onFailure { }.onFailure {
logcat(it) logcat(it)
launchSnackBar(exportFailed) launchSnackBar(exportFailed)
@@ -2,10 +2,13 @@ package com.hippo.ehviewer.ui.settings
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.provider.DocumentsContract
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -13,20 +16,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import arrow.fx.coroutines.parMap import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parMapNotNull import arrow.fx.coroutines.parMapNotNull
import com.hippo.ehviewer.EhDB import com.hippo.ehviewer.EhDB
@@ -46,32 +45,36 @@ import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderDen import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
import com.hippo.ehviewer.spider.readComicInfo import com.hippo.ehviewer.spider.readComicInfo
import com.hippo.ehviewer.spider.readCompatFromUniFile import com.hippo.ehviewer.spider.readCompatFromPath
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.keepNoMediaFileStatus import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.tools.observed import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.displayString import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile import com.hippo.files.delete
import com.hippo.unifile.asUniFile import com.hippo.files.find
import com.hippo.unifile.displayPath import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.metadataOrNull
import com.hippo.files.toOkioPath
import com.hippo.files.toUri
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx import splitties.init.appCtx
@Destination<RootGraph> @Destination<RootGraph>
@Composable @Composable
fun DownloadScreen(navigator: DestinationsNavigator) { fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val context = LocalContext.current fun launchSnackBar(content: String) = launch { showSnackbar(content) }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val snackbarHostState = remember { SnackbarHostState() }
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -84,18 +87,19 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) { Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
var downloadLocationState by ::downloadLocation.observed var downloadLocationState by ::downloadLocation.observed
val cannotGetDownloadLocation = stringResource(id = R.string.settings_download_cant_get_download_location) val cannotGetDownloadLocation = stringResource(id = R.string.settings_download_cant_get_download_location)
val selectDownloadDirLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri -> val selectDownloadDirLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri ->
treeUri?.run { treeUri?.run {
coroutineScope.launch { launchIO {
context.runCatching { runCatching {
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
downloadLocationState = treeUri.asUniFile() val path = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)).toOkioPath()
coroutineScope.launchNonCancellable { check(path.isDirectory) { "$path is not a directory" }
downloadLocationState = path
launchNonCancellable {
keepNoMediaFileStatus(downloadLocationState) keepNoMediaFileStatus(downloadLocationState)
} }
}.onFailure { }.onFailure {
@@ -107,7 +111,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
} }
Preference( Preference(
title = stringResource(id = R.string.settings_download_download_location), title = stringResource(id = R.string.settings_download_download_location),
summary = downloadLocationState.uri.displayPath, summary = downloadLocationState.toUri().displayPath,
) { ) {
selectDownloadDirLauncher.launch(null) selectDownloadDirLauncher.launch(null)
} }
@@ -167,7 +171,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_reload_metadata_summary), summary = stringResource(id = R.string.settings_download_reload_metadata_summary),
) { ) {
fun DownloadInfo.isStable(): Boolean { fun DownloadInfo.isStable(): Boolean {
val downloadTime = downloadDir?.findFile(COMIC_INFO_FILE)?.lastModified() ?: return false val downloadTime = downloadDir?.resolve(COMIC_INFO_FILE)?.metadataOrNull()?.lastModifiedAtMillis ?: return false
val postedTime = posted?.let { ParserUtils.parseDate(it) } ?: return false val postedTime = posted?.let { ParserUtils.parseDate(it) } ?: return false
// stable 30 days after posted // stable 30 days after posted
val stableTime = postedTime + 30L * 24L * 60L * 60L * 1000L val stableTime = postedTime + 30L * 24L * 60L * 60L * 1000L
@@ -183,10 +187,10 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
di.galleryInfo.also { SpiderDen(it, di.dirname!!).writeComicInfo(false) } di.galleryInfo.also { SpiderDen(it, di.dirname!!).writeComicInfo(false) }
} }
EhDB.updateGalleryInfo(toUpdate) EhDB.updateGalleryInfo(toUpdate)
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_successfully, toUpdate.size)) launchSnackBar(getString(R.string.settings_download_reload_metadata_successfully, toUpdate.size))
} }
}.onFailure { }.onFailure {
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_failed, it.displayString())) launchSnackBar(getString(R.string.settings_download_reload_metadata_failed, it.displayString()))
} }
} }
val restoreFailed = stringResource(id = R.string.settings_download_restore_failed) val restoreFailed = stringResource(id = R.string.settings_download_restore_failed)
@@ -195,19 +199,19 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_restore_download_items_summary), summary = stringResource(id = R.string.settings_download_restore_download_items_summary),
) { ) {
var restoreDirCount = 0 var restoreDirCount = 0
suspend fun getRestoreItem(file: UniFile): RestoreItem? { suspend fun getRestoreItem(file: Path): RestoreItem? {
if (!file.isDirectory) return null if (!file.isDirectory) return null
return runSuspendCatching { return runSuspendCatching {
val (gid, token) = file.findFile(SPIDER_INFO_FILENAME)?.let { val (gid, token) = file.find(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run { readCompatFromPath(it)?.run {
GalleryDetailUrlParser.Result(gid, token) GalleryDetailUrlParser.Result(gid, token)
} }
} ?: file.findFile(COMIC_INFO_FILE)?.let { } ?: file.find(COMIC_INFO_FILE)?.let {
readComicInfo(it)?.run { readComicInfo(it)?.run {
GalleryDetailUrlParser.parse(web) GalleryDetailUrlParser.parse(web)
} }
} ?: return null } ?: return null
val dirname = file.name ?: return null val dirname = file.name
if (DownloadManager.containDownloadInfo(gid)) { if (DownloadManager.containDownloadInfo(gid)) {
// Restore download dir to avoid redownload // Restore download dir to avoid redownload
val dbdirname = EhDB.getDownloadDirname(gid) val dbdirname = EhDB.getDownloadDirname(gid)
@@ -222,8 +226,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
logcat(it) logcat(it)
}.getOrNull() }.getOrNull()
} }
runCatching { runSuspendCatching {
val result = downloadLocation.listFiles().parMapNotNull { getRestoreItem(it) }.also { val result = downloadLocation.list().parMapNotNull { getRestoreItem(it) }.also {
fillGalleryListByApi(it, EhUrl.referer) fillGalleryListByApi(it, EhUrl.referer)
} }
if (result.isEmpty()) { if (result.isEmpty()) {
@@ -247,25 +251,27 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_download_clean_redundancy), title = stringResource(id = R.string.settings_download_clean_redundancy),
summary = stringResource(id = R.string.settings_download_clean_redundancy_summary), summary = stringResource(id = R.string.settings_download_clean_redundancy_summary),
) { ) {
fun clearFile(file: UniFile): Boolean { fun isRedundant(file: Path): Boolean {
var name = file.name ?: return false if (!file.isDirectory) return false
val index = name.indexOf('-') val name = file.name
if (index >= 0) { val gid = name.substringBefore('-').toLongOrNull() ?: return false
name = name.substring(0, index) return name != DownloadManager.getDownloadInfo(gid)?.dirname
}
val gid = name.toLongOrNull() ?: return false
if (DownloadManager.containDownloadInfo(gid)) {
return false
}
file.delete()
return true
} }
runSuspendCatching { val list = downloadLocation.list().filter(::isRedundant)
val cnt = downloadLocation.listFiles().sumOf { clearFile(it).compareTo(false) } if (list.isNotEmpty()) {
launchSnackBar(FINAL_CLEAR_REDUNDANCY_MSG(cnt)) awaitPermissionOrCancel(
}.onFailure { confirmText = R.string.delete,
logcat(it) title = R.string.settings_download_clean_redundancy,
) {
LazyColumn {
items(list) {
Text(it.name, modifier = Modifier.padding(vertical = 8.dp))
}
}
}
} }
val cnt = list.count { runCatching { it.delete() }.getOrNull() != null }
launchSnackBar(FINAL_CLEAR_REDUNDANCY_MSG(cnt))
} }
} }
} }
@@ -22,13 +22,13 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -127,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
onExpandedChange = { expanded = !expanded }, onExpandedChange = { expanded = !expanded },
) { ) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable), modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
readOnly = true, readOnly = true,
value = type, value = type,
onValueChange = {}, onValueChange = {},
@@ -1,17 +1,21 @@
package com.hippo.ehviewer.ui.theme package com.hippo.ehviewer.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.scrollbar.LocalScrollbarStyle
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.Settings import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.scrollbarStyle
import com.hippo.ehviewer.util.isAtLeastS import com.hippo.ehviewer.util.isAtLeastS
@Composable @Composable
@@ -44,8 +48,12 @@ fun EhTheme(content: @Composable () -> Unit) {
} }
} }
MaterialTheme( MaterialTheme(colorScheme = colors) {
colorScheme = colors, val scrollbarStyle = scrollbarStyle(color = MaterialTheme.colorScheme.primary)
content = content, CompositionLocalProvider(
) LocalContentColor provides MaterialTheme.colorScheme.onBackground,
LocalScrollbarStyle provides scrollbarStyle,
content = content,
)
}
} }
@@ -129,8 +129,9 @@ fun AnnotatedString.toBBCode() = buildString {
val stack = ArrayDeque<AnnotatedString.Range<SpanStyle>>() val stack = ArrayDeque<AnnotatedString.Range<SpanStyle>>()
val spans = spanStyles.groupBy { it.start } val spans = spanStyles.groupBy { it.start }
while (true) { while (true) {
while (stack.lastOrNull()?.end == current) while (stack.lastOrNull()?.end == current) {
stack.removeLast().item.pop() stack.removeLast().item.pop()
}
if (current == len) break if (current == len) break
spans[current]?.sortedByDescending { it.end }?.forEach { spans[current]?.sortedByDescending { it.end }?.forEach {
it.item.push() it.item.push()
@@ -70,6 +70,8 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.window.SecureFlagPolicy
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.right import arrow.core.right
import com.jamal.composeprefs3.ui.ifNotNullThen import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen import com.jamal.composeprefs3.ui.ifTrueThen
@@ -109,45 +111,43 @@ class DialogState {
suspend fun <R> awaitResult( suspend fun <R> awaitResult(
initial: R, initial: R,
@StringRes title: Int? = null, @StringRes title: Int? = null,
invalidator: (suspend (R) -> String?)? = null, invalidator: (suspend Raise<String>.(R) -> Unit)? = null,
block: @Composable DialogScope<R>.(String?) -> Unit, block: @Composable DialogScope<R>.(String?) -> Unit,
): R { ): R = dialog { cont ->
return dialog { cont -> val coroutineScope = rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope() val state = remember(cont) { mutableStateOf(initial) }
val state = remember(cont) { mutableStateOf(initial) } var errorMsg by remember(cont) { mutableStateOf<String?>(null) }
var error by remember(cont) { mutableStateOf<String?>(null) } val impl = remember(cont) {
val impl = remember(cont) { object : DialogScope<R> {
object : DialogScope<R> { override var expectedValue by state
override var expectedValue by state
}
} }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state.value)
} else {
coroutineScope.launch {
error = invalidator.invoke(state.value)
error ?: cont.resume(state.value)
}
}
}) {
Text(text = stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
cont.cancel()
}) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = { block(impl, error) },
)
} }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state.value)
} else {
coroutineScope.launch {
errorMsg = either { invalidator(state.value) }.leftOrNull()
errorMsg ?: cont.resume(state.value)
}
}
}) {
Text(text = stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = {
cont.cancel()
}) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = { block(impl, errorMsg) },
)
} }
suspend fun awaitInputText( suspend fun awaitInputText(
@@ -156,35 +156,90 @@ class DialogState {
hint: String? = null, hint: String? = null,
isNumber: Boolean = false, isNumber: Boolean = false,
@StringRes confirmText: Int = android.R.string.ok, @StringRes confirmText: Int = android.R.string.ok,
invalidator: (suspend (String) -> String?)? = null, invalidator: (suspend Raise<String>.(String) -> Unit)? = null,
): String { ) = dialog { cont ->
return dialog { cont -> val coroutineScope = rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope() var state by remember(cont) { mutableStateOf(initial) }
var state by remember(cont) { mutableStateOf(initial) } var error by remember(cont) { mutableStateOf<String?>(null) }
var error by remember(cont) { mutableStateOf<String?>(null) } AlertDialog(
AlertDialog( onDismissRequest = { cont.cancel() },
onDismissRequest = { cont.cancel() }, confirmButton = {
confirmButton = { TextButton(onClick = {
TextButton(onClick = { if (invalidator == null) {
if (invalidator == null) { cont.resume(state)
cont.resume(state) } else {
} else { coroutineScope.launch {
coroutineScope.launch { error = either { invalidator(state) }.leftOrNull()
error = invalidator.invoke(state) error ?: cont.resume(state)
error ?: cont.resume(state)
}
} }
}) {
Text(text = stringResource(id = confirmText))
} }
}, }) {
title = title.ifNotNullThen { Text(text = title!!) }, Text(text = stringResource(id = confirmText))
text = { }
},
title = title.ifNotNullThen { Text(text = title!!) },
text = {
OutlinedTextField(
value = state,
onValueChange = { state = it },
label = hint.ifNotNullThen {
Text(text = hint!!)
},
trailingIcon = error.ifNotNullThen {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
)
},
supportingText = error.ifNotNullThen {
Text(text = error!!)
},
isError = error != null,
keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default,
)
},
)
}
suspend fun awaitInputTextWithCheckBox(
initial: String = "",
@StringRes title: Int? = null,
@StringRes hint: Int? = null,
checked: Boolean,
@StringRes checkBoxText: Int,
isNumber: Boolean = false,
invalidator: (suspend Raise<String>.(String, Boolean) -> Unit)? = null,
): Pair<String, Boolean> = dialog { cont ->
val coroutineScope = rememberCoroutineScope()
var state by remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
var checkedState by remember { mutableStateOf(checked) }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state to checkedState)
} else {
coroutineScope.launch {
error = either { invalidator(state, checkedState) }.leftOrNull()
error ?: cont.resume(state to checkedState)
}
}
}) {
Text(text = stringResource(id = android.R.string.ok))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField( OutlinedTextField(
value = state, value = state,
onValueChange = { state = it }, onValueChange = { state = it },
label = hint.ifNotNullThen { label = hint.ifNotNullThen {
Text(text = hint!!) Text(text = stringResource(id = hint!!))
}, },
trailingIcon = error.ifNotNullThen { trailingIcon = error.ifNotNullThen {
Icon( Icon(
@@ -198,74 +253,15 @@ class DialogState {
isError = error != null, isError = error != null,
keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default, keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default,
) )
}, LabeledCheckbox(
) modifier = Modifier.fillMaxWidth(),
} checked = checkedState,
} onCheckedChange = { checkedState = !checkedState },
label = stringResource(checkBoxText),
suspend fun awaitInputTextWithCheckBox( )
initial: String = "", }
@StringRes title: Int? = null, },
@StringRes hint: Int? = null, )
checked: Boolean,
@StringRes checkBoxText: Int,
isNumber: Boolean = false,
invalidator: (suspend (String, Boolean) -> String?)? = null,
): Pair<String, Boolean> {
return dialog { cont ->
val coroutineScope = rememberCoroutineScope()
var state by remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
var checkedState by remember { mutableStateOf(checked) }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state to checkedState)
} else {
coroutineScope.launch {
error = invalidator.invoke(state, checkedState)
error ?: cont.resume(state to checkedState)
}
}
}) {
Text(text = stringResource(id = android.R.string.ok))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = state,
onValueChange = { state = it },
label = hint.ifNotNullThen {
Text(text = stringResource(id = hint!!))
},
trailingIcon = error.ifNotNullThen {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
)
},
supportingText = error.ifNotNullThen {
Text(text = error!!)
},
isError = error != null,
keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default,
)
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checkedState,
onCheckedChange = { checkedState = !checkedState },
label = stringResource(checkBoxText),
)
}
},
)
}
} }
suspend fun awaitPermissionOrCancel( suspend fun awaitPermissionOrCancel(
@@ -277,32 +273,30 @@ class DialogState {
onCancelButtonClick: () -> Unit = {}, onCancelButtonClick: () -> Unit = {},
secure: Boolean = false, secure: Boolean = false,
text: @Composable (() -> Unit)? = null, text: @Composable (() -> Unit)? = null,
) { ) = dialog { cont ->
return dialog { cont -> AlertDialog(
AlertDialog( onDismissRequest = { cont.cancel() },
onDismissRequest = { cont.cancel() }, confirmButton = {
confirmButton = { TextButton(onClick = { cont.resume(Unit) }, enabled = confirmButtonEnabled) {
TextButton(onClick = { cont.resume(Unit) }, enabled = confirmButtonEnabled) { Text(text = stringResource(id = confirmText))
Text(text = stringResource(id = confirmText)) }
} },
}, dismissButton = showCancelButton.ifTrueThen {
dismissButton = showCancelButton.ifTrueThen { TextButton(onClick = {
TextButton(onClick = { onCancelButtonClick()
onCancelButtonClick() cont.cancel()
cont.cancel() }) {
}) { Text(text = stringResource(id = dismissText))
Text(text = stringResource(id = dismissText)) }
} },
}, title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) }, text = text,
text = text, properties = if (secure) {
properties = if (secure) { DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) } else {
} else { DialogProperties()
DialogProperties() },
}, )
)
}
} }
suspend fun awaitSelectDate( suspend fun awaitSelectDate(
@@ -313,63 +307,59 @@ class DialogState {
initialDisplayMode: DisplayMode = DisplayMode.Picker, initialDisplayMode: DisplayMode = DisplayMode.Picker,
selectableDates: SelectableDates = DatePickerDefaults.AllDates, selectableDates: SelectableDates = DatePickerDefaults.AllDates,
showModeToggle: Boolean = true, showModeToggle: Boolean = true,
): Long? { ): Long? = dialog { cont ->
return dialog { cont -> val state = rememberDatePickerState(
val state = rememberDatePickerState( initialSelectedDateMillis,
initialSelectedDateMillis, initialDisplayedMonthMillis,
initialDisplayedMonthMillis, yearRange,
yearRange, initialDisplayMode,
initialDisplayMode, selectableDates,
selectableDates, )
DatePickerDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = { cont.resume(state.selectedDateMillis) }) {
Text(text = stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
) {
DatePicker(
state = state,
title = {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(DatePickerTitlePadding),
)
},
showModeToggle = showModeToggle,
) )
DatePickerDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = { cont.resume(state.selectedDateMillis) }) {
Text(text = stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { cont.cancel() }) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
) {
DatePicker(
state = state,
title = {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(DatePickerTitlePadding),
)
},
showModeToggle = showModeToggle,
)
}
} }
} }
suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R { suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R = dialog { cont ->
return dialog { cont -> val impl = remember(cont) {
val impl = remember(cont) { DismissDialogScope<R> {
DismissDialogScope<R> { cont.resume(it)
cont.resume(it)
}
} }
BasicAlertDialog(
onDismissRequest = { cont.cancel() },
properties = DialogProperties(usePlatformDefaultWidth = respectDefaultWidth),
content = {
Surface(
modifier = with(Modifier) { if (!respectDefaultWidth) defaultMinSize(280.dp) else width(280.dp) },
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
content = { block(impl) },
)
},
)
} }
BasicAlertDialog(
onDismissRequest = { cont.cancel() },
properties = DialogProperties(usePlatformDefaultWidth = respectDefaultWidth),
content = {
Surface(
modifier = with(Modifier) { if (!respectDefaultWidth) defaultMinSize(280.dp) else width(280.dp) },
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
content = { block(impl) },
)
},
)
} }
suspend fun awaitSelectTime( suspend fun awaitSelectTime(
@@ -542,6 +532,7 @@ class DialogState {
items: List<Pair<ImageVector, String>>, items: List<Pair<ImageVector, String>>,
@StringRes title: Int, @StringRes title: Int,
@StringRes hint: Int, @StringRes hint: Int,
initialNote: String,
maxChar: Int, maxChar: Int,
): Pair<Int, String> = showNoButton(false) { ): Pair<Int, String> = showNoButton(false) {
Column { Column {
@@ -550,7 +541,7 @@ class DialogState {
modifier = Modifier.fillMaxWidth().aspectRatio(1F), modifier = Modifier.fillMaxWidth().aspectRatio(1F),
placeFirstItemInCenter = true, placeFirstItemInCenter = true,
) { ) {
var note by remember { mutableStateOf("") } var note by remember { mutableStateOf(initialNote) }
TextField( TextField(
value = note, value = note,
onValueChange = { note = it }, onValueChange = { note = it },
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more