Compare commits

...
100 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
187 changed files with 83806 additions and 4876 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
- 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
run: ./gradlew spotlessCheck
- name: Lint Check
run: ./gradlew lintMarshmallowRelease
default:
runs-on: ubuntu-latest
steps:
@@ -61,7 +64,7 @@ jobs:
java-version: 21
- 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
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-
- name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleDefaultRelease
- name: Upload ARM64
@@ -111,7 +116,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: default-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultRelease/mapping.txt
path: app/build/outputs/mapping/defaultRelease
- name: Upload native debug symbols
uses: actions/upload-artifact@v4
@@ -132,7 +137,7 @@ jobs:
java-version: 21
- 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
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-
- name: Build
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
run: ./gradlew assembleMarshmallowRelease
- name: Upload ARM64
@@ -182,7 +189,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: marshmallow-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowRelease/mapping.txt
path: app/build/outputs/mapping/marshmallowRelease
- name: Upload native debug symbols
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Lock Threads'
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 8 * * *'
workflow_dispatch:
permissions:
+3 -1
View File
@@ -19,7 +19,7 @@ jobs:
java-version: 21
- 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
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
@@ -38,6 +38,8 @@ jobs:
- name: Gradle Build
uses: gradle/actions/setup-gradle@v3
env:
GITHUB_TOKEN: ${{ secrets.GithubToken }}
with:
arguments: assembleRelease
+23
View File
@@ -1,5 +1,28 @@
# 变更日志
## [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
### 改进
+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.DuplicateRule.GROUP
@@ -14,13 +15,15 @@ plugins {
alias(libs.plugins.spotless)
alias(libs.plugins.aboutlibrariesPlugin)
alias(libs.plugins.composeCompilerReportGenerator)
alias(libs.plugins.baselineprofile)
}
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 34
ndkVersion = "27.0.11718014-beta1"
compileSdk = if (isRelease) 35 else 34
buildToolsVersion = "35.0.0"
ndkVersion = "27.0.11902837-rc1"
androidResources.generateLocaleConfig = true
splits {
@@ -61,13 +64,16 @@ android {
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.')
val githubToken = gradleLocalProperties(rootDir, providers)["GITHUB_TOKEN"] as? String
?: System.getenv("GITHUB_TOKEN").orEmpty()
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 34
versionCode = 180056
targetSdk = 35
versionCode = 180057
versionName = "1.11.6"
versionNameSuffix = "-RC2"
// versionNameSuffix = "-SNAPSHOT"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -89,6 +95,7 @@ android {
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
buildConfigField("String", "GITHUB_TOKEN", "\"$githubToken\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
@@ -124,8 +131,9 @@ android {
}
lint {
checkReleaseBuilds = false
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlinedApi")
error += setOf("InlinedApi")
}
packaging {
@@ -149,6 +157,13 @@ android {
abortOnError = false
}
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
matchingFallbacks += listOf("release")
applicationIdSuffix = ".benchmark"
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
}
}
buildFeatures {
@@ -176,6 +191,10 @@ androidComponents {
}
}
baselineProfile {
mergeIntoMain = true
}
dependencies {
// https://developer.android.com/jetpack/androidx/releases/activity
implementation(libs.androidx.activity.compose)
@@ -205,14 +224,14 @@ dependencies {
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.viewpager2)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
implementation(libs.photoview)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -229,10 +248,11 @@ dependencies {
implementation(libs.insetter) // Dead Dependency
implementation(libs.reorderable)
// implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.arrow.fx.coroutines)
implementation(libs.arrow.resilience)
// https://coil-kt.github.io/coil/changelog/
implementation(platform(libs.coil.bom))
@@ -252,6 +272,9 @@ dependencies {
implementation(libs.cronet.embedded)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
}
@@ -293,13 +316,15 @@ aboutLibraries {
duplicationRule = GROUP
}
val ktlintVersion = libs.ktlint.get().version
spotless {
kotlin {
// https://github.com/diffplug/spotless/issues/111
target("src/**/*.kt")
ktlint()
ktlint(ktlintVersion)
}
kotlinGradle {
ktlint()
ktlint(ktlintVersion)
}
}
+5
View File
@@ -11,6 +11,11 @@
public static final ** CREATOR;
}
# https://issuetracker.google.com/347963245
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# Ktor logger
-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>
Date: Tue, 26 Sep 2023 00:09:25 +0800
Subject: [PATCH 2/2] Insert link libs
Subject: [PATCH] Insert link libs
---
CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4d859341..1cd1cd47 100644
index ec97e4c7..420f204c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
+33 -14
View File
@@ -1,5 +1,6 @@
cmake_minimum_required(VERSION 3.14)
project(ehviewer C)
include(ExternalProject)
include(FetchContent)
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
@@ -18,31 +19,47 @@ endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
option(BUILD_TESTING OFF)
set(XZ_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
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)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle
FetchContent_Declare(
if (CMAKE_HOST_SYSTEM_NAME STREQUAL Windows)
execute_process(
COMMAND wsl wslpath ${CMAKE_CURRENT_LIST_DIR}
OUTPUT_VARIABLE CURRENT_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE
)
else ()
set(CURRENT_DIR ${CMAKE_CURRENT_LIST_DIR})
endif ()
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
ExternalProject_Add(
nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e
SOURCE_DIR nettle-src/nettle
PREFIX nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.10.tar.gz
URL_MD5 c61453139d5fb44e9cdcc5b684b26e55
CONFIGURE_COMMAND bash ${CURRENT_DIR}/configure_nettle.sh ${NDK_VERSION} ${CMAKE_C_COMPILER_TARGET}
BUILD_COMMAND bash -c "make libnettle.a CFLAGS='${CMAKE_C_FLAGS}'"
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS nettle/src/nettle/libnettle.a
)
FetchContent_MakeAvailable(nettle)
set(NETTLE_DIR ${nettle_SOURCE_DIR})
add_subdirectory(nettle)
ExternalProject_Get_Property(nettle SOURCE_DIR)
include_directories(${SOURCE_DIR}/..)
add_library(libnettle STATIC IMPORTED)
set_property(TARGET libnettle PROPERTY IMPORTED_LOCATION ${SOURCE_DIR}/libnettle.a)
add_dependencies(libnettle nettle)
# Configure libnettle support for libarchive
include_directories(${CMAKE_CURRENT_BINARY_DIR}/nettle-src)
include_directories(.)
set(HAVE_LIBNETTLE 1)
set(HAVE_NETTLE_AES_H 1)
set(HAVE_NETTLE_HMAC_H 1)
@@ -58,6 +75,7 @@ SET(HAVE_LZMA_STREAM_ENCODER_MT 1)
SET(HAVE_LZMADEC_H 1)
SET(HAVE_LIBLZMADEC 1)
option(ENABLE_OPENSSL OFF)
option(ENABLE_TAR OFF)
option(ENABLE_CPIO OFF)
option(ENABLE_CAT OFF)
@@ -65,13 +83,14 @@ option(ENABLE_UNZIP OFF)
option(ENABLE_TEST OFF)
# Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
SET(LIBARCHIVE_CUSTOM_LIBS "libnettle" "liblzma")
set(LIBARCHIVE_PATCH ${CMAKE_CURRENT_LIST_DIR}/0001-Insert-link-libs.patch)
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
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)
@@ -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")
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics)
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} ehviewer_rust archive_static log jnigraphics -Wl,--exclude-libs,ALL)
+2 -1
View File
@@ -49,7 +49,7 @@ typedef struct {
} entry;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool;
static archive_ctx **ctx_pool = NULL;
#define CTX_POOL_SIZE 20
static void *mempool = NULL;
@@ -403,6 +403,7 @@ JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env);
EH_UNUSED(thiz);
if (!ctx_pool) return;
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
free(passwd);
+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
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.FloatDecayAnimationSpec
import androidx.compose.animation.core.generateDecayAnimationSpec
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.snapFlingBehavior
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
@@ -27,15 +33,23 @@ import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.observeReads
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
@@ -45,31 +59,31 @@ fun <T> Modifier.anchoredHorizontalDraggable(
enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning,
flingBehavior: FlingBehavior? = null,
): Modifier = this then AnchoredHorizontalDraggableElement(
state = state,
canDrag = { (x, y) ->
if (x > 0) {
enableDragFromStartToEnd
} else {
enableDragFromEndToStart
} && x.absoluteValue > y.absoluteValue * 2f
val enableDrag = if (x > 0) enableDragFromStartToEnd else enableDragFromEndToStart
enableDrag && x.absoluteValue > y.absoluteValue * 2f
},
enabled = enabled,
startDragImmediately = startDragImmediately,
flingBehavior = flingBehavior,
)
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableElement<T>(
private val state: AnchoredDraggableState<T>,
private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean,
private val startDragImmediately: Boolean,
private val flingBehavior: FlingBehavior?,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
@@ -78,6 +92,7 @@ private class AnchoredHorizontalDraggableElement<T>(
canDrag,
enabled,
startDragImmediately,
flingBehavior,
)
}
@@ -86,6 +101,7 @@ private class AnchoredHorizontalDraggableElement<T>(
result = 31 * result + canDrag.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + startDragImmediately.hashCode()
result = 31 * result + flingBehavior.hashCode()
return result
}
@@ -98,6 +114,7 @@ private class AnchoredHorizontalDraggableElement<T>(
if (canDrag != other.canDrag) return false
if (enabled != other.enabled) return false
if (startDragImmediately != other.startDragImmediately) return false
if (flingBehavior != other.flingBehavior) return false
return true
}
@@ -108,26 +125,56 @@ private class AnchoredHorizontalDraggableElement<T>(
properties["canDrag"] = canDrag
properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately
properties["flingBehavior"] = flingBehavior
}
}
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableNode<T>(
private var state: AnchoredDraggableState<T>,
private var canDrag: (Offset) -> Boolean,
enabled: Boolean,
private var startDragImmediately: Boolean,
) : HorizontalDragGestureNode(
enabled = enabled,
) {
private var flingBehavior: FlingBehavior?,
) : HorizontalDragGestureNode(enabled = enabled),
ObserverModifierNode {
lateinit var resolvedFlingBehavior: FlingBehavior
lateinit var density: Density
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun canDrag(change: PointerInputChange): Boolean {
return canDrag(change.positionChange().reverseIfNeeded())
override fun onAttach() {
updateFlingBehavior(flingBehavior)
}
override fun onObservedReadsChanged() {
val newDensity = currentValueOf(LocalDensity)
if (density != newDensity) {
density = newDensity
updateFlingBehavior(flingBehavior)
}
}
private fun updateFlingBehavior(newFlingBehavior: FlingBehavior?) {
// Fall back to default fling behavior if the new fling behavior is null
this.resolvedFlingBehavior =
if (newFlingBehavior == null) {
// Only register for LocalDensity snapshot updates if we are creating a decay
observeReads { density = currentValueOf(LocalDensity) }
anchoredDraggableFlingBehavior(
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
positionalThreshold = AnchoredDraggableDefaults.PositionalThreshold,
density = density,
state = state,
)
} else {
newFlingBehavior
}
}
override fun canDrag(change: PointerInputChange): Boolean = canDrag(change.positionChange().reverseIfNeeded())
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
state.anchoredDrag {
forEachDelta { dragDelta ->
@@ -141,10 +188,29 @@ private class AnchoredHorizontalDraggableNode<T>(
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
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
fun update(
@@ -152,11 +218,15 @@ private class AnchoredHorizontalDraggableNode<T>(
canDrag: (Offset) -> Boolean,
enabled: Boolean,
startDragImmediately: Boolean,
flingBehavior: FlingBehavior?,
) {
this.flingBehavior = flingBehavior
var resetPointerInputHandling = false
if (this.state != state) {
this.state = state
updateFlingBehavior(flingBehavior)
resetPointerInputHandling = true
}
@@ -175,7 +245,118 @@ private class AnchoredHorizontalDraggableNode<T>(
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((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
@@ -240,7 +421,8 @@ private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode {
) : Modifier.Node(),
LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
@@ -46,7 +46,9 @@ import kotlinx.coroutines.launch
*/
internal abstract class HorizontalDragGestureNode(
enabled: Boolean,
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
) : DelegatingNode(),
PointerInputModifierNode,
CompositionLocalConsumerModifierNode {
protected var enabled = enabled
private set
@@ -132,65 +134,63 @@ internal abstract class HorizontalDragGestureNode(
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
}
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode {
return SuspendingPointerInputModifierNode {
// re-create tracker when pointer input block restarts. This lazily creates the tracker
// only when it is need.
val velocityTracker = VelocityTracker()
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
{ startEvent, initialDelta ->
if (!isListeningForEvents) {
if (channel == null) {
channel = Channel(capacity = Channel.UNLIMITED)
}
startListeningForEvents()
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode = SuspendingPointerInputModifierNode {
// re-create tracker when pointer input block restarts. This lazily creates the tracker
// only when it is need.
val velocityTracker = VelocityTracker()
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
{ startEvent, initialDelta ->
if (!isListeningForEvents) {
if (channel == null) {
channel = Channel(capacity = Channel.UNLIMITED)
}
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
channel?.trySend(DragStarted(adjustedStart))
startListeningForEvents()
}
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 = {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
channel?.trySend(DragStarted(adjustedStart))
}
val onDragEnd: () -> Unit = {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
channel?.trySend(DragCancelled)
}
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
} catch (cancellation: CancellationException) {
channel?.trySend(DragCancelled)
}
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
)
} catch (cancellation: CancellationException) {
channel?.trySend(DragCancelled)
if (!isActive) throw cancellation
}
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() } } },
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
contentPadding = TextFieldDefaults.contentPaddingWithLabel(),
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
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
@@ -53,8 +53,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
@Suppress("NotCloseable")
@Stable
@@ -65,11 +63,7 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
)
/**
@@ -230,10 +224,8 @@ class DrawerState2(
fun rememberDrawerState2(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 {
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
@Composable
@@ -266,6 +258,10 @@ fun ModalSideDrawer(
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
),
) {
val radius by remember {
@@ -18,7 +18,7 @@ package androidx.compose.material3.fork
import androidx.annotation.FloatRange
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.DraggableAnchors
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.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
private val SnapAnimationSpec = SpringSpec<Float>()
// TODO: Update to M3 spec once it's available
private val DecayAnimationSpec = exponentialDecay<Float>()
/**
* State of the [SwipeToDismissBox] composable.
*
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @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
* 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.
@@ -61,16 +56,11 @@ class SwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue,
internal val density: Density,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float,
) {
internal val anchoredDraggableState =
AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = SnapAnimationSpec,
decayAnimationSpec = DecayAnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
@@ -150,7 +140,6 @@ class SwipeToDismissBoxState(
*/
fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
positionalThreshold: (totalDistance: Float) -> Float,
density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue },
@@ -159,7 +148,6 @@ class SwipeToDismissBoxState(
it,
density,
confirmValueChange,
positionalThreshold,
)
},
)
@@ -171,7 +159,6 @@ class SwipeToDismissBoxState(
*
* @param initialValue The initial value of the state.
* @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
* 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.
@@ -180,18 +167,15 @@ class SwipeToDismissBoxState(
fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float =
SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState {
val density = LocalDensity.current
return rememberSaveable(
saver = SwipeToDismissBoxState.Saver(
confirmValueChange = confirmValueChange,
density = density,
positionalThreshold = positionalThreshold,
),
) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
SwipeToDismissBoxState(initialValue, density, confirmValueChange)
}
}
@@ -224,6 +208,11 @@ fun SwipeToDismissBox(
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = state.anchoredDraggableState,
positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold,
animationSpec = SnapAnimationSpec,
),
),
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.
*/
private fun viewToSourceX(vx: Float): Float {
return if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
}
private fun viewToSourceX(vx: Float): Float = if (vTranslate == null) Float.NaN else (vx - vTranslate!!.x) / scale
/**
* Convert screen to source y coordinate.
*/
private fun viewToSourceY(vy: Float): Float {
return if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
}
private fun viewToSourceY(vy: Float): Float = if (vTranslate == null) Float.NaN else (vy - vTranslate!!.y) / scale
/**
* Convert screen coordinate to source coordinate.
@@ -929,9 +925,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param vxy view X/Y coordinate.
* @return a coordinate representing the corresponding source coordinate.
*/
fun viewToSourceCoord(vxy: PointF): PointF? {
return viewToSourceCoord(vxy.x, vxy.y, PointF())
}
fun viewToSourceCoord(vxy: PointF): PointF? = viewToSourceCoord(vxy.x, vxy.y, PointF())
/**
* Convert screen coordinate to source coordinate.
@@ -952,16 +946,12 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/**
* Convert source to view x coordinate.
*/
private fun sourceToViewX(sx: Float): Float {
return if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
}
private fun sourceToViewX(sx: Float): Float = if (vTranslate == null) Float.NaN else sx * scale + vTranslate!!.x
/**
* Convert source to view y coordinate.
*/
private fun sourceToViewY(sy: Float): Float {
return if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
}
private fun sourceToViewY(sy: Float): Float = if (vTranslate == null) Float.NaN else sy * scale + vTranslate!!.y
/**
* Convert source coordinate to view coordinate.
@@ -969,9 +959,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sxy source coordinates to convert.
* @return view coordinates.
*/
fun sourceToViewCoord(sxy: PointF?): PointF? {
return sourceToViewCoord(sxy!!.x, sxy.y, PointF())
}
fun sourceToViewCoord(sxy: PointF?): PointF? = sourceToViewCoord(sxy!!.x, sxy.y, PointF())
/**
* Convert source coordinate to view coordinate.
@@ -1053,9 +1041,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
/**
* Adjust a requested scale to be within the allowed limits.
*/
private fun limitedScale(targetScale: Float): Float {
return targetScale.coerceIn(minScale(), maxScale)
}
private fun limitedScale(targetScale: Float): Float = targetScale.coerceIn(minScale(), maxScale)
/**
* Apply a selected type of easing.
@@ -1067,12 +1053,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param duration Anm duration
* @return Current value
*/
private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float {
return when (type) {
EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration)
EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration)
else -> throw IllegalStateException("Unexpected easing type: $type")
}
private fun ease(type: Int, time: Long, from: Float, change: Float, duration: Long): Float = when (type) {
EASE_IN_OUT_QUAD -> easeInOutQuad(time, from, change, duration)
EASE_OUT_QUAD -> easeOutQuad(time, from, change, duration)
else -> throw IllegalStateException("Unexpected easing type: $type")
}
/**
@@ -1199,9 +1183,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
*
* @return the minimum scale as a source/view pixels ratio.
*/
fun getMinScale(): Float {
return minScale()
}
fun getMinScale(): Float = minScale()
/**
* Set the minimum scale allowed. A value of 1 means 1:1 pixels at minimum scale. You may wish to set this according
@@ -1325,9 +1307,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sCenter Target center point
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
*/
fun animateCenter(sCenter: PointF): AnimationBuilder? {
return if (isReady) AnimationBuilder(sCenter) else null
}
fun animateCenter(sCenter: PointF): AnimationBuilder? = if (isReady) AnimationBuilder(sCenter) else null
/**
* Creates a scale animation builder, that when started will animate a zoom in or out. If this would move the image
@@ -1337,9 +1317,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sCenter Target source center.
* @return [AnimationBuilder] instance. Call [SubsamplingScaleImageView.AnimationBuilder.start] to start the anim.
*/
fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? {
return if (isReady) AnimationBuilder(scale, sCenter) else null
}
fun animateScaleAndCenter(scale: Float, sCenter: PointF?): AnimationBuilder? = if (isReady) AnimationBuilder(scale, sCenter) else null
/**
* An event listener, allowing subclasses and activities to be notified of significant events.
@@ -27,7 +27,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ktor.KtorNetworkFetcherFactory
@@ -43,13 +43,14 @@ import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine
import com.hippo.ehviewer.ktor.Cronet
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
@@ -58,6 +59,7 @@ import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastS
import eu.kanade.tachiyomi.util.lang.launchIO
@@ -77,7 +79,9 @@ import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), SingletonImageLoader.Factory {
class EhApplication :
Application(),
SingletonImageLoader.Factory {
override fun onCreate() {
// Initialize Settings on first access
lifecycleScope.launchIO {
@@ -168,8 +172,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
add(KtorNetworkFetcherFactory { ktorClient })
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
}
add(CropBorderInterceptor)
add(HardwareBitmapInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
@@ -179,7 +185,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
diskCache { imageCache }
crossfade(300)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asCoilImage(true))
if (drawable != null) error(drawable.asImage(true))
if (BuildConfig.DEBUG) {
logger(DebugLogger())
} else {
@@ -195,7 +201,10 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
companion object {
val ktorClient by lazy {
HttpClient(CronetEngine) {
HttpClient(Cronet) {
engine {
client = cronetHttpClient
}
install(HttpCookies) {
storage = EhCookieStore
}
@@ -203,7 +212,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
}
val noRedirectKtorClient by lazy {
HttpClient(CronetEngine) {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
storage = EhCookieStore
@@ -33,9 +33,9 @@ import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.dao.Schema17to18
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow
import okio.Path
import okio.Path.Companion.toOkioPath
import splitties.arch.room.roomDb
object EhDB {
@@ -273,10 +273,10 @@ object EhDB {
db.filterDao().update(filter)
}
fun exportDB(context: Context, file: UniFile) {
fun exportDB(context: Context, file: Path) {
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
val dbFile = context.getDatabasePath(DB_NAME)
dbFile.asUniFile() sendTo file
dbFile.toOkioPath() sendTo file
}
suspend fun importDB(context: Context, uri: Uri) {
@@ -100,6 +100,7 @@ fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
object Settings : DataStorePreferences(null) {
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
@Suppress("ktlint:standard:backing-property-naming")
private val _favFlow = MutableSharedFlow<Unit>()
val favChangesFlow = _favFlow.debounce(1000)
var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow)
@@ -140,6 +141,7 @@ object Settings : DataStorePreferences(null) {
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
var hardwareBitmapThreshold by intPref("hardware_bitmap_threshold", 16384)
var forceEhThumb by boolPref("force_eh_thumb", false)
var showComments by boolPref("show_gallery_comments", true)
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
@@ -225,7 +227,7 @@ object Settings : DataStorePreferences(null) {
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming")
@Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
@@ -237,7 +239,7 @@ object Settings : DataStorePreferences(null) {
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard:property-naming")
@Suppress("ktlint:standard:backing-property-naming")
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
@@ -29,13 +29,9 @@ private const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
private const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
private const val URL_PREFIX_LARGE_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
fun getImageKey(gid: Long, index: Int): String {
return "image:$gid:$index"
}
fun getImageKey(gid: Long, index: Int): String = "image:$gid:$index"
fun getThumbKey(url: String): String {
return url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
}
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
val GalleryPreview.url
get() = if (exThumb) {
@@ -1,24 +1,19 @@
package com.hippo.ehviewer.client
import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastJoinToString
import io.ktor.client.plugins.cookies.CookiesStorage
import io.ktor.http.Cookie
import io.ktor.http.Url
import io.ktor.http.parseClientCookiesHeader
import io.ktor.http.renderCookieHeader
import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun removeAllCookies() = manager.removeAllCookies(null)
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean {
val url = EhUrl.HOST_E
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
}
fun hasSignedIn(): Boolean = getCookies(EhUrl.HOST_E)?.run {
containsKey(KEY_IPB_MEMBER_ID) && containsKey(KEY_IPB_PASS_HASH)
} ?: false
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
@@ -53,11 +48,7 @@ object EhCookieStore : CookiesStorage {
fun flush() = manager.flush()
fun getCookieHeader(url: String): String {
return load(Url(url)).fastJoinToString("; ") {
renderCookieHeader(it)
}
}
fun getCookieHeader(url: String): String? = manager.getCookie(url)
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override suspend fun addCookie(requestUrl: Url, cookie: Cookie) {
@@ -297,6 +297,10 @@ object EhEngine {
}.executeSafely { }
}
suspend fun getFavoriteNote(gid: Long, token: String) =
ehRequest(EhUrl.getAddFavorites(gid, token), EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(FavoritesParser::parseNote)
suspend fun downloadArchive(gid: Long, token: String, or: String, res: String, isHAtH: Boolean): String? {
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
@@ -26,8 +26,7 @@ import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.copyTo
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.sha1
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
@@ -45,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.tarsin.coroutines.runSuspendCatching
import okio.BufferedSource
import okio.Path.Companion.toOkioPath
import okio.buffer
import okio.source
import splitties.init.appCtx
@@ -63,9 +63,7 @@ object EhTagDatabase : CoroutineScope {
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
return tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
}
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
private fun internalSuggestFlow(
tags: Map<String, String>,
@@ -131,24 +129,16 @@ object EhTagDatabase : CoroutineScope {
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase)
private fun getMetadata(context: Context): Array<String>? {
return context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
}
private fun getMetadata(context: Context): Array<String>? = context.resources.getStringArray(R.array.tag_translation_metadata)
.takeIf { it.size == 4 }
fun isTranslatable(context: Context): Boolean {
return context.resources.getBoolean(R.bool.tag_translatable)
}
fun isTranslatable(context: Context): Boolean = context.resources.getBoolean(R.bool.tag_translatable)
private fun getFileContent(file: File): String? {
return runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
}.getOrNull()
}
private fun getFileContent(file: File): String? = runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
}.getOrNull()
private fun checkData(sha1: String?, data: File): Boolean {
return sha1 != null && sha1 == data.asUniFile().sha1()
}
private fun checkData(sha1: String?, data: File): Boolean = sha1 != null && sha1 == data.toOkioPath().sha1()
private suspend fun save(client: HttpClient, url: String, file: File) {
runCatching {
@@ -88,21 +88,18 @@ object EhUtils {
fun getCategory(type: String?): Int {
for (entry in CATEGORY_STRINGS) {
for (str in entry.first)
for (str in entry.first) {
if (str.equals(type, ignoreCase = true)) {
return entry.second
}
}
}
return UNKNOWN
}
fun getCategory(type: Int): String {
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
}
fun getCategory(type: Int): String = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
private fun differenceDegrees(a: Float, b: Float): Float {
return 180.0f - abs(abs(a - b) - 180.0f)
}
private fun differenceDegrees(a: Float, b: Float): Float = 180.0f - abs(abs(a - b) - 180.0f)
private fun sanitizeDegreesDouble(degrees: Float): Float {
val deg = degrees % 360.0f
@@ -159,13 +156,11 @@ object EhUtils {
Settings.needSignIn = true
}
fun getSuitableTitle(gi: GalleryInfo): String {
return if (Settings.showJpnTitle) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
} else {
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
}.orEmpty()
}
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle) {
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
} else {
if (gi.title.isNullOrEmpty()) gi.titleJpn else gi.title
}.orEmpty()
fun extractTitle(fullTitle: String?): String? {
var title: String = fullTitle ?: return null
@@ -88,7 +88,8 @@ data class BaseGalleryInfo(
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable {
) : GalleryInfo,
Parcelable {
constructor() : this(0, "")
}
@@ -55,7 +55,8 @@ data class GalleryDetail(
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
) : GalleryInfo by galleryInfo,
Parcelable {
fun fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
@@ -134,14 +134,10 @@ interface GalleryInfo {
}
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
return when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo = when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
return this as? GalleryDetail
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? = this as? GalleryDetail
@@ -22,4 +22,5 @@ import kotlinx.parcelize.Parcelize
data class GalleryTagGroup(
val groupName: String,
private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by tagList
) : Parcelable,
MutableList<String> by tagList
@@ -42,6 +42,7 @@ data class ListUrlBuilder(
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
var hash: String? = null,
var language: Int = -1,
var advanceSearch: Int = -1,
var minRating: Int = -1,
var pageFrom: Int = -1,
@@ -83,18 +84,16 @@ data class ListUrlBuilder(
mNext = q.name.substringAfterLast('@', "").ifEmpty { null }
}
fun toQuickSearch(name: String): QuickSearch {
return QuickSearch(
name = name,
mode = mode,
category = category,
keyword = mKeyword,
advanceSearch = advanceSearch,
minRating = minRating,
pageFrom = pageFrom,
pageTo = pageTo,
)
}
fun toQuickSearch(name: String): QuickSearch = QuickSearch(
name = name,
mode = mode,
category = category,
keyword = mKeyword,
advanceSearch = advanceSearch,
minRating = minRating,
pageFrom = pageFrom,
pageTo = pageTo,
)
fun equalsQuickSearch(q: QuickSearch?): Boolean {
if (null == q) {
@@ -259,85 +258,89 @@ data class ListUrlBuilder(
}
}
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
fun build(): String = when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
val query = mKeyword?.let { keyword ->
if (language == -1 || "gid:" in keyword || "l:" in keyword || "language:" in keyword) {
keyword
} else {
val tag = GalleryInfo.S_LANG_TAGS[language]
if (keyword.isNotEmpty()) {
"$tag $keyword"
} else {
tag
}
}
val query = mKeyword?.let {
val index = Settings.languageFilter.value
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
"$it $lang"
} ?: it
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
}
addQueryParameterIfNotBlank("f_search", query)
if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
if (advanceSearch and AdvanceTable.SH != 0) {
addQueryParameter("f_sh", "on")
}
if (advanceSearch and AdvanceTable.STO != 0) {
addQueryParameter("f_sto", "on")
}
if (advanceSearch and AdvanceTable.SFL != 0) {
addQueryParameter("f_sfl", "on")
}
if (advanceSearch and AdvanceTable.SFU != 0) {
addQueryParameter("f_sfu", "on")
}
if (advanceSearch and AdvanceTable.SFT != 0) {
addQueryParameter("f_sft", "on")
}
// Set min star
if (minRating > 0) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
addQueryParameterIfNotBlank("range", mRange.takeIf { it > 0 }?.toString())
}.buildString()
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
}
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> ehUrl {
addQueryParameter("f_shash", requireNotNull(hash))
}.buildString()
MODE_TOPLIST -> {
ehUrl("toplist.php", EhUrl.DOMAIN_E) {
addQueryParameter("tl", requireNotNull(mKeyword))
addQueryParameterIfNotBlank("p", mJumpTo)
}.buildString()
}
else -> throw IllegalStateException("Unexpected value: $mode")
}
@IntDef(
@@ -20,7 +20,5 @@ package com.hippo.ehviewer.client.parser
import org.jsoup.Jsoup
object EventPaneParser {
fun parse(body: String): String? {
return Jsoup.parse(body).getElementById("eventpane")?.html()
}
fun parse(body: String): String? = Jsoup.parse(body).getElementById("eventpane")?.html()
}
@@ -1,6 +1,7 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.util.unescapeXml
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@@ -17,13 +18,16 @@ data class FavParserResult(
}
object FavoritesParser {
fun parse(body: ByteBuffer): FavParserResult {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
throw ParseException("Parse favorites error", it)
}
private val NoteRegex = Regex("<textarea[^>]*>([^<]*)</textarea>")
fun parse(body: ByteBuffer): FavParserResult = runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
throw ParseException("Parse favorites error", it)
}
fun parseNote(body: String) = NoteRegex.find(body)?.run { groupValues[1].unescapeXml() }
?: throw ParseException("Can't parse favorite note")
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -21,15 +21,13 @@ import com.hippo.ehviewer.util.ExceptionUtils
import org.jsoup.Jsoup
object ForumsParser {
fun parse(body: String): String {
return runCatching {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw NotLoggedInException()
}
fun parse(body: String): String = runCatching {
val d = Jsoup.parse(body, EhUrl.URL_FORUMS)
val userlinks = d.getElementById("userlinks")
val child = userlinks!!.child(0).child(0).child(0)
child.attr("href")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw NotLoggedInException()
}
}
@@ -283,56 +283,50 @@ object GalleryDetailParser {
}
}
private fun parseTagGroup(element: Element): GalleryTagGroup? {
return try {
var nameSpace = element.child(0).text()
// Remove last ':'
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
val group = GalleryTagGroup(nameSpace)
val tags = element.child(1).children()
tags.forEach {
var tag = it.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = tag.indexOf('|')
if (index >= 0) {
tag = tag.substring(0, index).trim()
}
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
private fun parseTagGroup(element: Element): GalleryTagGroup? = try {
var nameSpace = element.child(0).text()
// Remove last ':'
nameSpace = nameSpace.substring(0, nameSpace.length - 1)
val group = GalleryTagGroup(nameSpace)
val tags = element.child(1).children()
tags.forEach {
var tag = it.text()
// Sometimes parody tag is followed with '|' and english translate, just remove them
val index = tag.indexOf('|')
if (index >= 0) {
tag = tag.substring(0, index).trim()
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
if (it.className() == "gtw") {
tag = "_$tag" // weak tag
}
group.add(tag)
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
null
}
/**
* Parse tag groups with html parser
*/
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
return try {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(document: Document): List<GalleryTagGroup> = try {
val taglist = document.getElementById("taglist")!!
val tagGroups = taglist.child(0).child(0).children()
parseTagGroups(tagGroups)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
return try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> = try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
logcat(e)
emptyList()
}
private suspend fun parseComment(element: Element): GalleryComment? {
@@ -449,22 +443,16 @@ object GalleryDetailParser {
/**
* Parse preview pages with regular expressions
*/
fun parsePreviewPages(body: String): Int {
return PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse preview page count error")
}
fun parsePreviewPages(body: String): Int = PATTERN_PREVIEW_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse preview page count error")
/**
* Parse pages with regular expressions
*/
fun parsePages(body: String): Int {
return PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
}
fun parsePages(body: String): Int = PATTERN_PAGES.find(body)?.groupValues?.get(1)?.toIntOrNull()
?: throw ParseException("Parse pages error")
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> {
return runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
}
fun parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> = runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
@@ -22,14 +22,12 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object GalleryNotAvailableParser {
fun parse(body: String): String? {
return runCatching {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat(it)
null
}
fun parse(body: String): String? = runCatching {
val e = Jsoup.parse(body).getElementsByClass("d").first()
e!!.child(0).html().replace("<br>", "\n")
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat(it)
null
}
}
@@ -32,13 +32,11 @@ object GalleryTokenApiParser {
* ]
* }
*/
fun parse(body: String): String {
return runCatching {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw EhException(body.parseAs<Error>().error)
}
fun parse(body: String): String = runCatching {
body.parseAs<Result>().tokenList[0].token
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw EhException(body.parseAs<Error>().error)
}
@Serializable
@@ -37,23 +37,13 @@ object ParserUtils {
minute()
}
fun formatDate(time: Long): String {
return formatter.format(time.toLocalDateTime())
}
fun formatDate(time: Long): String = formatter.format(time.toLocalDateTime())
fun parseDate(str: String): Long {
return formatter.parse(str).toEpochMillis()
}
fun parseDate(str: String): Long = formatter.parse(str).toEpochMillis()
fun trim(str: String?): String {
return str?.unescapeXml()?.trim() ?: ""
}
fun trim(str: String?): String = str?.unescapeXml()?.trim() ?: ""
fun parseInt(str: String?, defValue: Int): Int {
return trim(str).replace(",", "").toIntOrDefault(defValue)
}
fun parseInt(str: String?, defValue: Int): Int = trim(str).replace(",", "").toIntOrDefault(defValue)
fun parseLong(str: String?, defValue: Long): Long {
return trim(str).replace(",", "").toLongOrDefault(defValue)
}
fun parseLong(str: String?, defValue: Long): Long = trim(str).replace(",", "").toLongOrDefault(defValue)
}
@@ -22,31 +22,29 @@ import eu.kanade.tachiyomi.util.system.logcat
import org.jsoup.Jsoup
object ProfileParser {
fun parse(body: String): Result {
return runCatching {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
logcat { "No avatar" }
fun parse(body: String): Result = runCatching {
val d = Jsoup.parse(body)
val profilename = d.getElementById("profilename")
val displayName = profilename!!.child(0).text()
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
} else {
avatar
}
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
logcat { "No avatar" }
null
}
Result(displayName, avatar)
}.getOrElse {
ExceptionUtils.throwIfFatal(it)
throw ParseException("Parse forums error")
}
class Result(val displayName: String?, val avatar: String?)
@@ -25,11 +25,9 @@ object SignInParser {
"|<span class=\"postcolor\">(.+?)</span>",
)
fun parse(body: String): String {
return NAME_PATTERN.find(body)?.let {
it.groupValues[1]
} ?: ERROR_PATTERN.find(body)?.let {
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
} ?: throw ParseException("Can't parse sign in")
}
fun parse(body: String): String = NAME_PATTERN.find(body)?.let {
it.groupValues[1]
} ?: ERROR_PATTERN.find(body)?.let {
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
} ?: throw ParseException("Can't parse sign in")
}
@@ -21,7 +21,5 @@ import com.hippo.ehviewer.client.parseAs
object VoteTagParser {
// {"error":"The tag \"neko\" is not allowed. Use character:neko or artist:neko"}
fun parse(body: String): String? {
return runCatching { body.parseAs<Error>().error }.getOrNull()
}
fun parse(body: String): String? = runCatching { body.parseAs<Error>().error }.getOrNull()
}
@@ -1,20 +1,16 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.Image
import coil3.asCoilImage
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import com.hippo.ehviewer.image.detectBorder
import com.hippo.ehviewer.util.isAtLeastO
private const val CROP_THRESHOLD = 0.75f
private const val RATIO_THRESHOLD = 2
@@ -30,59 +26,33 @@ val ImageRequest.maybeCropBorder: Boolean
class BitmapImageWithRect(
val rect: IntRect,
val image: Image,
val image: BitmapImage,
) : Image by image
object CropBorderInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val req = chain.request
if (req.maybeCropBorder) {
val hw = req.allowHardware && isAtLeastO
val result = if (hw) {
val nonHw = req.newBuilder().allowHardware(false).build()
chain.withRequest(nonHw).proceed()
} else {
chain.proceed()
}
if (result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
val result = chain.proceed()
if (chain.request.maybeCropBorder && result is SuccessResult) {
val image = result.image
if (image is BitmapImage) {
val bitmap = image.bitmap
val ratio = if (image.height > image.width) {
image.height / image.width
} else {
image.width / image.height
}
if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
val rect = IntRect(array[0], array[1], array[2], array[3])
if (rect.width > minWidth && rect.height > minHeight) {
val new = BitmapImageWithRect(rect, image)
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
val rect = if (ratio < RATIO_THRESHOLD) {
val array = detectBorder(bitmap)
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
IntRect(array[0], array[1], array[2], array[3]).takeIf {
it.width > minWidth && it.height > minHeight
}
} else {
null
}
val maybeHwImage = if (hw) {
// Upload to Graphical Buffer to accelerate render
// May fail if the image is too long
bitmap.copy(Bitmap.Config.HARDWARE, false)?.run {
bitmap.recycle()
asCoilImage()
} ?: image
} else {
image
}
val new = if (rect != null) {
BitmapImageWithRect(rect, maybeHwImage)
} else {
maybeHwImage
}
return result.copy(image = new, request = result.request, dataSource = result.dataSource)
}
}
return result
}
return chain.proceed()
return result
}
}
@@ -7,28 +7,20 @@ import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import com.hippo.files.isFile
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
// TODO: Remove this after a few releases
private const val LEGACY_THUMB_FILE = ".thumb"
private const val THUMB_FILE = "thumb.jpg"
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext {
val legacyThumb = downloadLocation / location / LEGACY_THUMB_FILE
if (legacyThumb.isFile) {
legacyThumb.apply { renameTo(THUMB_FILE) }
} else {
downloadLocation / location / THUMB_FILE
}
}
val thumb = withIOContext { downloadLocation / location / THUMB_FILE }
if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
val new = chain.request.newBuilder().data(thumb.toUri()).build()
val result = chain.withRequest(new).proceed()
if (result is SuccessResult) return result
}
@@ -38,11 +30,9 @@ object DownloadThumbInterceptor : Interceptor {
withIOContext {
// Accessing the recreated file immediately after deleting it throws
// FileNotFoundException, so we just overwrite the existing file.
if (thumb.ensureFile()) {
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data.asUniFile() sendTo thumb
}
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data sendTo thumb
}
}
}
@@ -2,7 +2,7 @@ package com.hippo.ehviewer.coil
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import coil3.asCoilImage
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.NetworkHeaders
@@ -42,7 +42,7 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
httpHeaders(header)
}
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
private val stubImage = ColorDrawable(Color.BLACK).asImage(true)
private val stubResult = DecodeResult(stubImage, false)
private val stubFactory = Decoder { stubResult }
@@ -1,24 +1,57 @@
package com.hippo.ehviewer.coil
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.ui.unit.IntRect
import coil3.BitmapImage
import coil3.Extras
import coil3.asImage
import coil3.getExtra
import coil3.intercept.Interceptor
import coil3.intercept.Interceptor.Chain
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
/**
* Allocating hardware bitmaps may fail on extremely long images.
*
* Retry with [allowHardware] disabled.
*/
private val hardwareThresholdKey = Extras.Key(default = 16384)
fun ImageRequest.Builder.hardwareThreshold(size: Int) = apply {
extras[hardwareThresholdKey] = size
}
val ImageRequest.hardwareThreshold: Int
get() = getExtra(hardwareThresholdKey)
@RequiresApi(Build.VERSION_CODES.O)
object HardwareBitmapInterceptor : Interceptor {
override suspend fun intercept(chain: Chain): ImageResult {
val result = chain.proceed()
return if (chain.request.allowHardware && result is ErrorResult && result.throwable is OutOfMemoryError) {
val req = chain.request.newBuilder().allowHardware(false).build()
chain.withRequest(req).proceed()
} else {
result
val request = result.request
if (!request.allowHardware && result is SuccessResult) {
var rect: IntRect? = null
val image = result.image
val bitmap = when (image) {
is BitmapImageWithRect -> image.image.also { rect = image.rect }
is BitmapImage -> image
else -> return result
}.bitmap
// Large hardware bitmaps have rendering issues (e.g. crash, empty) on some devices.
// This is not ideal but I haven't figured out how to probe the threshold.
// All we know is that it's less than the maximum texture size.
if (maxOf(bitmap.width, bitmap.height) <= request.hardwareThreshold) {
bitmap.copy(Bitmap.Config.HARDWARE, false)?.let { hwBitmap ->
bitmap.recycle()
val newImage = hwBitmap.asImage()
return result.copy(
image = rect?.let { BitmapImageWithRect(it, newImage as BitmapImage) } ?: newImage,
request = request,
dataSource = result.dataSource,
)
}
}
}
return result
}
}
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val req = chain.request
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorCoroutineContext) { chain.proceed() }
suspendCancellableCoroutine { continuation ->
synchronized(pendingContinuationMapLock) {
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
}
try {
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
return withContext(req.interceptorCoroutineContext) { chain.proceed() }.apply {
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
notifyScope.launch {
synchronized(pendingContinuationMapLock) {
@@ -1,11 +0,0 @@
package com.hippo.ehviewer.cronet
import io.ktor.utils.io.pool.DirectByteBufferPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
val pool = DirectByteBufferPool(32)
// Limit thread to 1 since we are async & non-blocking
val cronetDispatcher = Dispatchers.Default.limitedParallelism(1)
val cronetHttpClientExecutor = cronetDispatcher.asExecutor()
@@ -1,7 +1,7 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import okio.Path.Companion.toOkioPath
import java.io.File
import org.chromium.net.CronetEngine
import splitties.init.appCtx
@@ -10,8 +10,8 @@ val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
setUserAgent(Settings.userAgent)
// Cache Quic hint only since the real cache mechanism should on Ktor layer
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
setStoragePath(cache.absolutePath)
val cache = File(appCtx.cacheDir, "http_cache").apply { mkdirs() }
setStoragePath(cache.path)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
addQuicHint("e-hentai.org", 443, 443)
@@ -37,8 +37,7 @@ import splitties.init.appCtx
private const val CHANNEL_ID = "DailyCheckNotification"
class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
class DailyCheckWork(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withIOContext {
checkDawn().onFailure {
return@withIOContext Result.retry()
@@ -72,7 +72,8 @@ data class DownloadInfo(
@Embedded
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
) : GalleryInfo by galleryInfo,
AbstractDownloadInfo by downloadInfo {
companion object {
const val STATE_INVALID = -1
@@ -38,17 +38,18 @@ import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.SPIDER_INFO_FILENAME
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.spider.putToDownloadDir
import com.hippo.ehviewer.spider.downloadDirname
import com.hippo.ehviewer.spider.readComicInfo
import com.hippo.ehviewer.spider.readCompatFromUniFile
import com.hippo.ehviewer.spider.readCompatFromPath
import com.hippo.ehviewer.spider.toSimpleTags
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.insertWith
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.asUniFileOrNull
import com.hippo.files.delete
import com.hippo.files.find
import com.hippo.files.toOkioPath
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -61,6 +62,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import logcat.LogPriority
import okio.Path
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import splitties.preferences.edit
object DownloadManager : OnSpiderListener, CoroutineScope {
@@ -172,7 +176,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
} else {
// It is new download info
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
info = DownloadInfo(galleryInfo, galleryInfo.downloadDirname())
info.label = label
info.state = DownloadInfo.STATE_WAIT
// Add to all download list and map
@@ -541,7 +545,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
info.downloadDir?.run {
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
val comicInfo = find(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) {
val galleryInfo = if (updateGallery) {
info.pages = comicInfo.pageCount
@@ -564,8 +568,8 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
null
}
} else if (info.pages == 0) {
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
val galleryInfo = find(SPIDER_INFO_FILENAME)?.let {
readCompatFromPath(it)?.run {
info.pages = pages
info.galleryInfo
}
@@ -605,6 +609,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
override fun onGet509(index: Int) {
launch {
mDownloadListener?.onGet509()
stopAllDownload()
}
}
@@ -626,7 +631,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
info.finished = finished
info.downloaded = downloaded
info.total = total
mDownloadListener?.onGetPage(info)
mDownloadListener?.onDownload(info)
mutableNotifyFlow.emit(info)
} ?: logcat(TAG, LogPriority.ERROR) { "Current task is null, but it should not be" }
}
@@ -702,11 +707,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
*/
fun onDownload(info: DownloadInfo)
/**
* Update page downloaded
*/
fun onGetPage(info: DownloadInfo)
/**
* Download done
*/
@@ -813,18 +813,21 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
private fun comparator() = SortMode.from(Settings.downloadSortMode.value).comparator()
}
var downloadLocation: UniFile
var downloadLocation: Path
get() = with(Settings) {
val uri = Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build()
uri.asUniFileOrNull() ?: AppConfig.defaultDownloadDir?.asUniFile() ?: UniFile.Stub
if (downloadScheme != null) {
Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build().toOkioPath()
} else {
AppConfig.defaultDownloadDir?.toOkioPath() ?: "".toPath()
}
}
set(value) = with(value.uri) {
set(value) = with(value.toUri()) {
Settings.edit {
downloadScheme = scheme
downloadAuthority = encodedAuthority
@@ -835,5 +838,5 @@ var downloadLocation: UniFile
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.run { findFile("$gid.cbz") ?: findFile("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")
val DownloadInfo.archiveFile get() = downloadDir?.run { find("$gid.cbz") ?: find("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")?.toOkioPath()
@@ -21,8 +21,6 @@ import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.SystemClock
import androidx.annotation.IntDef
import androidx.collection.LongSparseArray
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat
@@ -36,55 +34,57 @@ import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.unsafeLazy
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineScope {
class DownloadService :
Service(),
DownloadManager.DownloadListener,
CoroutineScope {
override val coroutineContext = Dispatchers.IO + SupervisorJob()
private val deferredMgr = async { DownloadManager.apply { setDownloadListener(this@DownloadService) } }
private var mNotifyManager: NotificationManagerCompat? = null
private var mDownloadingBuilder: NotificationCompat.Builder? = null
private var mDownloadedBuilder: NotificationCompat.Builder? = null
private var m509dBuilder: NotificationCompat.Builder? = null
private var mDownloadingDelay: NotificationDelay? = null
private var mDownloadedDelay: NotificationDelay? = null
private var m509Delay: NotificationDelay? = null
private var mChannelID: String? = null
private val deferredMgr = async { DownloadManager }
private val notifyManager by unsafeLazy { NotificationManagerCompat.from(this) }
private val downloadingNotification by unsafeLazy { initDownloadingNotification() }
private val downloadedNotification by lazy { initDownloadedNotification() }
private val error509Notification by lazy { init509Notification() }
private val channelId by unsafeLazy { "$packageName.download" }
override fun onCreate() {
super.onCreate()
mChannelID = "$packageName.download"
mNotifyManager = NotificationManagerCompat.from(this)
mNotifyManager!!.createNotificationChannel(
NotificationChannelCompat.Builder(mChannelID!!, NotificationManagerCompat.IMPORTANCE_LOW)
notifyManager.createNotificationChannel(
NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.download_service)).build(),
)
ensureDownloadingBuilder()
mDownloadingBuilder!!.setContentTitle(getString(R.string.download_service))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, mDownloadingBuilder!!.build())
downloadingNotification.builder.run {
setContentTitle(getString(R.string.download_service))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
startForeground(ID_DOWNLOADING, build())
}
launch {
deferredMgr.await().setDownloadListener(this@DownloadService)
}
}
override fun onDestroy() {
super.onDestroy()
val scope = this
launch {
deferredMgr.await().setDownloadListener(null)
// Wait for the last notification to be posted
delay(DELAY)
scope.cancel()
}
mNotifyManager = null
mDownloadingBuilder = null
mDownloadedBuilder = null
m509dBuilder = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -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() {
if (mDownloadingBuilder != null) {
return
}
private fun initDownloadingNotification(): NotificationHandler {
val stopAllIntent = Intent(this, DownloadService::class.java)
stopAllIntent.action = ACTION_STOP_ALL
val piStopAll =
PendingIntent.getService(this, 0, stopAllIntent, PendingIntent.FLAG_IMMUTABLE)
mDownloadingBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setAutoCancel(false)
@@ -173,15 +170,11 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
piStopAll,
)
.setShowWhen(false)
.setChannelId(mChannelID!!)
mDownloadingDelay =
NotificationDelay(this, mNotifyManager!!, mDownloadingBuilder!!, ID_DOWNLOADING)
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADING)
.apply { launch { run() } }
}
private fun ensureDownloadedBuilder() {
if (mDownloadedBuilder != null) {
return
}
private fun initDownloadedNotification(): NotificationHandler {
val clearIntent = Intent(this, DownloadService::class.java)
clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
@@ -191,29 +184,26 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
val piActivity = PendingIntent.getActivity(
this@DownloadService,
this,
0,
activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
mDownloadedBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(getString(R.string.stat_download_done_title))
.setDeleteIntent(piClear)
.setOngoing(false)
.setAutoCancel(true)
.setContentIntent(piActivity)
mDownloadedDelay =
NotificationDelay(this, mNotifyManager!!, mDownloadedBuilder!!, ID_DOWNLOADED)
return NotificationHandler(this, notifyManager, builder, ID_DOWNLOADED)
.apply { launch { run() } }
}
private fun ensure509Builder() {
if (m509dBuilder != null) {
return
}
m509dBuilder = NotificationCompat.Builder(applicationContext, mChannelID!!)
private fun init509Notification(): NotificationHandler {
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_baseline_warning_24)
.setContentText(getString(R.string.stat_509_alert_title))
.setContentTitle(getString(R.string.stat_509_alert_title))
.setContentText(getString(R.string.stat_509_alert_text))
.setStyle(
NotificationCompat.BigTextStyle().bigText(getString(R.string.stat_509_alert_text)),
@@ -221,89 +211,61 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
.setAutoCancel(true)
.setOngoing(false)
.setCategory(NotificationCompat.CATEGORY_ERROR)
m509Delay = NotificationDelay(this, mNotifyManager!!, m509dBuilder!!, ID_509)
return NotificationHandler(this, notifyManager, builder, ID_509)
.apply { launch { run() } }
}
override fun onGet509() {
launch {
deferredMgr.await().stopAllDownload()
if (mNotifyManager == null) {
return@launch
}
ensure509Builder()
m509dBuilder!!.setWhen(System.currentTimeMillis())
m509Delay!!.show()
error509Notification.run {
builder.setWhen(System.currentTimeMillis())
show()
}
}
override fun onStart(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
ensureDownloadingBuilder()
val bundle = Bundle()
bundle.putLong(KEY_GID, info.gid)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
val piActivity = PendingIntent.getActivity(
this@DownloadService,
this,
0,
activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
.setContentIntent(piActivity)
mDownloadingDelay!!.startForeground()
}
private fun onUpdate(info: DownloadInfo) {
if (mNotifyManager == null) {
return
downloadingNotification.run {
builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(null)
.setSubText(null)
.setProgress(0, 0, true)
.setContentIntent(piActivity)
startForeground()
}
ensureDownloadingBuilder()
var speed = info.speed
if (speed < 0) {
speed = 0
}
var text = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
text = if (remaining >= 0) {
getString(
R.string.download_speed_text_2,
text,
ReadableTime.getShortTimeInterval(remaining),
)
} else {
getString(R.string.download_speed_text, text)
}
mDownloadingBuilder!!.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
mDownloadingDelay!!.startForeground()
}
override fun onDownload(info: DownloadInfo) {
onUpdate(info)
}
override fun onGetPage(info: DownloadInfo) {
onUpdate(info)
val speed = info.speed.coerceAtLeast(0)
val speedText = FileUtils.humanReadableByteCount(speed, false) + "/s"
val remaining = info.remaining
val text = if (remaining >= 0) {
val interval = ReadableTime.getShortTimeInterval(remaining)
getString(R.string.download_speed_text_2, speedText, interval)
} else {
getString(R.string.download_speed_text, speedText)
}
downloadingNotification.run {
builder.setContentTitle(EhUtils.getSuitableTitle(info))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSubText(if (info.total == -1 || info.finished == -1) null else info.finished.toString() + "/" + info.total)
.setProgress(info.total, info.finished, false)
startForeground()
}
}
override fun onFinish(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
ensureDownloadedBuilder()
downloadingNotification.cancel()
val finish = info.state == DownloadInfo.STATE_FINISH
val gid = info.gid
val index = sItemStateArray.indexOfKey(gid)
@@ -392,21 +354,18 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
} else {
style = null
}
mDownloadedBuilder!!.setContentText(text)
.setStyle(style)
.setWhen(System.currentTimeMillis())
.setNumber(sDownloadedCount)
mDownloadedDelay!!.show()
downloadedNotification.run {
builder.setContentText(text)
.setStyle(style)
.setWhen(System.currentTimeMillis())
.setNumber(sDownloadedCount)
show()
}
checkStopSelf()
}
override fun onCancel(info: DownloadInfo) {
if (mNotifyManager == null) {
return
}
if (null != mDownloadingDelay) {
mDownloadingDelay!!.cancel()
}
downloadingNotification.cancel()
checkStopSelf()
}
@@ -419,105 +378,49 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
}
private class NotificationDelay(
private var mService: Service,
private val mNotifyManager: NotificationManagerCompat,
private val mBuilder: NotificationCompat.Builder,
private val mId: Int,
) : Runnable {
private var mLastTime: Long = 0
private var mPosted = false
// false for show, true for cancel
@Ops
private var mOps = 0
private class NotificationHandler(
private val service: Service,
private val notifyManager: NotificationManagerCompat,
val builder: NotificationCompat.Builder,
private val id: Int,
) {
private val channel = Channel<Ops>(Channel.CONFLATED)
fun show() {
if (mPosted) {
mOps = OPS_NOTIFY
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
if (ActivityCompat.checkSelfPermission(
mService,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
mNotifyManager.notify(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_NOTIFY
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
mLastTime = now
}
channel.trySend(Ops.Notify)
}
fun cancel() {
if (mPosted) {
mOps = OPS_CANCEL
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mNotifyManager.cancel(mId)
} else {
// Too quick, post delay
mOps = OPS_CANCEL
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
channel.trySend(Ops.Cancel)
}
fun startForeground() {
if (mPosted) {
mOps = OPS_START_FOREGROUND
} else {
val now = SystemClock.currentThreadTimeMillis()
if (now - mLastTime > DELAY) {
// Wait long enough, do it now
mService.startForeground(mId, mBuilder.build())
} else {
// Too quick, post delay
mOps = OPS_START_FOREGROUND
mPosted = true
SimpleHandler.postDelayed(this, DELAY)
}
}
channel.trySend(Ops.StartForeground)
}
override fun run() {
mPosted = false
when (mOps) {
OPS_NOTIFY -> {
if (ActivityCompat.checkSelfPermission(
mService,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
suspend fun run() {
channel.receiveAsFlow().sample(DELAY).collect {
when (it) {
Ops.Notify -> {
if (ActivityCompat.checkSelfPermission(
service,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
) {
notifyManager.notify(id, builder.build())
}
}
mNotifyManager.notify(mId, mBuilder.build())
}
OPS_CANCEL -> mNotifyManager.cancel(mId)
OPS_START_FOREGROUND -> mService.startForeground(mId, mBuilder.build())
Ops.Cancel -> notifyManager.cancel(id)
Ops.StartForeground -> service.startForeground(id, builder.build())
}
}
}
@IntDef(OPS_NOTIFY, OPS_CANCEL, OPS_START_FOREGROUND)
@Retention(AnnotationRetention.SOURCE)
private annotation class Ops
companion object {
private const val OPS_NOTIFY = 0
private const val OPS_CANCEL = 1
private const val OPS_START_FOREGROUND = 2
private const val DELAY: Long = 1000 // 1s
private enum class Ops {
Notify,
Cancel,
StartForeground,
}
}
@@ -544,6 +447,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
private const val ID_DOWNLOADING = 1
private const val ID_DOWNLOADED = 2
private const val ID_509 = 3
private const val DELAY = 1000L // 1s
private val sItemStateArray = LongSparseArray<Boolean>()
private val sItemTitleArray = LongSparseArray<String>()
private var sFailedCount = 0
@@ -28,8 +28,9 @@ import com.hippo.ehviewer.jni.openArchive
import com.hippo.ehviewer.jni.providePassword
import com.hippo.ehviewer.jni.releaseByteBuffer
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import com.hippo.unifile.displayPath
import com.hippo.ehviewer.util.displayPath
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import kotlinx.coroutines.CoroutineStart
@@ -41,21 +42,22 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import moe.tarsin.coroutines.NamedMutex
import moe.tarsin.coroutines.withLock
import okio.Path
typealias PasswdInvalidator = (String) -> Boolean
typealias PasswdProvider = suspend (PasswdInvalidator) -> String
class ArchivePageLoader(
private val file: UniFile,
private val file: Path,
gid: Long = 0,
startPage: Int = 0,
passwdProvider: PasswdProvider? = null,
) : PageLoader2(gid, startPage) {
private lateinit var pfd: ParcelFileDescriptor
private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" }
logcat(DEBUG_TAG) { "Open archive ${file.toUri().displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name!!.endsWith(".zip"))
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name.endsWith(".zip"))
if (size == 0) {
return@launch
}
@@ -81,7 +83,7 @@ class ArchivePageLoader(
super.stop()
closeArchive()
pfd.close()
logcat(DEBUG_TAG) { "Close archive ${file.uri.displayPath} successfully!" }
logcat(DEBUG_TAG) { "Close archive ${file.toUri().displayPath} successfully!" }
}
private val mJobMap = hashMapOf<Int, Job>()
@@ -149,19 +151,15 @@ class ArchivePageLoader(
FileUtils.getNameFromFilename(file.name)!!
}
override fun getImageExtension(index: Int): String {
return getExtension(index)
}
override fun getImageExtension(index: Int): String = getExtension(index)
override fun save(index: Int, file: UniFile): Boolean {
return runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
override fun save(index: Int, file: Path): Boolean = runCatching {
file.openFileDescriptor("w").use {
extractToFd(index, it.fd)
}
}.getOrElse {
logcat(it)
false
}
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
@@ -22,10 +22,11 @@ import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.unifile.UniFile
import okio.Path
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
PageLoader2(mGalleryInfo.gid, startPage), OnSpiderListener {
PageLoader2(mGalleryInfo.gid, startPage),
OnSpiderListener {
private lateinit var mSpiderQueen: SpiderQueen
override fun start() {
super.start()
@@ -45,13 +46,9 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
EhUtils.getSuitableTitle(mGalleryInfo)
}
override fun getImageExtension(index: Int): String? {
return mSpiderQueen.getExtension(index)
}
override fun getImageExtension(index: Int): String? = mSpiderQueen.getExtension(index)
override fun save(index: Int, file: UniFile): Boolean {
return mSpiderQueen.save(index, file)
}
override fun save(index: Int, file: Path): Boolean = mSpiderQueen.save(index, file)
override val size: Int
get() = mSpiderQueen.size
@@ -64,9 +61,7 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
mSpiderQueen.forceRequest(index, orgImg)
}
override suspend fun awaitReady(): Boolean {
return super.awaitReady() && mSpiderQueen.awaitReady()
}
override suspend fun awaitReady(): Boolean = super.awaitReady() && mSpiderQueen.awaitReady()
override val isReady: Boolean
get() = ::mSpiderQueen.isInitialized && mSpiderQueen.isReady
@@ -18,7 +18,6 @@ package com.hippo.ehviewer.gallery
import androidx.annotation.CallSuper
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -26,8 +25,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okio.Path
abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoader(), CoroutineScope {
abstract class PageLoader2(private val gid: Long, var startPage: Int) :
PageLoader(),
CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private val progressJob = launch(start = CoroutineStart.LAZY) {
if (startPage == -1) {
@@ -66,9 +68,5 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
abstract fun save(index: Int, file: UniFile): Boolean
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
(dir / filename).takeIf { save(index, it) }
}
abstract fun save(index: Int, file: Path): Boolean
}
@@ -30,10 +30,12 @@ import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Dimension
import coil3.size.Precision
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.coil.BitmapImageWithRect
import com.hippo.ehviewer.coil.hardwareThreshold
import com.hippo.ehviewer.coil.maybeCropBorder
import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap
@@ -42,9 +44,11 @@ import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile
import com.hippo.files.openFileDescriptor
import com.hippo.files.toUri
import eu.kanade.tachiyomi.util.system.logcat
import java.nio.ByteBuffer
import okio.Path
import splitties.init.appCtx
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
@@ -75,12 +79,14 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
companion object {
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
private suspend fun Either<ByteBufferSource, PathSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.uri) }
onRight { data(it.source.toUri()) }
size(Dimension(targetWidth), Dimension.Undefined)
precision(Precision.INEXACT)
allowHardware(false)
hardwareThreshold(Settings.hardwareBitmapThreshold)
maybeCropBorder(Settings.cropBorder.value)
memoryCachePolicy(CachePolicy.DISABLED)
}
@@ -93,7 +99,7 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
suspend fun decode(src: ImageSource): Image? {
return runCatching {
val image = when (src) {
is UniFileSource -> {
is PathSource -> {
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
@@ -133,8 +139,8 @@ class Image private constructor(image: CoilImage, private val src: AutoCloseable
sealed interface ImageSource : AutoCloseable
interface UniFileSource : ImageSource {
val source: UniFile
interface PathSource : ImageSource {
val source: Path
val type: String
}
@@ -0,0 +1,9 @@
package com.hippo.ehviewer.ktor
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.HttpClientEngineFactory
object Cronet : HttpClientEngineFactory<CronetConfig> {
override fun create(block: CronetConfig.() -> Unit): HttpClientEngine =
CronetEngine(CronetConfig().apply(block))
}
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.ktor
import android.content.Context
import io.ktor.client.engine.HttpClientEngineConfig
import org.chromium.net.CronetEngine
class CronetConfig : HttpClientEngineConfig() {
var client: CronetEngine? = null
fun config(context: Context, block: CronetEngine.Builder.() -> Unit) {
client = CronetEngine.Builder(context).apply(block).build()
}
}
@@ -1,11 +1,6 @@
package com.hippo.ehviewer.ktor
import com.hippo.ehviewer.cronet.cronetDispatcher
import com.hippo.ehviewer.cronet.cronetHttpClient
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
import com.hippo.ehviewer.cronet.pool
import io.ktor.client.engine.HttpClientEngineBase
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.callContext
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
@@ -18,12 +13,15 @@ import io.ktor.util.date.GMTDate
import io.ktor.util.flattenForEach
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.writer
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job
@@ -33,9 +31,12 @@ import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import org.chromium.net.apihelpers.UploadDataProviders
object CronetEngine : HttpClientEngineBase("Cronet") {
override val config = HttpClientEngineConfig()
override val dispatcher = cronetDispatcher
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
// Limit thread to 1 since we are async & non-blocking
override val dispatcher = Dispatchers.Default.limitedParallelism(1)
private val executor = dispatcher.asExecutor()
private val pool = DirectByteBufferPool(32)
private val client = config.client ?: error("Cronet client is not configured")
@InternalAPI
override suspend fun execute(data: HttpRequestData) = executeHttpRequest(callContext(), data)
@@ -94,12 +95,12 @@ object CronetEngine : HttpClientEngineBase("Cronet") {
}
}
cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
client.newUrlRequestBuilder(data.url.toString(), callback, executor).apply {
setHttpMethod(data.method.value)
data.headers.flattenForEach { key, value -> addHeader(key, value) }
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
data.body.toUploadDataProvider()?.let { setUploadDataProvider(it, executor) }
}.build().apply {
start()
callContext.job.invokeOnCompletion { cancel() }
@@ -7,17 +7,11 @@ import okio.source
fun readLegacySpiderInfo(inputStream: InputStream): SpiderInfo {
val source = inputStream.source().buffer()
fun read(): String {
return source.readUtf8LineStrict()
}
fun read(): String = source.readUtf8LineStrict()
fun readInt(): Int {
return read().toInt()
}
fun readInt(): Int = read().toInt()
fun readLong(): Long {
return read().toLong()
}
fun readLong(): Long = read().toLong()
repeat(2) { read() } // We assert that only info v2
val gid = readLong()
val token = read()
@@ -5,11 +5,8 @@ import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.SimpleTagsConverter
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -18,15 +15,19 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.XmlUtilInternal
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.newGenericWriter
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.serialization.XML
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.xmlStreaming
import okio.Path
const val COMIC_INFO_FILE = "ComicInfo.xml"
private const val TAG_ORIGINAL = "original"
// Workaround for https://youtrack.jetbrains.com/issue/KT-69182
@OptIn(XmlUtilInternal::class)
private val xml = XML {
recommended {
ignoreUnknownChildren()
@@ -97,22 +98,18 @@ fun ComicInfo.toSimpleTags() = listOfNotNull(
teams,
).flatten().ifEmpty { null }
fun ComicInfo.write(file: UniFile) {
file.openOutputStream().use {
OutputStreamWriter(it, Charsets.UTF_8).use { writer ->
xmlStreaming.newGenericWriter(writer).use { xmlWriter ->
xml.encodeToWriter(xmlWriter, ComicInfo.serializer(), this)
}
fun ComicInfo.write(file: Path) {
file.openOutputStream().bufferedWriter().use {
xmlStreaming.newWriter(it).use { writer ->
xml.encodeToWriter(writer, ComicInfo.serializer(), this)
}
}
}
fun readComicInfo(file: UniFile): ComicInfo? = runCatching {
file.openInputStream().use {
InputStreamReader(it, Charsets.UTF_8).use { reader ->
xmlStreaming.newGenericReader(reader).use { xmlReader ->
xml.decodeFromReader(ComicInfo.serializer(), xmlReader)
}
fun readComicInfo(file: Path): ComicInfo? = runCatching {
file.openInputStream().bufferedReader().use {
xmlStreaming.newReader(it).use { reader ->
xml.decodeFromReader(ComicInfo.serializer(), reader)
}
}
}.getOrNull()
@@ -18,12 +18,10 @@ object DownloadInfoMagics {
}
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> {
return if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> = if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
}
@@ -36,14 +36,18 @@ import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.image.UniFileSource
import com.hippo.ehviewer.image.PathSource
import com.hippo.ehviewer.jni.archiveFdBatch
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.openOutputStream
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.sha1
import com.hippo.files.delete
import com.hippo.files.exists
import com.hippo.files.find
import com.hippo.files.isDirectory
import com.hippo.files.list
import com.hippo.files.openFileDescriptor
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
@@ -53,16 +57,23 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.nio.copyTo
import kotlin.io.path.readText
import kotlinx.coroutines.CancellationException
import okio.Path
class SpiderDen(val info: GalleryInfo) {
private val gid = info.gid
var downloadDir: UniFile? = null
var downloadDir: Path? = null
private set
private var tempDownloadDir: UniFile? = null
private var tempDownloadDir: Path? = null
private val saveAsCbz = Settings.saveAsCbz
private val archiveName = "$gid.cbz"
// Search in both directories to maintain compatibility
private val fileCache by lazy {
(tempDownloadDir?.list().orEmpty() + downloadDir?.list().orEmpty())
.associateBy { it.name } as MutableMap
}
private val imageDir
get() = tempDownloadDir.takeIf { saveAsCbz } ?: downloadDir
@@ -79,10 +90,10 @@ class SpiderDen(val info: GalleryInfo) {
mode = value
if (mode == SpiderQueen.MODE_DOWNLOAD) {
if (downloadDir == null) {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.ensureDir() }
downloadDir = getGalleryDownloadDir(info)
}
if (saveAsCbz && tempDownloadDir == null) {
tempDownloadDir = info.tempDownloadDir?.takeIf { it.ensureDir() }
tempDownloadDir = info.tempDownloadDir!!
}
}
}
@@ -92,14 +103,12 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.read(key) { true } ?: false
}
// Search in both directories to maintain compatibility
private fun findImageFile(index: Int): UniFile? {
return tempDownloadDir?.findImageFile(index) ?: downloadDir?.findImageFile(index)
private fun findImageFile(index: Int) = synchronized(fileCache) {
val head = perFilename(index)
fileCache.entries.firstOrNull { (name) -> name.startsWith(head) }?.value
}
private fun containInDownloadDir(index: Int): Boolean {
return findImageFile(index) != null
}
private fun containInDownloadDir(index: Int): Boolean = findImageFile(index) != null
private fun copyFromCacheToDownloadDir(index: Int): Boolean {
val dir = imageDir ?: return false
@@ -107,8 +116,8 @@ class SpiderDen(val info: GalleryInfo) {
return runCatching {
sCache.read(key) {
val extension = metadata.toFile().readText()
val file = dir.createFile(perFilename(index, extension)) ?: return false
data.asUniFile() sendTo file
val file = dir.findDownloadFileForIndex(index, extension)
data sendTo file
true
} ?: false
}.onFailure {
@@ -116,19 +125,17 @@ class SpiderDen(val info: GalleryInfo) {
}.getOrDefault(false)
}
operator fun contains(index: Int): Boolean {
return when (mode) {
SpiderQueen.MODE_READ -> {
containInCache(index) || containInDownloadDir(index)
}
operator fun contains(index: Int): Boolean = when (mode) {
SpiderQueen.MODE_READ -> {
containInCache(index) || containInDownloadDir(index)
}
SpiderQueen.MODE_DOWNLOAD -> {
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
}
SpiderQueen.MODE_DOWNLOAD -> {
containInDownloadDir(index) || copyFromCacheToDownloadDir(index)
}
else -> {
false
}
else -> {
false
}
}
@@ -137,16 +144,20 @@ class SpiderDen(val info: GalleryInfo) {
return sCache.remove(key)
}
private fun removeFromDownloadDir(index: Int): Boolean {
return findImageFile(index)?.delete() ?: false
private fun removeFromDownloadDir(index: Int) = findImageFile(index)?.run {
delete()
synchronized(fileCache) {
fileCache.remove(name)
}
}
fun remove(index: Int): Boolean {
return removeFromCache(index) or removeFromDownloadDir(index)
}
fun remove(index: Int) = removeFromCache(index).also { removeFromDownloadDir(index) }
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? {
return imageDir?.createFile(perFilename(index, extension))
private fun Path.findDownloadFileForIndex(index: Int, extension: String): Path {
val name = perFilename(index, extension)
return synchronized(fileCache) {
fileCache.getOrPut(name) { resolve(name) }
}
}
suspend fun makeHttpCallAndSaveImage(
@@ -171,10 +182,10 @@ class SpiderDen(val info: GalleryInfo) {
private suspend inline fun saveResponseMeta(
index: Int,
ext: String,
crossinline fops: suspend (UniFile) -> Unit,
crossinline fops: suspend (Path) -> Unit,
): Boolean {
findDownloadFileForIndex(index, ext)?.run {
fops(this)
imageDir?.run {
fops(findDownloadFileForIndex(index, ext))
return true
}
@@ -183,7 +194,7 @@ class SpiderDen(val info: GalleryInfo) {
val key = getImageKey(gid, index)
return sCache.suspendEdit(key) {
metadata.toFile().writeText(ext)
fops(data.asUniFile())
fops(data)
}
}
return false
@@ -196,19 +207,19 @@ class SpiderDen(val info: GalleryInfo) {
outFile.openOutputStream().use {
response.bodyAsChannel().copyTo(it.channel)
}
val expected = FileHashRegex.find(url)?.run { groupValues[0] }
val expected = FileHashRegex.findAll(url).last().groupValues[1]
val actual = outFile.sha1()
check(expected == actual) { "File hash mismatch: expected $expected, but got $actual\nURL: $url" }
}
}
fun saveToUniFile(index: Int, file: UniFile): Boolean {
fun saveToPath(index: Int, file: Path): Boolean {
val key = getImageKey(gid, index)
// Read from diskCache first
sCache.read(key) {
runCatching {
data.asUniFile() sendTo file
data sendTo file
return true
}.onFailure {
logcat(it)
@@ -234,13 +245,13 @@ class SpiderDen(val info: GalleryInfo) {
?: findImageFile(index)?.name.let { FileUtils.getExtensionFromFilename(it) }
}
fun getImageSource(index: Int): UniFileSource? {
fun getImageSource(index: Int): PathSource? {
if (mode == SpiderQueen.MODE_READ) {
val key = getImageKey(gid, index)
val snapshot = sCache.openSnapshot(key)
if (snapshot != null) {
return object : UniFileSource, AutoCloseable by snapshot {
override val source = snapshot.data.asUniFile()
return object : PathSource, AutoCloseable by snapshot {
override val source = snapshot.data
override val type by lazy {
snapshot.metadata.toNioPath().readText()
}
@@ -248,7 +259,7 @@ class SpiderDen(val info: GalleryInfo) {
}
}
val source = findImageFile(index) ?: return null
return object : UniFileSource {
return object : PathSource {
override val source = source
override val type by lazy {
FileUtils.getExtensionFromFilename(source.name)!!
@@ -261,7 +272,7 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun archive() {
if (saveAsCbz) {
downloadDir?.run {
findFile(archiveName) ?: createFile(archiveName)?.let { file ->
resolve(archiveName).let { file ->
runCatching {
archiveTo(file)
}.onFailure {
@@ -276,23 +287,23 @@ class SpiderDen(val info: GalleryInfo) {
suspend fun postArchive(): Boolean {
val dir = downloadDir
val archived = saveAsCbz && dir?.findFile(archiveName) != null
val archived = saveAsCbz && dir?.find(archiveName) != null
if (archived) {
dir.listFiles().parMap(concurrency = 10) {
if (it.name?.matches(FileNameRegex) == true) {
dir.list().parMap(concurrency = 10) {
if (it.name.matches(FileNameRegex)) {
it.delete()
}
}
dir.findFile(SpiderQueen.SPIDER_INFO_FILENAME)?.delete()
(dir / SpiderQueen.SPIDER_INFO_FILENAME).delete()
tempDownloadDir?.delete()
}
return archived
}
suspend fun exportAsCbz(file: UniFile) =
downloadDir!!.findFile(archiveName)?.sendTo(file) ?: archiveTo(file)
suspend fun exportAsCbz(file: Path) =
downloadDir!!.find(archiveName)?.sendTo(file) ?: archiveTo(file)
private suspend fun archiveTo(file: UniFile) = resourceScope {
private suspend fun archiveTo(file: Path) = resourceScope {
val comicInfo = closeable {
val f = downloadDir!! / COMIC_INFO_FILE
if (!f.exists()) {
@@ -312,18 +323,17 @@ class SpiderDen(val info: GalleryInfo) {
}
suspend fun initDownloadDirIfExist() {
downloadDir = getGalleryDownloadDir(gid)?.takeIf { it.isDirectory }
downloadDir = getGalleryDownloadDir(info).takeIf { it.isDirectory }
tempDownloadDir = info.tempDownloadDir?.takeIf { it.isDirectory }
}
suspend fun initDownloadDir() {
downloadDir = getGalleryDownloadDir(gid) ?: (downloadLocation / info.putToDownloadDir())
check(downloadDir!!.ensureDir())
downloadDir = getGalleryDownloadDir(info)
}
suspend fun writeComicInfo(fetchMetadata: Boolean = true) {
downloadDir?.run {
createFile(COMIC_INFO_FILE)?.also {
resolve(COMIC_INFO_FILE).also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
EhEngine.fillGalleryListByApi(listOf(info))
@@ -344,25 +354,21 @@ class SpiderDen(val info: GalleryInfo) {
}
private val FileNameRegex = Regex("^\\d{8}\\.\\w{3,4}")
private val FileHashRegex = Regex("[0-9a-f]{40}")
private val FileHashRegex = Regex("/([0-9a-f]{40})(?:-\\d+){3}-\\w+")
fun perFilename(index: Int, extension: String = ""): String {
return "%08d.%s".format(index + 1, extension)
}
fun perFilename(index: Int, extension: String = ""): String = "%08d.%s".format(index + 1, extension)
private fun UniFile.findImageFile(index: Int): UniFile? {
val head = perFilename(index)
return findFirst { name -> name.startsWith(head) }
}
suspend fun GalleryInfo.putToDownloadDir(): String {
val title = getSuitableTitle(this)
val dirname = FileUtils.sanitizeFilename("$gid-$title")
EhDB.putDownloadDirname(gid, dirname)
suspend fun GalleryInfo.downloadDirname(): String {
var dirname = EhDB.getDownloadDirname(gid)
if (dirname == null) {
val title = getSuitableTitle(this)
dirname = FileUtils.sanitizeFilename("$gid-$title")
EhDB.putDownloadDirname(gid, dirname)
}
return dirname
}
suspend fun getGalleryDownloadDir(gid: Long): UniFile? {
val dirname = EhDB.getDownloadDirname(gid) ?: return null
suspend fun getGalleryDownloadDir(info: GalleryInfo): Path {
val dirname = info.downloadDirname()
return downloadLocation / dirname
}
@@ -4,15 +4,15 @@ import com.hippo.ehviewer.coil.edit
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.legacy.readLegacySpiderInfo
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import com.hippo.unifile.openOutputStream
import com.hippo.files.openInputStream
import com.hippo.files.openOutputStream
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import okio.Path.Companion.toOkioPath
import splitties.init.appCtx
@@ -35,7 +35,7 @@ private val cbor = Cbor {
ignoreUnknownKeys = true
}
fun SpiderInfo.write(file: UniFile) {
fun SpiderInfo.write(file: Path) {
file.openOutputStream().use {
it.write(cbor.encodeToByteArray(this))
}
@@ -58,24 +58,20 @@ private val spiderInfoCache by lazy {
}
}
fun readFromCache(gid: Long): SpiderInfo? {
return spiderInfoCache.read(gid.toString()) {
runCatching {
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
}.onFailure {
logcat(it)
}.getOrNull()
}
}
fun readCompatFromUniFile(file: UniFile): SpiderInfo? {
return runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
fun readFromCache(gid: Long): SpiderInfo? = spiderInfoCache.read(gid.toString()) {
runCatching {
cbor.decodeFromByteArray<SpiderInfo>(data.toFile().readBytes())
}.onFailure {
logcat(it)
}.getOrNull()
}
fun readCompatFromPath(file: Path): SpiderInfo? = runCatching {
file.openInputStream().use {
cbor.decodeFromByteArray<SpiderInfo>(it.readBytes())
}
}.getOrNull() ?: runCatching {
file.openInputStream().use { readLegacySpiderInfo(it) }
}.getOrNull()
const val TOKEN_FAILED = "failed"
@@ -38,7 +38,7 @@ import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile
import com.hippo.files.find
import eu.kanade.tachiyomi.util.system.logcat
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
@@ -61,6 +61,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineScope {
@@ -302,12 +303,12 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
mWorkerScope.launch(index, force, orgImg)
}
fun save(index: Int, file: UniFile): Boolean {
fun save(index: Int, file: Path): Boolean {
val state = getPageState(index)
return if (STATE_FINISHED != state) {
false
} else {
mSpiderDen.saveToUniFile(index, file)
mSpiderDen.saveToPath(index, file)
}
}
@@ -320,16 +321,14 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private fun readSpiderInfoFromLocal(): SpiderInfo? {
return mSpiderDen.downloadDir?.run {
findFile(SPIDER_INFO_FILENAME)?.let { file ->
readCompatFromUniFile(file)?.takeIf {
it.gid == galleryInfo.gid && it.token == galleryInfo.token
}
private fun readSpiderInfoFromLocal(): SpiderInfo? = mSpiderDen.downloadDir?.run {
find(SPIDER_INFO_FILENAME)?.let { file ->
readCompatFromPath(file)?.takeIf {
it.gid == galleryInfo.gid && it.token == galleryInfo.token
}
}
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
}
?: readFromCache(galleryInfo.gid)?.takeIf { it.gid == galleryInfo.gid && it.token == galleryInfo.token }
private fun readPreviews(body: String, index: Int, spiderInfo: SpiderInfo) {
spiderInfo.previewPages = parsePreviewPages(body)
@@ -349,21 +348,19 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? {
return runSuspendCatching {
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
logcat(it)
}.getOrNull()
}
private suspend fun readSpiderInfoFromInternet(): SpiderInfo? = runSuspendCatching {
ehRequest(
getGalleryDetailUrl(galleryInfo.gid, galleryInfo.token, 0, false),
referer,
).fetchUsingAsText {
val pages = parsePages(this)
val spiderInfo = SpiderInfo(galleryInfo.gid, galleryInfo.token, pages)
readPreviews(this, 0, spiderInfo)
spiderInfo
}
}.onFailure {
logcat(it)
}.getOrNull()
suspend fun getPTokenFromMultiPageViewer(index: Int): String? {
val spiderInfo = mSpiderInfo
@@ -417,13 +414,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
@Synchronized
private fun writeSpiderInfoToLocal() {
if (!isReady) return
mSpiderDen.downloadDir?.run { createFile(SPIDER_INFO_FILENAME)?.also { mSpiderInfo.write(it) } }
mSpiderDen.downloadDir?.run { mSpiderInfo.write(resolve(SPIDER_INFO_FILENAME)) }
mSpiderInfo.saveToCache()
}
private fun isStateDone(state: Int): Boolean {
return state == STATE_FINISHED || state == STATE_FAILED
}
private fun isStateDone(state: Int): Boolean = state == STATE_FINISHED || state == STATE_FAILED
fun updatePageState(index: Int, @State state: Int, error: String? = null) {
var oldState: Int
@@ -57,13 +57,16 @@ import com.hippo.ehviewer.ui.destinations.ReaderScreenDestination
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.bgWork
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.unifile.UniFile
import com.hippo.files.delete
import com.hippo.files.isDirectory
import com.hippo.files.openOutputStream
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.lang.withIOContext
@@ -77,20 +80,20 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.todayIn
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
private fun removeNoMediaFile(downloadDir: UniFile) {
val noMedia = downloadDir / ".nomedia"
noMedia.delete()
private fun removeNoMediaFile(downloadDir: Path) {
(downloadDir / ".nomedia").delete()
}
private fun ensureNoMediaFile(downloadDir: UniFile) {
downloadDir.createFile(".nomedia") ?: return
private fun ensureNoMediaFile(downloadDir: Path) {
(downloadDir / ".nomedia").openOutputStream().close()
}
private val lck = Mutex()
suspend fun keepNoMediaFileStatus(downloadDir: UniFile = downloadLocation) {
suspend fun keepNoMediaFileStatus(downloadDir: Path = downloadLocation) {
if (downloadDir.isDirectory) {
lck.withLock {
if (Settings.mediaScan) {
@@ -205,10 +208,14 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
add(localFav)
addAll(cloudFav)
}
if (galleryInfo.favoriteSlot >= 0 && galleryInfo.favoriteNote == null) {
galleryInfo.favoriteNote = bgWork { EhEngine.getFavoriteNote(galleryInfo.gid, galleryInfo.token) }
}
val (slot, note) = awaitSelectItemWithIconAndTextField(
items,
title = R.string.add_favorites_dialog_title,
hint = R.string.favorite_note,
initialNote = galleryInfo.favoriteNote.orEmpty(),
maxChar = MAX_FAVNOTE_CHAR,
)
return doModifyFavorites(galleryInfo, if (isFavorited) slot - 2 else slot - 1, localFavorited, note)
@@ -247,6 +254,7 @@ private suspend fun doModifyFavorites(
in 0..9 -> {
EhEngine.modifyFavorites(galleryInfo.gid, galleryInfo.token, slot, note)
galleryInfo.favoriteNote = note
true
}
@@ -439,9 +447,7 @@ suspend fun DialogState.awaitSelectDate(): String? {
title = R.string.go_to,
yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
return utcTimeMillis in dateRange
}
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in dateRange
},
)
val date = dateMillis?.run { toLocalDateTime().date.toString() }
@@ -135,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.calculateFraction
import com.hippo.ehviewer.util.displayString
@@ -142,8 +143,9 @@ import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.sha1
import com.hippo.files.isDirectory
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
@@ -226,7 +228,7 @@ class MainActivity : EhActivity() {
}
suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.ensureDir() }
val valid = withIOContext { downloadLocation.isDirectory }
if (!valid) {
awaitPermissionOrCancel(
confirmText = R.string.open_settings,
@@ -242,17 +244,19 @@ class MainActivity : EhActivity() {
}
}
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
if (!AppConfig.isBenchmark) {
LaunchedEffect(Unit) {
runCatching { dialogState.checkDownloadLocation() }
runCatching { dialogState.checkAppLinkVerify() }
runSuspendCatching {
withIOContext {
AppUpdater.checkForUpdate()?.let {
dialogState.showNewVersion(this@MainActivity, it)
}
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}.onFailure {
snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString()))
}
}
@@ -277,7 +281,7 @@ class MainActivity : EhActivity() {
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val hash = withIOContext { uri.asUniFile().sha1() }
val hash = withIOContext { uri.toOkioPath().sha1() }
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
@@ -5,8 +5,6 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
@@ -27,7 +25,6 @@ inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
@@ -41,7 +38,6 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
dialogState.Intercept()
CompositionLocalProvider(
LocalDialogState provides dialogState,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
content()
}
@@ -4,6 +4,7 @@ import android.graphics.drawable.Animatable
import android.graphics.drawable.DrawableWrapper
import android.text.Html
import android.widget.TextView
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.crossfade
import com.hippo.ehviewer.ktbuilder.imageRequest
@@ -22,7 +22,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import io.ktor.http.Url
@Destination<RootGraph>
@Composable
@@ -37,16 +36,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
view.destroy()
return
}
var getId = false
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) {
if (EhCookieStore.hasSignedIn()) {
present = true
coroutineScope.launchIO {
withNonCancellableContext { postLogin() }
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.dp
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.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.snackBarPadding
import kotlin.time.Duration.Companion.microseconds
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
@@ -66,35 +67,32 @@ class FabLayoutState(
val expandProgress = Animatable(if (initialValue == FabLayoutValue.Expand) 1f else 0f)
.apply { updateBounds(0f, 1f) }
private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) {
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() {
private suspend fun downTo(value: FabLayoutValue, priority: MutatePriority) = mutatorMutex.mutate(priority) {
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 hide(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Hidden, priority)
suspend fun collapse(priority: MutatePriority = MutatePriority.Default) = downTo(FabLayoutValue.Primary, priority)
suspend fun expand(priority: MutatePriority = MutatePriority.Default) = upTo(FabLayoutValue.Expand, priority)
}
private val fabSyncSchedule = Schedule.linear<Throwable>(100.microseconds)
fun interface FabBuilder {
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
@@ -118,12 +116,14 @@ fun FabLayout(
}
val state = rememberFabLayoutState(newState)
LaunchedEffect(newState) {
when (newState) {
FabLayoutValue.Expand -> state.expand()
FabLayoutValue.Hidden -> state.hide()
FabLayoutValue.Primary -> {
state.collapse()
state.show()
fabSyncSchedule.retry {
when (newState) {
FabLayoutValue.Expand -> state.expand()
FabLayoutValue.Hidden -> state.hide()
FabLayoutValue.Primary -> {
state.collapse()
state.show()
}
}
}
}
@@ -131,7 +131,7 @@ fun FabLayout(
val secondaryFab by delegateSnapshotUpdate {
record { buildFab(builder) }
transform { onEachLatest { state.waitCollapse() } }
transform { onEachLatest { state.collapse(MutatePriority.PreventUserInput) } }
}
val density = LocalDensity.current
@@ -151,9 +151,9 @@ fun GalleryList(
columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = contentModifier,
state = thumbListState,
contentPadding = contentPadding + PaddingValues(marginH, marginV),
verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = contentPadding + PaddingValues(marginH, marginV),
) {
items(
count = data.itemCount,
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import arrow.core.raise.ensure
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS
@@ -62,9 +61,11 @@ private val categoryTable = arrayOf(
fun SearchFilter(
modifier: Modifier = Modifier,
category: Int,
onCategoryChanged: (Int) -> Unit,
onCategoryChange: (Int) -> Unit,
language: Int,
onLanguageChange: (Int) -> Unit,
advancedOption: AdvancedSearchOption,
onAdvancedOptionChanged: (AdvancedSearchOption) -> Unit,
onAdvancedOptionChange: (AdvancedSearchOption) -> Unit,
) = Column(modifier) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -82,7 +83,7 @@ fun SearchFilter(
items(categories, { it.first }) {
FilterChip(
selected = isCategoryChecked(it.first),
onClick = { onCategoryChanged(category xor it.first) },
onClick = { onCategoryChange(category xor it.first) },
label = { Text(text = stringResource(id = it.second)) },
modifier = Modifier.thenIf(animateItems) { animateItem() },
)
@@ -109,14 +110,11 @@ fun SearchFilter(
}
}
}
var languageFilter by Settings.languageFilter.asMutableState()
DropdownFilterChip(
label = languageStr,
menuItems = languages,
selectedItemIndex = languageFilter + 1,
onSelectedItemIndexChange = {
languageFilter = it - 1
},
selectedItemIndex = language + 1,
onSelectedItemIndexChange = { onLanguageChange(it - 1) },
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr)
@@ -125,7 +123,7 @@ fun SearchFilter(
menuItems = minRatingItems.asList(),
selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0),
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)
@@ -153,13 +151,13 @@ fun SearchFilter(
val (from, to) = dialogState.awaitResult(
initial = advancedOption.fromPage to advancedOption.toPage,
title = R.string.key_pages,
invalidator = { (from, to) ->
if (to != 0 && to < 10) {
pageErr1
} else if (from != 0 && to != 0 && to - from < 20) {
pageErr2
invalidator = { (min, _max) ->
// 0 means max pages not filled, though +inf
val max = if (_max == 0) Int.MAX_VALUE else _max
if (min != 0) {
ensure(max - min >= 20) { pageErr2 }
} else {
null
ensure(max >= 10) { pageErr1 }
}
},
) { 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) },
)
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(
selected = checked(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.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.util.requestPermission
import com.hippo.unifile.asUniFile
import com.hippo.unifile.displayPath
import com.hippo.files.toOkioPath
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlinx.datetime.Clock
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path.Companion.toOkioPath
import splitties.systemservices.clipboardManager
context(PageLoader2)
private fun Context.provideImage(index: Int): Uri? {
val dir = AppConfig.externalTempDir ?: return null
val name = saveToDir(index, dir.asUniFile())?.name ?: return null
return FileProvider.getUriForFile(this, "$APPLICATION_ID.fileprovider", File(dir, name))
val name = getImageFilename(index) ?: return null
val file = File(dir, name).takeIf { save(index, it.toOkioPath()) } ?: return null
return FileProvider.getUriForFile(this, "$APPLICATION_ID.fileprovider", file)
}
context(SnackbarHostState, Context, PageLoader2)
@@ -95,7 +97,7 @@ suspend fun save(page: ReaderPage) {
val values = ContentValues()
val realPath: String
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)
if (isAtLeastQ) {
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)
if (imageUri != null) {
if (!save(page.index, imageUri.asUniFile())) {
if (!save(page.index, imageUri.toOkioPath())) {
try {
contentResolver.delete(imageUri, null, null)
} catch (e: Exception) {
@@ -146,7 +148,7 @@ suspend fun saveTo(page: ReaderPage) {
page.runSuspendCatching {
val uri = awaitActivityResult(ActivityResultContracts.CreateDocument(mimeType), filename)
if (uri != null) {
save(index, uri.asUniFile())
save(index, uri.toOkioPath())
showSnackbar(getString(R.string.image_saved, uri.displayPath))
}
}.onFailure {
@@ -98,10 +98,8 @@ fun rememberSliderPagerDoubleSyncState(
lazyListState: LazyListState,
pagerState: PagerState,
pageLoader: PageLoader2,
): SliderPagerDoubleSync {
return remember {
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
}
): SliderPagerDoubleSync = remember {
SliderPagerDoubleSync(lazyListState, pagerState, pageLoader)
}
private fun smoothScroll(distance: Int) =
@@ -15,8 +15,10 @@ data class DownloadsFilterState(
) : Parcelable
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) {
title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) ||
title.containsIgnoreCase(keyword) ||
titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
}
@@ -48,7 +48,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
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.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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 ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name)
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 sortModes = stringArrayResource(id = R.array.download_sort_modes)
val downloadStates = stringArrayResource(id = R.array.download_state)
val list = remember(filterState, invalidateKey) {
downloadInfoList.filterTo(mutableStateListOf()) { info ->
filterState.take(info)
@@ -227,10 +222,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
launch {
val text = awaitInputText(title = newLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
DownloadManager.containLabel(text) -> labelExists
else -> null
text.isBlank() -> raise(labelEmpty)
text == defaultName -> raise(defaultInvalid)
DownloadManager.containLabel(text) -> raise(labelExists)
}
}
DownloadManager.addLabel(text)
@@ -337,7 +331,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
val index by rememberUpdatedState(itemIndex)
val item by rememberUpdatedState(label)
// 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) {
snapshotFlow { dismissState.currentValue }.collect {
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(
state = dismissState,
backgroundContent = {},
@@ -390,10 +384,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
launch {
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
DownloadManager.containLabel(text) -> labelExists
else -> null
text.isBlank() -> raise(labelEmpty)
text == defaultName -> raise(defaultInvalid)
DownloadManager.containLabel(text) -> raise(labelExists)
}
}
DownloadManager.renameLabel(item, new)
@@ -544,9 +537,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
FastScrollLazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
contentPadding = realPadding,
verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = realPadding,
) {
items(list, key = { it.gid }) { info ->
GalleryInfoGridItem(
@@ -653,8 +646,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
onClick(Icons.AutoMirrored.Default.Sort) {
val oldMode = SortMode.from(sortMode)
val sortModes = resources.getStringArray(R.array.download_sort_modes).toList()
val (selected, checked) = awaitSelectItemWithCheckBox(
sortModes.toList(),
sortModes,
R.string.sort_by,
R.string.group_by_download_label,
SortMode.All.indexOfFirst { it.field == oldMode.field && it.order == oldMode.order },
@@ -666,8 +660,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
invalidateKey = !invalidateKey
}
onClick(Icons.Default.FilterList) {
val downloadStates = resources.getStringArray(R.array.download_state).toList()
val state = awaitSingleChoice(
downloadStates.toList(),
downloadStates,
filterState.state + 1,
R.string.download_filter,
) - 1
@@ -150,7 +150,8 @@ import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.isAtLeastQ
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.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -184,21 +185,19 @@ data class TokenArgs(
) : GalleryDetailScreenArgs
@StringRes
private fun getRatingText(rating: Float): Int {
return when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun getRatingText(rating: Float): Int = when ((rating * 2).roundToInt()) {
0 -> R.string.rating0
1 -> R.string.rating1
2 -> R.string.rating2
3 -> R.string.rating3
4 -> R.string.rating4
5 -> R.string.rating5
6 -> R.string.rating6
7 -> R.string.rating7
8 -> R.string.rating8
9 -> R.string.rating9
10 -> R.string.rating10
else -> R.string.rating_none
}
private fun List<GalleryTagGroup>.getArtistTag(): String? {
@@ -588,7 +587,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
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)
showSnackbar(downloadTorrentStarted)
}
@@ -618,14 +617,12 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
)
}
Spacer(modifier = Modifier.size(keylineMargin))
fun getAllRatingText(rating: Float, ratingCount: Int): String {
return getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
}
fun getAllRatingText(rating: Float, ratingCount: Int): String = getString(
R.string.rating_text,
getString(getRatingText(rating)),
rating,
ratingCount,
)
var ratingText by rememberSaveable {
mutableStateOf(getAllRatingText(galleryDetail.rating, galleryDetail.ratingCount))
}
@@ -1079,7 +1076,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
)
val dirname = downloadInfo?.dirname
if (uri != null && dirname != null) {
val file = uri.asUniFile()
val file = uri.toOkioPath()
val msg = runCatching {
bgWork {
withIOContext {
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.net.Uri
import android.view.ViewConfiguration
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState
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.material3.ElevatedCard
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
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.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.pickVisualMedia
import com.hippo.unifile.asUniFile
import com.hippo.unifile.sha1
import com.hippo.ehviewer.util.sha1
import com.hippo.files.toOkioPath
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -176,6 +177,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
val animateItems by Settings.animateItems.collectAsState()
var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY }
var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() }
@@ -202,7 +204,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
onBack = { showSearchLayout = false },
)
val density = LocalDensity.current
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
val listState = rememberLazyGridState()
val gridState = rememberLazyStaggeredGridState()
val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST }
@@ -253,6 +254,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}
}.flow.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
ReportDrawnWhen { data.loadState.refresh !is LoadState.Loading }
FavouriteStatusRouter.Observe(data)
val listMode by Settings.listMode.collectAsState()
@@ -262,6 +264,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val toplists = remember { entries zip values }
val quickSearchName = getSuitableTitleForUrlBuilder(urlBuilder, false)
var saveProgress by Settings.qSSaveProgress.asMutableState()
var languageFilter by Settings.languageFilter.asMutableState()
fun getFirstVisibleItemIndex() = if (listMode == 0) {
listState.firstVisibleItemIndex
@@ -340,20 +343,15 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
checkBoxText = R.string.save_progress,
) { input, checked ->
var text = input.trim()
nameEmpty.takeIf {
text.isEmpty()
} ?: dupName.takeIf {
if (checked) text += "@$next"
quickSearchList.fastAny { it.name == text }
} ?: run {
val quickSearch = urlBuilder.toQuickSearch(text)
quickSearch.position = quickSearchList.size
// Insert to DB first to update the id
EhDB.insertQuickSearch(quickSearch)
quickSearchList.add(quickSearch)
saveProgress = checked
null
}
ensure(text.isNotBlank()) { nameEmpty }
if (checked) text += "@$next"
ensure(quickSearchList.none { it.name == text }) { dupName }
val quickSearch = urlBuilder.toQuickSearch(text)
quickSearch.position = quickSearchList.size
// Insert to DB first to update the id
EhDB.insertQuickSearch(quickSearch)
quickSearchList.add(quickSearch)
saveProgress = checked
}
}
}
@@ -370,9 +368,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val quickSearchListState = rememberLazyListState()
val hapticFeedback = rememberHapticFeedback()
val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
quickSearchList.apply { add(to.index, removeAt(from.index)) }
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
@@ -381,15 +377,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
state = quickSearchListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
// Fix the first item's reorder animation
stickyHeader {
HorizontalDivider()
}
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
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
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density) }
LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
@@ -427,10 +419,14 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
ListItem(
modifier = Modifier.clickable {
if (urlBuilder.mode == MODE_WHATS_HOT) {
val builder = ListUrlBuilder(item)
val builder = ListUrlBuilder(item).apply {
language = languageFilter
}
navigator.navigate(builder.asDst())
} else {
urlBuilder = ListUrlBuilder(item)
urlBuilder = ListUrlBuilder(item).apply {
language = languageFilter
}
data.refresh()
}
showSearchLayout = false
@@ -515,6 +511,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
builder.mode = newMode
builder.keyword = query
builder.category = category
builder.language = languageFilter
builder.advanceSearch = advancedSearchOption.advanceSearch
builder.minRating = advancedSearchOption.minRating
builder.pageFrom = advancedSearchOption.fromPage
@@ -525,7 +522,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
return@launchIO
}
builder.mode = MODE_IMAGE_SEARCH
builder.hash = imageUri!!.asUniFile().sha1()
builder.hash = imageUri!!.toOkioPath().sha1()
}
when (oldMode) {
MODE_TOPLIST, MODE_WHATS_HOT -> {
@@ -578,11 +575,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
filter = {
SearchFilter(
category = category,
onCategoryChanged = { category = it },
onCategoryChange = { category = it },
language = languageFilter,
onLanguageChange = { languageFilter = it },
advancedOption = advancedSearchOption,
onAdvancedOptionChanged = {
advancedSearchOption = it
},
onAdvancedOptionChange = { advancedSearchOption = it },
)
},
floatingActionButton = {
@@ -735,11 +732,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0
val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES)
val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText ->
when (oriText.trim().toIntOrNull()?.let { it - 1 }) {
null -> invalidNum
!in 0..<TOPLIST_PAGES -> outOfRange
else -> null
}
val goto = ensureNotNull(oriText.trim().toIntOrNull()) { invalidNum } - 1
ensure(goto in 0..<TOPLIST_PAGES) { outOfRange }
}.trim().toInt() - 1
urlBuilder.setJumpTo(text)
} 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)),
state = state,
contentPadding = paddingValues,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
count = data.itemCount,
@@ -227,12 +227,14 @@ fun SearchBarScreen(
onExpandedChange = onExpandedChange,
modifier = Modifier.widthIn(max = maxWidth - SearchBarHorizontalPadding * 2),
label = title.ifNotNullThen {
Text(title!!, overflow = TextOverflow.Ellipsis)
Text(title!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
}.takeUnless {
val contentActive by activeState.state
expanded || contentActive || searchFieldState.text.isNotEmpty()
},
placeholder = searchFieldHint.ifNotNullThen { Text(searchFieldHint!!) },
placeholder = searchFieldHint.ifNotNullThen {
Text(searchFieldHint!!, overflow = TextOverflow.Ellipsis, maxLines = 1)
},
leadingIcon = {
if (expanded) {
IconButton(onClick = { hideSearchView() }) {
@@ -298,22 +300,20 @@ fun SearchBarScreen(
}
}
fun wrapTagKeyword(keyword: String, translate: Boolean = false): String {
return if (keyword.endsWith(':')) {
keyword
fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = if (keyword.endsWith(':')) {
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 {
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 {
"$keyword$"
}
"$keyword$"
}
}
@@ -154,9 +154,7 @@ suspend fun DialogState.showNewVersion(context: Context, release: Release) {
}
if (Settings.backupBeforeUpdate) {
val time = ReadableTime.getFilenamableTime()
downloadLocation.createFile("$time.db")?.let {
EhDB.exportDB(context, it)
}
EhDB.exportDB(context, (downloadLocation / "$time.db"))
}
// TODO: Download in the background and show progress in notification
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.Crash
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.getAppLanguage
import com.hippo.ehviewer.util.getLanguages
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastV
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.SwitchPref
import com.ramcosta.composedestinations.annotation.Destination
@@ -129,7 +131,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
Crash.collectInfo(zipOs.writer())
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 {
launchSnackBar(dumpLogError)
@@ -155,6 +157,15 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
useSelectedAsSummary = true,
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(
title = stringResource(id = R.string.preload_thumb_aggressively),
value = Settings::preloadThumbAggressively,
@@ -190,8 +201,8 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
uri?.let {
context.runCatching {
grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
EhDB.exportDB(context, uri.asUniFile())
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.toString()))
EhDB.exportDB(context, uri.toOkioPath())
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.displayPath))
}.onFailure {
logcat(it)
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_WRITE_URI_PERMISSION
import android.provider.DocumentsContract
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
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.verticalScroll
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.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parMapNotNull
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.SpiderQueen.Companion.SPIDER_INFO_FILENAME
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.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.displayPath
import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.displayPath
import com.hippo.files.delete
import com.hippo.files.find
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.RootGraph
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.system.logcat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import okio.Path
import splitties.init.appCtx
@Destination<RootGraph>
@Composable
fun DownloadScreen(navigator: DestinationsNavigator) {
fun DownloadScreen(navigator: DestinationsNavigator) = composing(navigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val snackbarHostState = remember { SnackbarHostState() }
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
fun launchSnackBar(content: String) = launch { showSnackbar(content) }
Scaffold(
topBar = {
TopAppBar(
@@ -84,18 +87,19 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
scrollBehavior = scrollBehavior,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
var downloadLocationState by ::downloadLocation.observed
val cannotGetDownloadLocation = stringResource(id = R.string.settings_download_cant_get_download_location)
val selectDownloadDirLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { treeUri ->
treeUri?.run {
coroutineScope.launch {
context.runCatching {
launchIO {
runCatching {
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
downloadLocationState = treeUri.asUniFile()
coroutineScope.launchNonCancellable {
val path = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)).toOkioPath()
check(path.isDirectory) { "$path is not a directory" }
downloadLocationState = path
launchNonCancellable {
keepNoMediaFileStatus(downloadLocationState)
}
}.onFailure {
@@ -107,7 +111,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
}
Preference(
title = stringResource(id = R.string.settings_download_download_location),
summary = downloadLocationState.uri.displayPath,
summary = downloadLocationState.toUri().displayPath,
) {
selectDownloadDirLauncher.launch(null)
}
@@ -167,7 +171,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_reload_metadata_summary),
) {
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
// stable 30 days after posted
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) }
}
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 {
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)
@@ -195,19 +199,19 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_restore_download_items_summary),
) {
var restoreDirCount = 0
suspend fun getRestoreItem(file: UniFile): RestoreItem? {
suspend fun getRestoreItem(file: Path): RestoreItem? {
if (!file.isDirectory) return null
return runSuspendCatching {
val (gid, token) = file.findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
val (gid, token) = file.find(SPIDER_INFO_FILENAME)?.let {
readCompatFromPath(it)?.run {
GalleryDetailUrlParser.Result(gid, token)
}
} ?: file.findFile(COMIC_INFO_FILE)?.let {
} ?: file.find(COMIC_INFO_FILE)?.let {
readComicInfo(it)?.run {
GalleryDetailUrlParser.parse(web)
}
} ?: return null
val dirname = file.name ?: return null
val dirname = file.name
if (DownloadManager.containDownloadInfo(gid)) {
// Restore download dir to avoid redownload
val dbdirname = EhDB.getDownloadDirname(gid)
@@ -222,8 +226,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
logcat(it)
}.getOrNull()
}
runCatching {
val result = downloadLocation.listFiles().parMapNotNull { getRestoreItem(it) }.also {
runSuspendCatching {
val result = downloadLocation.list().parMapNotNull { getRestoreItem(it) }.also {
fillGalleryListByApi(it, EhUrl.referer)
}
if (result.isEmpty()) {
@@ -247,25 +251,27 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_download_clean_redundancy),
summary = stringResource(id = R.string.settings_download_clean_redundancy_summary),
) {
fun clearFile(file: UniFile): Boolean {
var name = file.name ?: return false
val index = name.indexOf('-')
if (index >= 0) {
name = name.substring(0, index)
}
val gid = name.toLongOrNull() ?: return false
if (DownloadManager.containDownloadInfo(gid)) {
return false
}
file.delete()
return true
fun isRedundant(file: Path): Boolean {
if (!file.isDirectory) return false
val name = file.name
val gid = name.substringBefore('-').toLongOrNull() ?: return false
return name != DownloadManager.getDownloadInfo(gid)?.dirname
}
runSuspendCatching {
val cnt = downloadLocation.listFiles().sumOf { clearFile(it).compareTo(false) }
launchSnackBar(FINAL_CLEAR_REDUNDANCY_MSG(cnt))
}.onFailure {
logcat(it)
val list = downloadLocation.list().filter(::isRedundant)
if (list.isNotEmpty()) {
awaitPermissionOrCancel(
confirmText = R.string.delete,
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.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -127,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
readOnly = true,
value = type,
onValueChange = {},
@@ -1,17 +1,21 @@
package com.hippo.ehviewer.ui.theme
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.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.scrollbarStyle
import com.hippo.ehviewer.util.isAtLeastS
@Composable
@@ -44,8 +48,12 @@ fun EhTheme(content: @Composable () -> Unit) {
}
}
MaterialTheme(
colorScheme = colors,
content = content,
)
MaterialTheme(colorScheme = colors) {
val scrollbarStyle = scrollbarStyle(color = MaterialTheme.colorScheme.primary)
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 spans = spanStyles.groupBy { it.start }
while (true) {
while (stack.lastOrNull()?.end == current)
while (stack.lastOrNull()?.end == current) {
stack.removeLast().item.pop()
}
if (current == len) break
spans[current]?.sortedByDescending { it.end }?.forEach {
it.item.push()
@@ -70,6 +70,8 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.right
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
@@ -109,45 +111,43 @@ class DialogState {
suspend fun <R> awaitResult(
initial: R,
@StringRes title: Int? = null,
invalidator: (suspend (R) -> String?)? = null,
invalidator: (suspend Raise<String>.(R) -> Unit)? = null,
block: @Composable DialogScope<R>.(String?) -> Unit,
): R {
return dialog { cont ->
val coroutineScope = rememberCoroutineScope()
val state = remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
val impl = remember(cont) {
object : DialogScope<R> {
override var expectedValue by state
}
): R = dialog { cont ->
val coroutineScope = rememberCoroutineScope()
val state = remember(cont) { mutableStateOf(initial) }
var errorMsg by remember(cont) { mutableStateOf<String?>(null) }
val impl = remember(cont) {
object : DialogScope<R> {
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(
@@ -156,35 +156,90 @@ class DialogState {
hint: String? = null,
isNumber: Boolean = false,
@StringRes confirmText: Int = android.R.string.ok,
invalidator: (suspend (String) -> String?)? = null,
): String {
return dialog { cont ->
val coroutineScope = rememberCoroutineScope()
var state by remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state)
} else {
coroutineScope.launch {
error = invalidator.invoke(state)
error ?: cont.resume(state)
}
invalidator: (suspend Raise<String>.(String) -> Unit)? = null,
) = dialog { cont ->
val coroutineScope = rememberCoroutineScope()
var state by remember(cont) { mutableStateOf(initial) }
var error by remember(cont) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = {
if (invalidator == null) {
cont.resume(state)
} else {
coroutineScope.launch {
error = either { invalidator(state) }.leftOrNull()
error ?: cont.resume(state)
}
}) {
Text(text = stringResource(id = confirmText))
}
},
title = title.ifNotNullThen { Text(text = title!!) },
text = {
}) {
Text(text = stringResource(id = confirmText))
}
},
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(
value = state,
onValueChange = { state = it },
label = hint.ifNotNullThen {
Text(text = hint!!)
Text(text = stringResource(id = hint!!))
},
trailingIcon = error.ifNotNullThen {
Icon(
@@ -198,74 +253,15 @@ class DialogState {
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 (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),
)
}
},
)
}
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checkedState,
onCheckedChange = { checkedState = !checkedState },
label = stringResource(checkBoxText),
)
}
},
)
}
suspend fun awaitPermissionOrCancel(
@@ -277,32 +273,30 @@ class DialogState {
onCancelButtonClick: () -> Unit = {},
secure: Boolean = false,
text: @Composable (() -> Unit)? = null,
) {
return dialog { cont ->
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = { cont.resume(Unit) }, enabled = confirmButtonEnabled) {
Text(text = stringResource(id = confirmText))
}
},
dismissButton = showCancelButton.ifTrueThen {
TextButton(onClick = {
onCancelButtonClick()
cont.cancel()
}) {
Text(text = stringResource(id = dismissText))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = text,
properties = if (secure) {
DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
} else {
DialogProperties()
},
)
}
) = dialog { cont ->
AlertDialog(
onDismissRequest = { cont.cancel() },
confirmButton = {
TextButton(onClick = { cont.resume(Unit) }, enabled = confirmButtonEnabled) {
Text(text = stringResource(id = confirmText))
}
},
dismissButton = showCancelButton.ifTrueThen {
TextButton(onClick = {
onCancelButtonClick()
cont.cancel()
}) {
Text(text = stringResource(id = dismissText))
}
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = text,
properties = if (secure) {
DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
} else {
DialogProperties()
},
)
}
suspend fun awaitSelectDate(
@@ -313,63 +307,59 @@ class DialogState {
initialDisplayMode: DisplayMode = DisplayMode.Picker,
selectableDates: SelectableDates = DatePickerDefaults.AllDates,
showModeToggle: Boolean = true,
): Long? {
return dialog { cont ->
val state = rememberDatePickerState(
initialSelectedDateMillis,
initialDisplayedMonthMillis,
yearRange,
initialDisplayMode,
selectableDates,
): Long? = dialog { cont ->
val state = rememberDatePickerState(
initialSelectedDateMillis,
initialDisplayedMonthMillis,
yearRange,
initialDisplayMode,
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 {
return dialog { cont ->
val impl = remember(cont) {
DismissDialogScope<R> {
cont.resume(it)
}
suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R = dialog { cont ->
val impl = remember(cont) {
DismissDialogScope<R> {
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(
@@ -542,6 +532,7 @@ class DialogState {
items: List<Pair<ImageVector, String>>,
@StringRes title: Int,
@StringRes hint: Int,
initialNote: String,
maxChar: Int,
): Pair<Int, String> = showNoButton(false) {
Column {
@@ -550,7 +541,7 @@ class DialogState {
modifier = Modifier.fillMaxWidth().aspectRatio(1F),
placeFirstItemInCenter = true,
) {
var note by remember { mutableStateOf("") }
var note by remember { mutableStateOf(initialNote) }
TextField(
value = note,
onValueChange = { note = it },
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more