Compare commits

..
1 Commits
Author SHA1 Message Date
FooIbar 19d14e1d0c Release 1.9.0
Releases / Build (push) Has been cancelled
2023-12-01 22:46:11 +08:00
702 changed files with 34460 additions and 33992 deletions

No files matched your search

-3
View File
@@ -1,7 +1,4 @@
[*.{kt,kts}]
ij_kotlin_imports_layout = *
ij_kotlin_line_break_after_multiline_when_entry = false
ktlint_code_style = intellij_idea
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_class-signature = disabled
ktlint_standard_mixed-condition-operators = disabled
+3
View File
@@ -141,6 +141,9 @@ body:
请确认您已经遵守所有必选项
Please ensure you have obtained all needed options
options:
- label: 如果您有足够的时间和能力,并愿意为此提交 PR ,请勾上此复选框 / Pull request is welcome. Check this if you want to start a pull request
required: false
- label: 您已搜索过 [Issue Tracker](https://github.com/FooIbar/EhViewer/issues),没有找到类似内容 / I have searched on [Issue Tracker](https://github.com/FooIbar/EhViewer/issues), No duplicate or related open issue has been found
required: true
-65
View File
@@ -1,65 +0,0 @@
name: Baseline profile generation
on:
schedule:
- cron: '30 0 * * *'
workflow_dispatch:
push:
branches:
- '**'
paths:
- 'benchmark/**'
- '.github/workflows/baseline-profile.yml'
jobs:
baseline-profile:
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: rm -rf $AGENT_TOOLSDIRECTORY
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Checkout
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "./app/src/main/rust"
- name: CMake Cache
uses: actions/cache/restore@v5
with:
path: app/.cxx
key: cmake-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Upload reports
if: failure()
uses: actions/upload-artifact@v6
with:
name: reports
path: benchmark/build/reports
+146 -128
View File
@@ -4,30 +4,33 @@ on:
push:
branches:
- '*'
- 'renovate/**'
pull_request:
workflow_dispatch:
jobs:
check:
linux:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
submodules: 'recursive'
- name: Setup Java
uses: actions/setup-java@v5
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-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 }}
- name: Rustfmt Check
working-directory: ./app/src/main/rust
@@ -35,162 +38,177 @@ jobs:
- name: Clippy Check
working-directory: ./app/src/main/rust
run: cargo clippy --target aarch64-linux-android --all-features -- -D warnings
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
uses: gradle/gradle-build-action@v2
with:
cache-read-only: true
- name: Check build-logic
run: ./gradlew :build-logic:convention:check
gradle-home-cache-cleanup: true
- name: Spotless Check
run: ./gradlew spotlessCheck
- name: Lint Check
run: ./gradlew lintMarshmallowRelease
default:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi
- 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@v5
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
- name: CMake Cache
uses: actions/cache@v5
uses: actions/cache@v3
with:
path: app/.cxx
key: cmake-default-${{ hashFiles('rust-toolchain.toml', 'settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
restore-keys: cmake-default-
path: |
app/.cxx
app/src/main/cpp/nettle/nettle
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: ${{ runner.os }}-cmake-
# https://github.com/mikepenz/AboutLibraries/issues/1225
- name: Generate aboutlibraries.json
run: ./gradlew :app:prepareLibraryDefinitionsDefaultRelease --rerun
- name: Build OSS
run: ./gradlew assembleDefaultOssRelease
- name: Build
run: ./gradlew assembleDefaultRelease
- name: Upload ARM64
uses: actions/upload-artifact@v6
- name: Upload OSS ARM64
uses: actions/upload-artifact@v3
with:
name: default-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk
name: default-oss-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk
- name: Upload x86_64
uses: actions/upload-artifact@v6
- name: Upload OSS x86_64
uses: actions/upload-artifact@v3
with:
name: default-x86_64-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-x86_64-release.apk
name: default-oss-x86_64-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk
- name: Upload ARM32
uses: actions/upload-artifact@v6
- name: Upload OSS ARM32
uses: actions/upload-artifact@v3
with:
name: default-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
name: default-oss-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk
- name: Upload mapping
if: always()
uses: actions/upload-artifact@v6
- name: Upload OSS x86
uses: actions/upload-artifact@v3
with:
name: default-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultRelease
name: default-oss-x86-${{ github.sha }}
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk
- name: Upload native debug symbols
uses: actions/upload-artifact@v6
- name: Upload OSS mapping
uses: actions/upload-artifact@v3
with:
name: default-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip
name: default-oss-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultOssRelease/mapping.txt
marshmallow:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v5
- name: Upload OSS native debug symbols
uses: actions/upload-artifact@v3
with:
distribution: temurin
java-version: 21
name: default-oss-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi
- name: Build GMS
run: ./gradlew assembleDefaultGmsRelease
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Upload GMS ARM64
uses: actions/upload-artifact@v3
with:
workspaces: "./app/src/main/rust"
shared-key: ${{ runner.os }}
save-if: "false"
name: default-gms-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Upload GMS x86_64
uses: actions/upload-artifact@v3
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
name: default-gms-x86_64-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk
- name: CMake Cache
uses: actions/cache@v5
- name: Upload GMS ARM32
uses: actions/upload-artifact@v3
with:
path: app/.cxx
key: cmake-marshmallow-${{ hashFiles('rust-toolchain.toml', 'settings.gradle.kts', 'app/src/main/cpp/CMakeLists.txt', 'app/src/main/rust/Cargo.lock') }}
restore-keys: cmake-marshmallow-
name: default-gms-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk
# https://github.com/mikepenz/AboutLibraries/issues/1225
- name: Generate aboutlibraries.json
run: ./gradlew :app:prepareLibraryDefinitionsMarshmallowRelease --rerun
- name: Build
run: ./gradlew assembleMarshmallowRelease
- name: Upload ARM64
uses: actions/upload-artifact@v6
- name: Upload GMS x86
uses: actions/upload-artifact@v3
with:
name: marshmallow-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk
name: default-gms-x86-${{ github.sha }}
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk
- name: Upload x86_64
uses: actions/upload-artifact@v6
- name: Upload GMS mapping
uses: actions/upload-artifact@v3
with:
name: marshmallow-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk
name: default-gms-mapping-${{ github.sha }}
path: app/build/outputs/mapping/defaultGmsRelease/mapping.txt
- name: Upload ARM32
uses: actions/upload-artifact@v6
- name: Upload GMS native debug symbols
uses: actions/upload-artifact@v3
with:
name: marshmallow-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
name: default-gms-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip
- name: Upload mapping
if: always()
uses: actions/upload-artifact@v6
with:
name: marshmallow-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowRelease
- name: Build Marshmallow OSS
run: ./gradlew assembleMarshmallowOssRelease
- name: Upload native debug symbols
uses: actions/upload-artifact@v6
- name: Upload Marshmallow OSS ARM64
uses: actions/upload-artifact@v3
with:
name: marshmallow-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip
name: marshmallow-oss-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk
- name: Upload Marshmallow OSS x86_64
uses: actions/upload-artifact@v3
with:
name: marshmallow-oss-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk
- name: Upload Marshmallow OSS ARM32
uses: actions/upload-artifact@v3
with:
name: marshmallow-oss-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk
- name: Upload Marshmallow OSS x86
uses: actions/upload-artifact@v3
with:
name: marshmallow-oss-x86-${{ github.sha }}
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk
- name: Upload Marshmallow OSS mapping
uses: actions/upload-artifact@v3
with:
name: marshmallow-oss-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowOssRelease/mapping.txt
- name: Upload Marshmallow OSS native debug symbols
uses: actions/upload-artifact@v3
with:
name: marshmallow-oss-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip
- name: Build Marshmallow GMS
run: ./gradlew assembleMarshmallowGmsRelease
- name: Upload Marshmallow GMS ARM64
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-arm64-v8a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk
- name: Upload Marshmallow GMS x86_64
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-x86_64-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk
- name: Upload Marshmallow GMS ARM32
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk
- name: Upload Marshmallow GMS x86
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-x86-${{ github.sha }}
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk
- name: Upload Marshmallow GMS mapping
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-mapping-${{ github.sha }}
path: app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt
- name: Upload Marshmallow GMS native debug symbols
uses: actions/upload-artifact@v3
with:
name: marshmallow-gms-native-debug-symbols-${{ github.sha }}
path: app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip
+3 -2
View File
@@ -2,7 +2,7 @@ name: 'Lock Threads'
on:
schedule:
- cron: '0 8 * * *'
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
@@ -15,7 +15,8 @@ jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '1'
include-any-issue-labels: 'duplicate, wontfix'
process-only: 'issues'
+79 -60
View File
@@ -6,29 +6,23 @@ on:
- "*"
jobs:
build:
linux:
name: Build
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: rm -rf $AGENT_TOOLSDIRECTORY
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
submodules: 'recursive'
- name: Setup Java
uses: actions/setup-java@v5
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Install Rust Toolchain
run: rustup target add armv7-linux-androideabi
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -36,61 +30,86 @@ jobs:
workspaces: "./app/src/main/rust"
- name: CMake Cache
uses: actions/cache/restore@v5
uses: actions/cache@v3
with:
path: app/.cxx
key: cmake-
path: |
app/.cxx
app/src/main/cpp/nettle/nettle
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
restore-keys: ${{ runner.os }}-cmake-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Gradle Build
uses: gradle/gradle-build-action@v2
with:
cache-encryption-key: ${{ secrets.GradleEncryptionKey }}
cache-read-only: true
- name: Generate Baseline Profile
run: ./gradlew generateBaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
# https://github.com/mikepenz/AboutLibraries/issues/1225
- name: Generate aboutlibraries.json
run: ./gradlew
:app:prepareLibraryDefinitionsDefaultRelease --rerun
:app:prepareLibraryDefinitionsMarshmallowRelease --rerun
- name: Build
run: ./gradlew assembleRelease
arguments: assembleRelease
- name: Rename Apks
run: |
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
mv app/build/outputs/apk/defaultOss/release/app-default-oss-universal-release.apk EhViewer-${{ github.ref_name }}-default-oss-universal.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86.apk
mv app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
mv app/build/outputs/apk/defaultGms/release/app-default-gms-universal-release.apk EhViewer-${{ github.ref_name }}-default-gms-universal.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86.apk
mv app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
- name: Releases
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
body: Bump Version
files: |
EhViewer-${{ github.ref_name }}-default-universal.apk
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-x86_64.apk
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-mapping.txt
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-default-oss-universal.apk
EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
EhViewer-${{ github.ref_name }}-default-oss-x86.apk
EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-default-gms-universal.apk
EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
EhViewer-${{ github.ref_name }}-default-gms-x86.apk
EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
+3 -4
View File
@@ -1,10 +1,9 @@
.gradle
/local.properties
.DS_Store
build
.cxx
.kotlin
/build
/src
*.iml
.idea
.vscode
/captures
google-services.json
+3 -551
View File
@@ -1,554 +1,6 @@
# 变更日志
## [1.14.6] - 2025-12-17
### 新功能
* 支持显示画廊阅读进度(于 EH 设置中开启) [#2277](https://github.com/FooIbar/EhViewer/issues/2277)
### 改进
* 画廊列表变化时重新显示悬浮操作按钮 [#2681](https://github.com/FooIbar/EhViewer/issues/2681)
* 移除文件名中的尾随空格和点以兼容 Windows [#2727](https://github.com/FooIbar/EhViewer/issues/2727)
* 长按画廊详情中的标签时显示标签名称 [#2769](https://github.com/FooIbar/EhViewer/issues/2769)
* 杂项优化
### Bug 修复
* 对话框中的下拉菜单显示错位
* HTTP 状态错误显示为“什么都没有找到”
* 某些老设备无法通过 Cloudflare 验证 [#2794](https://github.com/FooIbar/EhViewer/issues/2794)
* 杂项修复
## [1.14.5] - 2025-10-19
### 改进
* [阅读器] 显示画廊标题
* [阅读器] 显示菜单时改变标题栏颜色
### Bug 修复
* 横屏模式下打开阅读器设置会闪退 [#2673](https://github.com/FooIbar/EhViewer/issues/2673)
* 强制横屏时竖直拿取设备加载图片会横竖屏乱跳 [#2674](https://github.com/FooIbar/EhViewer/issues/2674)
## [1.14.4] - 2025-10-12
### 改进
* [阅读器] 支持禁用使用音量键翻页 [#2593](https://github.com/FooIbar/EhViewer/issues/2593)
* 视搜索警告为错误 [#2604](https://github.com/FooIbar/EhViewer/issues/2604)
* 余额不足时停止下载并显示通知 [#2479](https://github.com/FooIbar/EhViewer/issues/2479)
* [阅读器] 使用默认阅读模式时对带有 webtoon 标签的画廊自动使用条漫模式 [#2632](https://github.com/FooIbar/EhViewer/issues/2632)
* 优化按页数排序下载项目的速度
* 开启显示标签投票状态时为 power >= 100 的标签添加下划线
* 杂项优化
### Bug 修复
* Android 16 QPR1 上画廊详情中的标签文本对比度不足
* 初始化 DownloadManager 用时过长导致 ANR [#2588](https://github.com/FooIbar/EhViewer/issues/2588)
* 某些设备上无法下载名称较长的画廊 [#2607](https://github.com/FooIbar/EhViewer/issues/2607)
* 评论区链接实际可点击区域比画面显示上的略长 [#2615](https://github.com/FooIbar/EhViewer/issues/2615)
* 主页快速搜索再次打开出现以前的画廊重复出现在最上层 [#2628](https://github.com/FooIbar/EhViewer/issues/2628)
* 非条漫模式下原始大小较小的动图不播放 [#2652](https://github.com/FooIbar/EhViewer/issues/2652)
* Android 9 以下打开评论区切屏后回来会闪退 [#2655](https://github.com/FooIbar/EhViewer/issues/2655)
* 杂项修复
## [1.14.3] - 2025-09-01
### 新功能
* 支持以 label: 语法搜索下载标签
### Bug 修复
* [阅读器] 关闭滤镜设置时崩溃 [#2573](https://github.com/FooIbar/EhViewer/issues/2573)
* 编辑评论时上下文菜单缺少格式化选项
* 某些情况下搜索相似画廊时出错
## [1.14.2] - 2025-08-16
### 改进
* [下载] 支持将最低下载速度设为 0 [#2545](https://github.com/FooIbar/EhViewer/issues/2545)
* [下载] 移除令人困惑的连接超时设置 [#2556](https://github.com/FooIbar/EhViewer/issues/2556)
### Bug 修复
* 下载速度计算不准确导致下载失败误报 [#2545](https://github.com/FooIbar/EhViewer/issues/2545)
* 无法解析带有 location: 标签的画廊
## [1.14.1] - 2025-07-31
### 改进
* 更新 GitHub 访问令牌
## [1.14.0] - 2025-07-23
### 重大变更
* 默认隐藏分数为 -100 的评论
* 迁移部分组件到 Material 3 Expressive
### 新功能
* [EH] 支持显示标签投票状态
* 支持添加画廊标签时自动补全
* [下载] 支持指定连接超时和最低响应速度 [#1915](https://github.com/FooIbar/EhViewer/issues/1915)
* [阅读器] 支持反转物理按键控制 [#1962](https://github.com/FooIbar/EhViewer/issues/1962)
* [高级] 支持禁用 QUIC 支持
* [高级] 重新添加桌面版网站选项以绕过 Cloudflare [#2230](https://github.com/FooIbar/EhViewer/issues/2230)
* 支持 Android 6.0 上的动画 WebP
### 改进
* 优化压缩包加载性能
* 优化预加载策略
* 更新日本语翻译
* 为已选中的项目使用不同的颜色 [#2121](https://github.com/FooIbar/EhViewer/issues/2121)
* 缩略图模式下显示已收藏图标 [#2128](https://github.com/FooIbar/EhViewer/issues/2128)
* 迁移大部分解析器到 Rust 实现
* 优化动画 WebP 解码性能 [#2415](https://github.com/FooIbar/EhViewer/issues/2415)
* 优化标签自动补全命中率 [#2473](https://github.com/FooIbar/EhViewer/issues/2473)
* 在状态栏后绘制半透明背景以提高对比度
* 杂项优化
### Bug 修复
* 退出阅读器时崩溃
* Android 8.0 以下的设备无法读取某些压缩包
* 列表模式下点击画廊封面无法进入详情页
* 点击某些评论时崩溃
* 开启裁剪边缘时某些图片被不正确裁剪
* 下载数较多时无网络时启动 app 时崩溃 [#2041](https://github.com/FooIbar/EhViewer/issues/2041)
* 标签翻译数据无法更新 [#2075](https://github.com/FooIbar/EhViewer/issues/2075)
* 画廊详情页面显示的收藏夹名称错误 [#2081](https://github.com/FooIbar/EhViewer/issues/2081)
* 不使用 CI 频道时无法更新
* 添加后移动快捷搜索闪退 [#2124](https://github.com/FooIbar/EhViewer/issues/2124)
* 某些数据无法导入
* App 位于后台时清除下载通知时崩溃
* 杂项修复
## [1.13.1] - 2024-11-11
### 重大变更
* 由于网站限制,移除搜索封面功能
* 移除桌面版网站选项,默认使用 1280x 分辨率
### 改进
* 更新日本语翻译
* 优化压缩包读取
* 缓解某些小米系统上应用闪退 [#1826](https://github.com/FooIbar/EhViewer/issues/1826)
* 杂项优化
### Bug 修复
* Android 9 上的下载失败误报 [#1914](https://github.com/FooIbar/EhViewer/issues/1914)
## [1.13.0] - 2024-11-01
### 重大变更
* 不再捆绑 Cronet 库,在不支持 HttpEngine 的设备上回退到 OkHttp
* 适配 E 站缩略图和 WebP 等相关变更
### 改进
* 使用磁力链接代替种子下载
* 在搜索栏右侧显示论坛头像
* 支持重置下载路径
* 为 Android 10 以下且没有 DocumentsUI 的设备创建默认下载目录 [#1735](https://github.com/FooIbar/EhViewer/issues/1735)
* 重新添加了强制使用 e-hentai 缩略图服务器的选项
* 阅读器支持 PageUp/PageDown 和方向键上/下翻页 [#1801](https://github.com/FooIbar/EhViewer/issues/1801)
* 未找到搜索结果时显示可能存在的警告 [#1787](https://github.com/FooIbar/EhViewer/issues/1787)
* 更新了日本语翻译
* 杂项优化
### Bug 修复
* 阅读器屏幕方向与手机屏幕方向不同时闪退 [#1779](https://github.com/FooIbar/EhViewer/issues/1779)
* 下载文件哈希不匹配导致下载失败 [#1811](https://github.com/FooIbar/EhViewer/issues/1811)
* 无法同时下载和查看 [#1822](https://github.com/FooIbar/EhViewer/issues/1822)
* 杂项修复
## [1.12.1] - 2024-09-28
### 新功能
* 屏蔽含有二维码的图片(位于高级设置)
### 改进
* 移除高级设置中的自定义 User Agent,改为桌面版网站选项
* 画廊预览与画廊详情页面合并
* 为快速搜索和下载标签列表添加了滚动条
* 无网络条件下启动时自动转到下载页面
* 杂项优化
### Bug 修复
* 从左到右或右到左模式观看使用鼠标滚轮无法翻页 [#1541](https://github.com/FooIbar/EhViewer/issues/1541)
* 状态栏颜色未跟随阅读器主题变化 [#1542](https://github.com/FooIbar/EhViewer/issues/1542)
* 打开 EH 设置时闪退 [#1563](https://github.com/FooIbar/EhViewer/issues/1563)
* 画廊种子过多时显示没有种子 [#1561](https://github.com/FooIbar/EhViewer/issues/1561)
* 加载大于 100 MB 的位图时崩溃
* 阅读器加载小圆圈在黑色背景下不自动变色 [#1579](https://github.com/FooIbar/EhViewer/issues/1579)
* 因闪退造成的无法挽回的下载失败 [#1602](https://github.com/FooIbar/EhViewer/issues/1602)
* 某些情况下闪退 [#1548](https://github.com/FooIbar/EhViewer/issues/1548) [#1555](https://github.com/FooIbar/EhViewer/issues/1555) [#1568](https://github.com/FooIbar/EhViewer/issues/1568)
* Android 7.0 及以下的设备无法加载图片 [#1633](https://github.com/FooIbar/EhViewer/issues/1633)
* 某些情况下导入数据失败 [#1634](https://github.com/FooIbar/EhViewer/issues/1634)
* 下载时创建重复文件夹 [#1619](https://github.com/FooIbar/EhViewer/issues/1619)
* 滚动方向改变后预载图片不生效 [#1560](https://github.com/FooIbar/EhViewer/issues/1560)
* 选择无效的下载位置后闪退
* 杂项修复
## [1.12.0] - 2024-08-19
### 重大变更
* Compose 阅读器已稳定,移除了旧阅读器实现 [#847](https://github.com/FooIbar/EhViewer/issues/847)
* 由于 E 站缩略图服务器变更,移除了强制使用 e-hentai 缩略图服务器的选项
### 改进
* 更新高级搜索选项中页数过滤器的范围限制以遵循 E 站变更
### Bug 修复
* 评论页面刷新指示器边距不正确 [#1428](https://github.com/FooIbar/EhViewer/issues/1428)
* 关闭打开失败的压缩包时崩溃
* 无法打开带密码的压缩包 [#1454](https://github.com/FooIbar/EhViewer/issues/1454)
* 使用音量键翻页时无法关闭过渡动画 [#1467](https://github.com/FooIbar/EhViewer/issues/1467)
* 带有图片的评论显示错位 [#1468](https://github.com/FooIbar/EhViewer/issues/1468)
* 在包含相同项目的页面间切换时崩溃 [#1490](https://github.com/FooIbar/EhViewer/issues/1490)
* 无法检查更新 [#1512](https://github.com/FooIbar/EhViewer/issues/1512)
* 画廊详情未加载完成时退出阅读器导致崩溃 [#1507](https://github.com/FooIbar/EhViewer/issues/1507)
* 无法为带有临时标签的画廊生成 ComicInfo.xml [#1509](https://github.com/FooIbar/EhViewer/issues/1509)
* 已下载的画廊翻页时出现加载动画 [#1510](https://github.com/FooIbar/EhViewer/issues/1510)
* 画廊详情页面的缩略图无法加载 [#1529](https://github.com/FooIbar/EhViewer/issues/1529)
* 图片未加载完成时无法通过点击翻页
* 搜索结果为空时解析失败 [#1418](https://github.com/FooIbar/EhViewer/issues/1418)
* 缩略图分辨率设置不起作用
## [1.11.7] - 2024-07-10
### Bug 修复
* 无法下载标题包含 # 或 % 的画廊
* 下载页数较多的画廊时应用无响应
* 退出搜索栏时文本闪烁
## [1.11.6] - 2024-07-09 [YANKED]
### 改进
* 某些设备上无法加载长图时可尝试减小 设置-高级 中的硬件位图阈值 [#1321](https://github.com/FooIbar/EhViewer/issues/1321)
* 清理下载冗余时显示确认对话框 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 修改收藏时显示收藏备注 [#1392](https://github.com/FooIbar/EhViewer/issues/1392)
### Bug 修复
* 图片完整性校验误报 [#1286](https://github.com/FooIbar/EhViewer/issues/1286)
* 某些情况下应用崩溃 [#1290](https://github.com/FooIbar/EhViewer/issues/1290) [#1302](https://github.com/FooIbar/EhViewer/issues/1302)
* 某些情况下渲染问题 [#1284](https://github.com/FooIbar/EhViewer/issues/1284)
* 无法下载标题过长的画廊 [#1340](https://github.com/FooIbar/EhViewer/issues/1340)
* 某些界面拖动滚动条时黑屏 [#1329](https://github.com/FooIbar/EhViewer/issues/1329)
* 搜索栏多行文本遮挡其他内容 [#1365](https://github.com/FooIbar/EhViewer/issues/1365)
* CIFS Documents Provider 兼容问题 [#1356](https://github.com/FooIbar/EhViewer/issues/1356)
* 清理下载冗余未按预期运行 [#1369](https://github.com/FooIbar/EhViewer/issues/1369)
* 读取压缩包失败时崩溃
* 某些设备上崩溃 [#1276](https://github.com/FooIbar/EhViewer/issues/1276)
* 检查更新失效
## [1.11.6-RC2] - 2024-06-06
### 改进
* 下载图片时执行完整性校验以防止文件损坏/被篡改
## [1.11.6-RC1] - 2024-06-02
### 改进
* 下载列表默认显示全部下载项目
* 替换搜索文本中的换行符为空格 [#1258](https://github.com/FooIbar/EhViewer/issues/1258)
### Bug 修复
* 评价画廊时未使用输入的评分
* 搜索时意外选择 Non-H 类别 [#1246](https://github.com/FooIbar/EhViewer/issues/1246)
* 侧边栏与导航抽屉重叠 [#1262](https://github.com/FooIbar/EhViewer/issues/1262)
* 快速搜索/下载标签排序失效 [#1267](https://github.com/FooIbar/EhViewer/issues/1267)
* 加载压缩包/种子列表出错时 UI 冻结
* 低版本 Android 上某些 UI 被遮挡
## [1.11.5] - 2024-05-19
### 重大变更
* 不再支持 32 位 x86
### 新功能
* 支持按作者对下载分组
* 由于 E 站不再强制要求人机验证,重新支持账号密码登录
### 改进
* 在登录时加载收藏名称 [#1077](https://github.com/FooIbar/EhViewer/issues/1077)
* 优化归档和种子界面 UI
* 禁止在 cookie 界面截图以防止泄露
* 在缩略图模式下显示画廊页数
* 记住最低评分和页数搜索参数
* 更新 User-Agent 以缓解 IP 封禁 [#1182](https://github.com/FooIbar/EhViewer/issues/1182)
* 账号密码登录支持自动填充
* 更新中文(台灣)翻译
### Bug 修复
* 非触摸模式下无法退出搜索界面 [#1060](https://github.com/FooIbar/EhViewer/issues/1060)
* 某些设备上无法安装更新 [#1067](https://github.com/FooIbar/EhViewer/issues/1067)
* 某些设备上崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 搜索记录中存在重复条目时崩溃 [#1130](https://github.com/FooIbar/EhViewer/issues/1130)
* 条漫模式下缩小后点按区域偏移 [#127](https://github.com/FooIbar/EhViewer/issues/127)
* 多窗口模式下点按区域偏移
* 点击下载通知时未清除状态
* 保存未完成加载的图片时崩溃 [#1154](https://github.com/FooIbar/EhViewer/issues/1154)
* 历史记录界面无法在项目上滑动打开抽屉 [#464](https://github.com/FooIbar/EhViewer/issues/464)
* 导入数据失败时显示导入成功 [#1174](https://github.com/FooIbar/EhViewer/issues/1174)
* 某些条件下收藏界面崩溃 [#1190](https://github.com/FooIbar/EhViewer/issues/1190)
* 反转点按区域功能失效 [#1217](https://github.com/FooIbar/EhViewer/issues/1217)
### 已知问题
* 某些设备上从后台返回时某些 UI 消失,可通过关闭列表项目动画部分缓解 [#1184](https://github.com/FooIbar/EhViewer/issues/1184)
## [1.11.4] - 2024-04-21
### 新功能
* 更新前自动备份数据
### 改进
* 在 设置-高级 中加入了禁用列表项目动画的选项,该功能在部分设备上导致崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 下载列表多选模式下避免误触 [#1016](https://github.com/FooIbar/EhViewer/issues/1016)
* 杂项 UI 优化
### Bug 修复
* 重命名下载标签后删除该标签时崩溃 [#1008](https://github.com/FooIbar/EhViewer/issues/1008)
* 搜索栏中某些输入法长按退格无法连续删除 [#606](https://github.com/FooIbar/EhViewer/issues/606)
* 评论/屏蔽/搜索建议/下载标签/快速搜索列表卡顿 [#1041](https://github.com/FooIbar/EhViewer/issues/1041)
## [1.11.3] - 2024-04-06
### 改进
* 优化预加载逻辑和图片加载顺序
* 优化画廊详情和评论页面 UI
### Bug 修复
* 某些模式下搜索结果不显示画廊语言 [#923](https://github.com/FooIbar/EhViewer/issues/923)
* 某些情况下在搜索结果中直接阅读画廊后崩溃 [#927](https://github.com/FooIbar/EhViewer/issues/927)
* 更新至最新版本后仍提示有新版本 [#927](https://github.com/FooIbar/EhViewer/issues/927)
* 从旧版本升级时包含不兼容数据导致崩溃/无法导出数据 [#940](https://github.com/FooIbar/EhViewer/issues/940) [#974](https://github.com/FooIbar/EhViewer/issues/974)
* Android 10 以下的设备打开 设置-隐私 时崩溃 [#953](https://github.com/FooIbar/EhViewer/issues/953)
* 下载标签无法拖动
* 刷新 和 刷新(原图) 无法在已下载的图片上使用
* 临时 IP 封禁错误显示为解析失败
* 某些情况下下载时闪退 [#980](https://github.com/FooIbar/EhViewer/issues/980)
* 某些设备上下载列表中的缩略图无法显示 [#977](https://github.com/FooIbar/EhViewer/issues/977)
## [1.11.2] - 2024-03-17
### 重大变更
* Default 变种最低支持版本更改为 Android 8.0,重新添加了最低支持 Android 6.0 的 Marshmallow 变种
### 改进
* 重新添加了删除快速搜索/下载标签时的确认对话框
### Bug 修复
* 收藏页面 搜索栏中文标签转英文异常 [#902](https://github.com/FooIbar/EhViewer/issues/902)
* 在收藏或者取消收藏之后,收藏按钮会无反应 [#908](https://github.com/FooIbar/EhViewer/issues/908)
* 横屏页面下阅读设置UI重合 [#909](https://github.com/FooIbar/EhViewer/issues/909)
* Search bar does not work properly in Japanese [#906](https://github.com/FooIbar/EhViewer/issues/906)
* 搜索结果中的 Disowned 画廊不显示上传者
* 使用 CI 频道更新时,有时会获取到错误的更新链接
## [1.11.1] - 2024-03-12
### 重大变更
* 由于 E 站强制要求人机验证,仅支持通过网页登录
* 默认支持 Android 6,不再提供 Marshmallow 变种
* 集成 Cronet 库,不再提供 GMS 变种
### 改进
* 支持在排序下载列表前按下载标签分组
### Bug 修复
* 下载画廊时崩溃 [#893](https://github.com/FooIbar/EhViewer/issues/893)
## [1.11.0] - 2024-03-09
### 重大变更
* 由于 E 站 Cookie 策略变更,不再支持 Cookie 登录
* 登录时自动选择 exhentai (如果可用)
* 不再提供默认下载路径
* 手动排序更改为以添加时间/上传时间/标题/下载标签/页数对下载列表排序
* 由于数据库结构变更,此版本导出的数据将无法被旧版本导入
### 新功能
* 下载画廊时生成 ComicInfo.xml 格式元数据
* 归档下载时生成 ComicInfo.xml 格式元数据 [#711](https://github.com/FooIbar/EhViewer/issues/711)
* 归档下载支持第三方下载管理器 [#79](https://github.com/FooIbar/EhViewer/issues/79)
* 保存下载结果为 CBZ 压缩包 [#660](https://github.com/FooIbar/EhViewer/issues/660)
* 清除 `igneous` Cookie (将于下次搜索时重新获取)
* 随机打开本地收藏/下载中的画廊 [#695](https://github.com/FooIbar/EhViewer/issues/695)
* 随机在搜索结果中跳转
* 以网格模式查看下载列表
* 本地收藏/历史/下载中的标签搜索 [#860](https://github.com/FooIbar/EhViewer/issues/860)
### 改进
* 搜索时优先显示已选择的类别卡片
* 多处性能优化
### Bug 修复
* 显示模式为 Thumbnail 时无法点击上传者卡片 [#653](https://github.com/FooIbar/EhViewer/issues/653)
* [Marshmallow] GIF 只显示第一帧
* 无法在 `定时请求新闻页面` 关闭时开启 `隐藏 HV 事件通知`
* 响应正文含有无效 UTF-8 字符导致解码失败 [#643](https://github.com/FooIbar/EhViewer/issues/643)
* 某些情况下应用崩溃 [#710](https://github.com/FooIbar/EhViewer/issues/710)
* 画廊名称包含非法路径字符时无法保存图片 [#815](https://github.com/FooIbar/EhViewer/issues/815)
* 无法解码超长图片 [#732](https://github.com/FooIbar/EhViewer/issues/732)
* E 站变更导致种子解析失败 [#880](https://github.com/FooIbar/EhViewer/issues/880)
* E 站不再支持相似扫描导致图片搜索无法使用
* 杂项修复
### 已知问题
* Android 14 上使用手势导航返回时过渡动画过早结束 [#775](https://github.com/FooIbar/EhViewer/issues/775)
## [1.10.3] - 2024-01-15
### 新功能
* [Marshmallow] 支持裁剪边缘、打开压缩包和 GIF
### 改进
* 由于部分设备上的非预期行为 [#578](https://github.com/FooIbar/EhViewer/issues/578) [#596](https://github.com/FooIbar/EhViewer/issues/596),默认禁用预见式返回导航动画,可于 设置-高级 中开启
* 杂项优化
### Bug 修复
* 刷新后页面无响应 [#579](https://github.com/FooIbar/EhViewer/issues/579)
* 浏览中保存图片后点击操作被阻挡 [#581](https://github.com/FooIbar/EhViewer/issues/581)
* 在画廊详情页面切换至后台后点击评论闪退 [#580](https://github.com/FooIbar/EhViewer/issues/580)
* 已下载的画廊中未阅读缓存过的图片解码失败 [#592](https://github.com/FooIbar/EhViewer/issues/592)
* 在收藏页多选并下载会使得下载状态出现问题 [#603](https://github.com/FooIbar/EhViewer/issues/603)
* 未能在不重启情况下切换 Http 引擎 [#610](https://github.com/FooIbar/EhViewer/issues/610)
* 在有进度的画廊阅读界面无法直接回到第一页 [#614](https://github.com/FooIbar/EhViewer/issues/614)
* 请求新闻界面失效 [#605](https://github.com/FooIbar/EhViewer/issues/605)
* 收藏夹为空解析失败 [#630](https://github.com/FooIbar/EhViewer/issues/630)
## [1.10.2] - 2023-12-31
### 新功能
* [阅读器] 支持裁剪图片黑色/白色边缘
### 改进
* 切换 HTTP 引擎无需重启
* 优化过渡动画
* 杂项性能优化
### Bug 修复
* 修复搜索建议中出现关键词相同的条目时崩溃 [#533](https://github.com/FooIbar/EhViewer/issues/533) [#555](https://github.com/FooIbar/EhViewer/issues/555)
* 修复重命名下载标签时不显示原标签 [#534](https://github.com/FooIbar/EhViewer/issues/534)
* 修复调整下载项目顺序时卡顿 [#552](https://github.com/FooIbar/EhViewer/issues/552)
* 修复新增或修改评论后黑屏 [#554](https://github.com/FooIbar/EhViewer/issues/554)
* 修复剪贴板读取bug [#566](https://github.com/FooIbar/EhViewer/issues/566)
* 修复画廊信息对话框无法通过返回按钮/手势关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
## [1.10.1] - 2023-12-19
### 重大变更
* 由于 Apache HttpClient 5 在 Android 10 以下无法使用,后备 HTTP 引擎恢复为 OkHttp [#514](https://github.com/FooIbar/EhViewer/issues/514)
### Bug 修复
* 修复获取 pToken 失败导致无限加载 [#508](https://github.com/FooIbar/EhViewer/issues/508)
* 修复阅读页快速滑动滑块导致下载延时过多累计 [#512](https://github.com/FooIbar/EhViewer/issues/512)
* 修复 Android 9 以下的设备上无法退出搜索栏 [#513](https://github.com/FooIbar/EhViewer/issues/513)
## [1.10.0] - 2023-12-14 [YANKED]
### 重大变更
* 后备 HTTP 引擎变更为 Apache HttpClient 5
* 类别和高级搜索选项移至搜索栏下方
### 新功能
* 支持在搜索时指定画廊语言
### 改进
* 在下载中阅读画廊时会更新历史记录
* 多处性能改进
### Bug 修复
* 修复无法由外部应用进入画廊页面 [#436](https://github.com/FooIbar/EhViewer/issues/436)
* 修复点击精确到页的画廊链接闪退 [#430](https://github.com/FooIbar/EhViewer/issues/430)
* 修复无法搜索本地收藏 [#491](https://github.com/FooIbar/EhViewer/issues/491)
* 修复 CI 版本不显示变更日志
## [1.9.0] - 2023-12-01
## 1.9.0
### 重大变更
@@ -562,7 +14,7 @@
### 改进
* [阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* 优化原图下载重试策略
* 优化小屏设备上的搜索类别布局
* 优化过渡动画
@@ -580,7 +32,7 @@
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
* [Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* 修复已舍弃画廊上传者解析
### 已知问题
+22 -18
View File
@@ -8,14 +8,10 @@
<a href="/docs/README/zh-tw.md">
正體中文
</a>
<span> | </span>
<a href="/docs/README/ja.md">
日本語
</a>
</p>
<h1 align="center">
<img src="https://github.com/FooIbar/EhViewer-art/blob/master/launcher_icon-web.svg" width="200" alt="EhViewer">
<img src="https://github.com/Ehviewer-Overhauled/Art/blob/master/launcher_icon-web.svg" width="200" alt="EhViewer">
<br>EhViewer<br>
</h1>
@@ -63,48 +59,56 @@
# Description
A modern EhViewer fork dedicated to high-performance
EhViewer fork dedicated to lightweight and high-performance
with [Material Design 3](https://m3.material.io/)
and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview) Support
# Download
| Flavor | Minimum Android Version | Notes |
|-------------|-------------------------|--------------------------------|
| Default | 8.0 | Full support |
| Marshmallow | 6.0* | Limited support, no guarantees |
| Flavor | Feature |
|-------------|-----------------------------------|
| Default | Android 9+, full support |
| Marshmallow | Android 6.0-8.1, limited support* |
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
*Devices running Android 6 need to have the [ISRG Root X1](https://letsencrypt.org/certs/isrgrootx1.pem) certificate installed
\* Archives and GIFs are not supported
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
<a href="https://github.com/FooIbar/EhViewer/releases">
<img alt="Get it on GitHub" src="https://github.com/FooIbar/EhViewer-art/blob/master/get-it-on-github.svg" width="200px"/>
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
</a>
# Screenshot
![screenshots-01](https://github.com/FooIbar/EhViewer-art/blob/master/screenshots-01.webp)
![screenshots-02](https://github.com/FooIbar/EhViewer-art/blob/master/screenshots-02.webp)
![screenshot-01](https://github.com/Ehviewer-Overhauled/Art/blob/master/screenshot-01.png)
![screenshot-02](https://github.com/Ehviewer-Overhauled/Art/blob/master/screenshot-02.png)
# Thanks
Here is the libraries
- [Arrow](https://arrow-kt.io/)
- [AOSP & AndroidX](https://source.android.com/)
- [AOSP & AndroidX](http://source.android.com/)
- [Kotlin & KotlinX](https://kotlinlang.org/)
- [MDC-Android](https://github.com/material-components/material-components-android)
- [Material Icons](https://github.com/google/material-design-icons)
- [Ktor](https://ktor.io/)
- [OkHttp](https://square.github.io/okhttp/)
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](https://www.libarchive.org/)
- [libarchive](http://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
# License
Copyright 2014-2019 Hippo Seven
Copyright 2020-2022 NekoInverter
Copyright 2022-2023 Tarsin Norbin
Copyright 2023-2024 Foolbar
Copyright 2023 Foolbar
EhViewer is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+8
View File
@@ -0,0 +1,8 @@
/build
/src/main/java-gen
manifest-merger-release-report.txt
/src/main/libs
/src/main/obj
# Intellij
*.iml
/.cxx
+170 -113
View File
@@ -1,28 +1,35 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import java.util.regex.Pattern
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
plugins {
alias(libs.plugins.ehviewer.android.application)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.aboutlibrariesAndroid)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.spotless)
alias(libs.plugins.aboutlibrariesPlugin)
alias(libs.plugins.rustAndroidPlugin)
alias(libs.plugins.composeCompilerReportGenerator)
}
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 34
buildToolsVersion = "34.0.0"
ndkVersion = "26.1.10909125"
splits {
abi {
isEnable = true
reset()
if (isRelease) {
include(*supportedAbis)
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
isUniversalApk = true
} else {
include("arm64-v8a", "x86_64")
@@ -43,9 +50,10 @@ android {
commandLine = "git rev-parse --short=7 HEAD".split(' ')
}.standardOutput.asText.get().trim()
val commitTime = providers.exec {
commandLine = "git log -1 --format=%ct".split(' ')
}.standardOutput.asText.get().trim()
val buildTime by lazy {
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC)
formatter.format(Instant.now())
}
val repoName = providers.exec {
commandLine = "git remote get-url origin".split(' ')
@@ -54,30 +62,59 @@ android {
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
versionCode = 180063
versionName = "1.14.6"
// versionNameSuffix = "-SNAPSHOT"
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
minSdk = 28
targetSdk = 34
versionCode = 180046
versionName = "1.9.0"
resourceConfigurations.addAll(
listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
),
)
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
}
debugSymbolLevel = "FULL"
}
}
flavorDimensions += "api"
flavorDimensions += listOf("api", "oss")
productFlavors {
create("default") {
minSdk = 26
dimension = "api"
}
create("marshmallow") {
dimension = "api"
minSdk = 23
applicationIdSuffix = ".m"
versionNameSuffix = "-M"
externalNativeBuild {
cmake {
targets += "noop"
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
}
create("oss") {
dimension = "oss"
}
create("gms") {
dimension = "oss"
versionNameSuffix = "-gms"
}
}
@@ -87,52 +124,47 @@ android {
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
kotlinOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xlambdas=indy",
"-opt-in=coil.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true",
)
}
lint {
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlineApi")
}
packaging {
dex {
useLegacyPackaging = false
}
jniLibs {
excludes += "**/libdatastore_shared_counter.so" // DataStore multi-process
}
resources {
// Required by Layout Inspector
pickFirsts += "/META-INF/androidx.compose.ui_ui.version"
excludes += listOf(
"/META-INF/**",
"/kotlin/**",
"**.txt",
"**.bin",
)
excludes += "/META-INF/**"
excludes += "/kotlin/**"
excludes += "**.txt"
excludes += "**.bin"
}
}
androidResources {
ignoreAssetsPatterns += listOf(
"!PublicSuffixDatabase.list", // OkHttp
"!composepreference.preference.generated.resources",
)
generateLocaleConfig = true
localeFilters += listOf(
"zh",
"zh-rCN",
"zh-rHK",
"zh-rTW",
"es",
"ja",
"ko",
"fr",
"de",
"th",
"tr",
"nb-rNO",
)
}
dependenciesInfo.includeInApk = false
buildTypes {
@@ -141,131 +173,156 @@ android {
isShrinkResources = true
proguardFiles("proguard-rules.pro")
signingConfig = signConfig
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
}
debug {
applicationIdSuffix = ".debug"
}
create("benchmarkRelease") {
initWith(buildTypes.getByName("release"))
matchingFallbacks += listOf("release")
applicationIdSuffix = ".benchmark"
signingConfig = signingConfigs.getByName("debug")
isDebuggable = false
buildConfigField("String", "BUILD_TIME", "\"\"")
}
}
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
namespace = "com.hippo.ehviewer"
}
baselineProfile {
mergeIntoMain = true
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.i18n)
implementation(projects.core.ui)
// https://developer.android.com/jetpack/androidx/releases/activity
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.biometric)
implementation(libs.androidx.browser)
implementation(libs.androidx.webkit)
// https://developer.android.com/jetpack/androidx/releases/compose-material3
api(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.compose.destinations.core)
ksp(libs.compose.destinations.compiler)
implementation(libs.compose.preference) {
// R8 won't remove it because it adds a content provider
exclude(group = "org.jetbrains.compose.components", module = "components-resources")
}
implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.coordinatorlayout)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// https://developer.android.com/jetpack/androidx/releases/navigation
implementation(libs.bundles.androidx.navigation)
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
implementation(libs.bundles.androidx.paging)
implementation(libs.androidx.recyclerview)
// https://developer.android.com/jetpack/androidx/releases/room
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.work.runtime)
implementation(libs.material.motion.core)
implementation(libs.material.kolor)
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
// https://github.com/google/accompanist/releases
implementation(libs.bundles.accompanist)
implementation(libs.material)
implementation(libs.bundles.splitties)
// https://square.github.io/okhttp/changelogs/changelog/
implementation(platform(libs.okhttp.bom))
implementation(libs.bundles.okhttp)
implementation(libs.logcat)
implementation(libs.okio.jvm)
implementation(libs.diff)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.accompanist.drawable.painter)
implementation(libs.insetter) // Dead Dependency
implementation(libs.reorderable)
implementation(platform(libs.arrow.stack))
implementation(libs.bundles.arrow)
implementation(libs.arrow.fx.coroutines)
// https://coil-kt.github.io/coil/changelog/
implementation(platform(libs.coil.bom))
implementation(libs.bundles.coil)
implementation(libs.telephoto.zoomable)
implementation(libs.ktor.client.okhttp)
implementation(libs.bundles.ktor)
implementation(libs.bundles.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.jsoup)
debugImplementation(libs.chucker)
releaseImplementation(libs.chucker.nop)
coreLibraryDesugaring(libs.desugar)
implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":benchmark"))
"gmsImplementation"(libs.bundles.cronet)
}
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
configurations.all {
resolutionStrategy {
// Workaround IME won't show again once hidden.
// Google issue-tracker link?
force("androidx.compose.ui:ui-text-android:1.6.0-alpha08")
exclude("androidx.navigation", "navigation-compose")
}
}
kotlin {
compilerOptions {
optIn.addAll(
"coil3.annotation.ExperimentalCoilApi",
"androidx.paging.ExperimentalPagingApi",
"me.saket.telephoto.ExperimentalTelephotoApi",
)
}
jvmToolchain(17)
}
ksp {
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
}
aboutLibraries {
collect {
includePlatform = false
duplicationMode = MERGE
duplicationRule = GROUP
}
cargo {
module = "src/main/rust"
libname = "ehviewer_rust"
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64", "x86_64")
if (isRelease) profile = "release"
}
val ktlintVersion = libs.versions.ktlint.get()
spotless {
kotlin {
// https://github.com/diffplug/spotless/issues/111
target("src/**/*.kt")
ktlint(ktlintVersion)
}
library {
exclusionPatterns.add(Pattern.compile("org\\.jetbrains\\.(?:compose|androidx)\\..*"))
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.GROUP
kotlinGradle {
ktlint(ktlintVersion)
}
}
tasks.configureEach {
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
dependsOn("cargoBuild")
// fix mergeDebugJniLibFolders UP-TO-DATE
inputs.dir(layout.buildDirectory.dir("rustJniLibs/android"))
}
}
+3 -11
View File
@@ -11,18 +11,10 @@
public static final ** CREATOR;
}
-keepclassmembers,allowobfuscation class com.hippo.ehviewer.coil.AnimatedWebPDrawable {
java.nio.ByteBuffer source;
# https://issuetracker.google.com/issues/307323842
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
setWindowFocused(boolean);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
# https://issuetracker.google.com/222232895
-dontwarn androidx.window.extensions.**
-dontwarn androidx.window.sidecar.Sidecar*
-keepattributes LineNumberTable
-allowaccessmodification
-repackageclasses
@@ -0,0 +1,88 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "c801ee62729ba20a203b150211234d5c",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT, `VALUE` TEXT, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT, `PATH` TEXT, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c801ee62729ba20a203b150211234d5c')"
]
}
}
@@ -0,0 +1,88 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "1b80cd29939b0f43934721f1289ba94d",
"entities": [
{
"tableName": "OK_HTTP_3_COOKIE",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`NAME` TEXT NOT NULL, `VALUE` TEXT NOT NULL, `EXPIRES_AT` INTEGER NOT NULL, `DOMAIN` TEXT NOT NULL, `PATH` TEXT NOT NULL, `SECURE` INTEGER NOT NULL, `HTTP_ONLY` INTEGER NOT NULL, `PERSISTENT` INTEGER NOT NULL, `HOST_ONLY` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "VALUE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "expiresAt",
"columnName": "EXPIRES_AT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "DOMAIN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "PATH",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secure",
"columnName": "SECURE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpOnly",
"columnName": "HTTP_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "persistent",
"columnName": "PERSISTENT",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostOnly",
"columnName": "HOST_ONLY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1b80cd29939b0f43934721f1289ba94d')"
]
}
}
@@ -0,0 +1,106 @@
package com.hippo.ehviewer.cronet
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.EhCookieStore
import java.nio.ByteBuffer
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.RequestBody
import okio.Buffer
import okio.Path.Companion.toOkioPath
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UploadDataProvider
import org.chromium.net.UploadDataSink
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
import splitties.init.appCtx
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
enableBrotli(true)
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
setStoragePath(cache.absolutePath)
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
setUserAgent(Settings.userAgent)
}.build()
class CronetRequest {
lateinit var consumer: (ByteBuffer) -> Unit
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
lateinit var request: UrlRequest
lateinit var onError: (Throwable) -> Unit
lateinit var readerCont: Continuation<Unit>
val callback = object : UrlRequest.Callback() {
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
req.followRedirect()
}
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
onResponse(info)
}
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
consumer(data)
}
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
readerCont.resume(Unit)
}
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
onError(e)
}
}
}
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(conf).build()
}
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
contract {
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
}
return coroutineScope {
suspendCancellableCoroutine { cont ->
onResponse = { launch { cont.resume(callback(it)) } }
cont.invokeOnCancellation { request.cancel() }
onError = { cont.resumeWithException(it) }
request.start()
}
}
}
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
addHeader("Content-Type", body.contentType().toString())
val buffer = Buffer().apply { body.writeTo(this) }
val provider = object : UploadDataProvider() {
override fun getLength() = body.contentLength()
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
buffer.read(byteBuffer)
uploadDataSink.onReadSucceeded(false)
}
override fun rewind(uploadDataSink: UploadDataSink) {
error("OneShot!")
}
}
setUploadDataProvider(provider, cronetHttpClientExecutor)
}
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.util
import com.google.android.gms.net.CronetProviderInstaller
import com.hippo.ehviewer.Settings
import org.chromium.net.CronetProvider
import splitties.init.appCtx
val isCronetSupported: Boolean
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
if (!it) {
CronetProviderInstaller.installProvider(appCtx)
}
}
+46 -34
View File
@@ -29,6 +29,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="@bool/large_heap"
android:localeConfig="@xml/locale_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -36,10 +37,15 @@
tools:targetApi="tiramisu">
<profileable android:shell="true" />
<activity
android:name=".ui.ConfigureActivity"
android:exported="false"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:banner="@mipmap/ic_leanback_banner"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
android:configChanges="screenSize"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/AppTheme.Splash"
@@ -70,39 +76,6 @@
<data android:host="exhentai.org" />
<data android:host="e-hentai.org" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-7z-compressed" />
<data android:mimeType="application/x-tar" />
<data android:mimeType="application/x-xz" />
<data android:mimeType="application/gzip" />
<data android:mimeType="application/vnd.rar" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<!-- unofficial -->
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-cbr" />
<data android:mimeType="application/x-cbt" />
<data android:mimeType="application/x-cb7" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-compressed-tar" />
<data android:mimeType="application/x-bzip-compressed-tar" />
<data android:mimeType="application/x-lzma-compressed-tar" />
<data android:mimeType="application/x-xz-compressed-tar" />
</intent-filter>
<intent-filter android:label="@string/keyword_search">
<action android:name="android.intent.action.SEND" />
@@ -118,6 +91,44 @@
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<activity
android:name="eu.kanade.tachiyomi.ui.reader.ReaderActivity"
android:configChanges="screenSize"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-7z-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-tar" />
<data android:mimeType="application/x-xz" />
<data android:mimeType="application/gzip" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-cbr" />
<data android:mimeType="application/x-cbt" />
<data android:mimeType="application/x-cb7" />
<!-- untested -->
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<data android:mimeType="application/x-compressed-tar" />
<data android:mimeType="application/x-bzip-compressed-tar" />
<data android:mimeType="application/x-lzma-compressed-tar" />
<data android:mimeType="application/x-xz-compressed-tar" />
</intent-filter>
</activity>
<activity
android:name=".ui.SecurityActivity"
android:configChanges="screenSize"
@@ -128,6 +139,7 @@
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity android:name=".ui.WebViewActivity" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -1,25 +0,0 @@
From e971b9f23727833cc39b9e325db30f383e5bfc30 Mon Sep 17 00:00:00 2001
From: Dude so hot <djohn@fbi.gov>
Date: Thu, 14 Nov 2024 00:28:13 +0800
Subject: [PATCH] Fix zip_time performance
Signed-off-by: Dude so hot <djohn@fbi.gov>
---
libarchive/archive_time.c | 1 +
1 file changed, 1 insertion(+)
diff --git a/libarchive/archive_time.c b/libarchive/archive_time.c
index 3352c809..9289bf1e 100644
--- a/libarchive/archive_time.c
+++ b/libarchive/archive_time.c
@@ -53,6 +53,7 @@ FILETIME_to_ntfs(const FILETIME* filetime)
int64_t
dos_to_unix(uint32_t dos_time)
{
+ return 0;
uint16_t msTime, msDate;
struct tm ts;
time_t t;
--
2.47.0
@@ -1,27 +0,0 @@
From 86d199429b3fabc83f7dcc9afd72e7b4c7750b1a Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Thu, 14 Nov 2024 20:22:52 +0800
Subject: [PATCH] Use UTF-8 as default charset on bionic
---
libarchive/archive_string.c | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/libarchive/archive_string.c b/libarchive/archive_string.c
index abf7ad66..63a73c58 100644
--- a/libarchive/archive_string.c
+++ b/libarchive/archive_string.c
@@ -423,7 +423,9 @@ static const char *
default_iconv_charset(const char *charset) {
if (charset != NULL && charset[0] != '\0')
return charset;
-#if HAVE_LOCALE_CHARSET && !defined(__APPLE__)
+#ifdef __BIONIC__
+ return "UTF-8";
+#elif HAVE_LOCALE_CHARSET && !defined(__APPLE__)
/* locale_charset() is broken on Mac OS */
return locale_charset();
#elif HAVE_NL_LANGINFO
--
2.43.0
+42 -122
View File
@@ -1,77 +1,48 @@
cmake_minimum_required(VERSION 3.14)
project(ehviewer C)
include(ExternalProject)
project(ehviewer C ASM)
include(FetchContent)
if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
string(CONCAT CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 -ffast-math -fvisibility=hidden -fvisibility-inlines-hidden -funroll-loops -flto "
"-ffile-prefix-map=${ANDROID_TOOLCHAIN_ROOT}= "
"-ffile-prefix-map=${CMAKE_SOURCE_DIR}= "
"-ffile-prefix-map=${CMAKE_BINARY_DIR}= "
"-mllvm -polly "
"-mllvm -polly-run-dce "
"-mllvm -polly-run-inliner "
"-mllvm -polly-isl-arg=--no-schedule-serialize-sccs "
"-mllvm -polly-ast-use-context "
"-mllvm -polly-detect-keep-going "
"-mllvm -polly-position=before-vectorizer "
"-mllvm -polly-vectorizer=stripmine "
"-mllvm -polly-detect-profitability-min-per-loop-insts=40 "
"-mllvm -polly-invariant-load-hoisting")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Ofast -fvisibility=hidden -fvisibility-inlines-hidden -funroll-loops -flto \
-mllvm -polly \
-mllvm -polly-run-dce \
-mllvm -polly-run-inliner \
-mllvm -polly-isl-arg=--no-schedule-serialize-sccs \
-mllvm -polly-ast-use-context \
-mllvm -polly-detect-keep-going \
-mllvm -polly-position=before-vectorizer \
-mllvm -polly-vectorizer=stripmine \
-mllvm -polly-detect-profitability-min-per-loop-insts=40 \
-mllvm -polly-invariant-load-hoisting")
endif (NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
option(BUILD_TESTING OFF)
option(XZ_DOC OFF)
option(XZ_LZIP_DECODER OFF)
option(XZ_MICROLZMA_DECODER OFF)
option(XZ_MICROLZMA_ENCODER OFF)
option(XZ_TOOL_LZMADEC OFF)
option(XZ_TOOL_LZMAINFO OFF)
option(XZ_TOOL_XZ OFF)
option(XZ_TOOL_XZDEC OFF)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.8.1
GIT_SHALLOW 1
)
# Change the default path of fetch content to avoid downloading the same dependencies per architecture
get_filename_component(deps "../_deps" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
set(FETCHCONTENT_BASE_DIR ${deps})
set(BUILD_TESTING 0)
FetchContent_Declare(liblzma
GIT_REPOSITORY https://github.com/FooIbar/xz.git
GIT_TAG v5.4.5
)
FetchContent_MakeAvailable(liblzma)
include_directories(${liblzma_SOURCE_DIR}/src/liblzma/api)
# Build GNUTLS libnettle
if (CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
set(NDK_VERSION "${ANDROID_NDK_MAJOR}.${ANDROID_NDK_MINOR}.${ANDROID_NDK_BUILD}")
set(TOOLCHAIN "$ANDROID_HOME/ndk/${NDK_VERSION}/toolchains/llvm/prebuilt/linux-x86_64/bin")
set(WSL wsl)
else ()
set(TOOLCHAIN "${ANDROID_TOOLCHAIN_ROOT}/bin")
endif ()
set(CONFIGURE_COMMAND ./configure --disable-public-key --disable-dependency-tracking
"--host=${CMAKE_C_COMPILER_TARGET}"
"CFLAGS=${CMAKE_C_FLAGS} -Qunused-arguments"
"AR=${TOOLCHAIN}/llvm-ar"
"CC=${TOOLCHAIN}/clang --target=${CMAKE_C_COMPILER_TARGET}"
"RANLIB=${TOOLCHAIN}/llvm-ranlib"
)
ExternalProject_Add(
nettle
URL https://ftpmirror.gnu.org/nettle/nettle-3.10.2.tar.gz
URL_MD5 b28bcbf6f045ff007940a9401673600d
CONFIGURE_COMMAND ${WSL} ${CONFIGURE_COMMAND}
BUILD_COMMAND ${WSL} make -j4 libnettle.a
INSTALL_COMMAND ""
BUILD_IN_SOURCE 1
BUILD_BYPRODUCTS <SOURCE_DIR>/libnettle.a
)
FetchContent_Declare(nettle
URL https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz
URL_MD5 29fcd2dec6bf5b48e5e3ffb3cbc4779e
SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/nettle/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)
FetchContent_MakeAvailable(nettle)
add_subdirectory(nettle)
# Configure libnettle support for libarchive
include_directories(nettle)
include_directories(.)
set(HAVE_LIBNETTLE 1)
set(HAVE_NETTLE_AES_H 1)
set(HAVE_NETTLE_HMAC_H 1)
@@ -87,76 +58,25 @@ SET(HAVE_LZMA_STREAM_ENCODER_MT 1)
SET(HAVE_LZMADEC_H 1)
SET(HAVE_LIBLZMADEC 1)
option(ENABLE_OPENSSL OFF)
option(ENABLE_TAR OFF)
option(ENABLE_CPIO OFF)
option(ENABLE_CAT OFF)
option(ENABLE_UNZIP OFF)
option(ENABLE_TEST OFF)
set(LIBARCHIVE_PATCH
${CMAKE_CURRENT_LIST_DIR}/0001-Fix-zip_time-performance.patch
${CMAKE_CURRENT_LIST_DIR}/0002-Use-UTF-8-as-default-charset-on-bionic.patch
)
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.8.4
GIT_SHALLOW 1
PATCH_COMMAND git apply --check -R ${LIBARCHIVE_PATCH} || git apply ${LIBARCHIVE_PATCH}
)
# Configure libarchive link's static lib
SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
FetchContent_Declare(libarchive
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
GIT_TAG v3.7.2
)
FetchContent_MakeAvailable(libarchive)
target_link_libraries(archive_static liblzma libnettle)
include_directories(${libarchive_SOURCE_DIR}/libarchive)
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.6.0
GIT_SHALLOW 1
)
option(WEBP_BUILD_ANIM_UTILS OFF)
option(WEBP_BUILD_CWEBP OFF)
option(WEBP_BUILD_DWEBP OFF)
option(WEBP_BUILD_VWEBP OFF)
option(WEBP_BUILD_WEBPINFO OFF)
option(WEBP_BUILD_LIBWEBPMUX OFF)
option(WEBP_BUILD_EXTRAS OFF)
option(WEBP_ENABLE_SWAP_16BIT_CSP ON)
# Disable runtime detection of NEON support as NEON is required since API level 23
# https://source.android.com/docs/compatibility/6.0/android-6.0-cdd#3_3_1_application_binary_interfaces
unset(ANDROID)
FetchContent_MakeAvailable(libwebp)
if (CMAKE_SYSTEM_NAME STREQUAL "Android")
set(ANDROID 1)
endif ()
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ../../../../rust-toolchain.toml)
unset(Rust_TOOLCHAIN CACHE)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.6
GIT_SHALLOW 1
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(
MANIFEST_PATH ../rust/Cargo.toml
LOCKED
FEATURES $<IF:$<VERSION_GREATER_EQUAL:${ANDROID_NATIVE_API_LEVEL},26>,android-26,android>
FLAGS $<$<NOT:$<CONFIG:Debug>>:-Z build-std -Z build-std-features=>
)
corrosion_add_target_rustflags(ehviewer_rust $<$<NOT:$<CONFIG:Debug>>:-Z unstable-options -C panic=immediate-abort -Z location-detail=none -Z fmt-debug=none>)
corrosion_link_libraries(ehviewer_rust android jnigraphics log webp webpdemux)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c hash.c natsort/strnatcmp.c)
target_link_libraries(
${PROJECT_NAME} archive_static ehviewer_rust
-Wl,-u,JNI_OnLoad -Wl,--undefined-glob,Java_*
-Wl,--version-script,${CMAKE_SOURCE_DIR}/libehviewer.map.txt
)
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log)
add_custom_target("noop")
+219 -223
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2024 Tarsin Norbin
* Copyright 2022 Tarsin Norbin
*
* This file is part of EhViewer
*
@@ -34,6 +34,7 @@
#include "natsort/strnatcmp.h"
#include "ehviewer.h"
#include "spinlock.h"
typedef struct {
int using;
@@ -45,29 +46,33 @@ typedef struct {
typedef struct {
const char *filename;
int index;
ssize_t size;
void *addr;
size_t size;
} entry;
_Atomic mcs_lock ctx_lock;
static archive_ctx **ctx_pool;
#define CTX_POOL_SIZE 20
#define MAX_PARALLEL_DECOMP 4
#define max(a, b) ((a) > (b) ? (a) : (b))
static pthread_mutex_t ctx_pool_mutex = PTHREAD_MUTEX_INITIALIZER;
static archive_ctx **ctx_pool = NULL;
static pthread_mutex_t buffer_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *decode_buffer[MAX_PARALLEL_DECOMP];
static void *mempool = NULL;
static size_t *mempoolofs = NULL;
#define PAGE_ALIGN(x) ((x + ~PAGE_MASK) & PAGE_MASK)
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
#define MEMPOOL_ENTITY_SIZE(x) PAGE_ALIGN(entries[x].size)
#define PROT_RW (PROT_WRITE | PROT_READ)
#define MAP_ANON_POOL (MAP_ANONYMOUS | MAP_NORESERVE | MAP_PRIVATE | MAP_UNINITIALIZED)
static bool need_encrypt = false;
static char *passwd = NULL;
static void *archiveAddr = MAP_FAILED;
static void *archiveAddr = NULL;
static size_t archiveSize = 0;
static entry *entries = NULL;
static size_t entryCount = 0;
static ssize_t max_file_size = 0;
#define SUPPORT_EXT_COUNT 11
const char supportExt[SUPPORT_EXT_COUNT][5] = {
const char supportExt[10][6] = {
"jpeg",
"jpg",
"png",
@@ -76,104 +81,93 @@ const char supportExt[SUPPORT_EXT_COUNT][5] = {
"bmp",
"ico",
"wbmp",
"heic",
"heif",
"avif"
};
static inline int filename_is_playable_file(const char *name) {
if (!name)
return false;
const char *dotptr = strrchr(name, '.');
if (!dotptr++)
return false;
int i;
for (i = 0; i < SUPPORT_EXT_COUNT; i++)
for (i = 0; i < 10; i++)
if (strcmp(dotptr, supportExt[i]) == 0)
return true;
return false;
}
static inline bool archive_entry_is_file(struct archive_entry *entry) {
return archive_entry_filetype(entry) == AE_IFREG;
}
static inline bool archive_entry_is_playable(struct archive_entry *entry) {
return archive_entry_is_file(entry) &&
filename_is_playable_file(archive_entry_pathname(entry));
}
static inline int compare_entries(const void *a, const void *b) {
const char *fa = ((entry *) a)->filename;
const char *fb = ((entry *) b)->filename;
return strnatcmp(fa, fb);
}
#define ADDR_IN_FILE_MAPPING(addr) (addr >= archiveAddr && addr < archiveAddr + archiveSize)
static bool fill_entry_zero_copy(struct archive *arc, entry *entry) {
void *buffer = NULL;
size_t buffer_size = 0;
la_int64_t output_ofs = 0;
archive_read_data_block(arc, (const void **) &buffer, &buffer_size, &output_ofs);
bool zero_copy = ADDR_IN_FILE_MAPPING(buffer) && !output_ofs && buffer_size == entry->size;
entry->addr = zero_copy ? buffer : NULL;
return zero_copy;
}
static void archive_map_entries_index(archive_ctx *ctx, bool sort) {
int count = 0;
bool zero_copy = true;
static long archive_map_entries_index(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname(ctx->entry);
if (archive_entry_is_file(ctx->entry) && filename_is_playable_file(name)) {
if (filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
ssize_t size = archive_entry_size(ctx->entry);
max_file_size = max(size, max_file_size);
entries[count].size = size;
// We don't expect zero copy if first content can't do zero copy
if (zero_copy) zero_copy = fill_entry_zero_copy(ctx->arc, &entries[count]);
entries[count].size = archive_entry_size(ctx->entry);
count++;
}
}
if (sort) qsort(entries, entryCount, sizeof(entry), compare_entries);
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
qsort(entries, entryCount, sizeof(entry), compare_entries);
return count;
}
static void *acquire_decode_buffer() {
void *addr = NULL;
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
addr = decode_buffer[i];
if (addr) {
decode_buffer[i] = NULL;
break;
}
static bool archive_prealloc_mempool() {
mempoolofs = calloc(entryCount, sizeof(size_t));
for (int i = 0; i < entryCount; ++i) {
if (!i)
mempoolofs[i] = PAGE_ALIGN(entries[i].size);
else
mempoolofs[i] = PAGE_ALIGN(entries[i].size) + mempoolofs[i - 1];
}
pthread_mutex_unlock(&buffer_mutex);
if (!addr) addr = malloc(max_file_size);
return addr;
}
static void release_decode_buffer(void *buffer) {
pthread_mutex_lock(&buffer_mutex);
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
void *addr = decode_buffer[i];
if (!addr) {
decode_buffer[i] = buffer;
pthread_mutex_unlock(&buffer_mutex);
return;
}
mempool = mmap(0, MEMPOOL_SIZE, PROT_RW, MAP_ANON_POOL, -1, 0);
if (mempool == MAP_FAILED) {
LOGE("%s%s", "mmap failed with error ", strerror(errno));
return false;
}
pthread_mutex_unlock(&buffer_mutex);
free(buffer);
return true;
}
static int archive_list_all_entries(archive_ctx *ctx) {
int count = 0;
// Mincore does not support anon mapping yet
// Now that we use MADV_FREE, how can we detect whether pages is reclaimed?
static bool is_mempool_pages_present_and_lock(int index) {
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = MEMPOOL_ENTITY_SIZE(index);
mlock(addr, size);
#if 0
unsigned char vec[size / PAGE_SIZE];
unsigned char r = 1;
mincore(addr, size, vec);
for (int i = 0; i < size / PAGE_SIZE; ++i) r &= vec[i];
return r;
#endif
return false;
}
#define MEMPOOL_IN_CORE_AND_LOCK(x) is_mempool_pages_present_and_lock(x)
static void mempool_release_pages(void *addr, size_t size) {
size = PAGE_ALIGN(size);
munlock(addr, size);
int ret = madvise(addr, size, MADV_FREE);
if (ret == -EINVAL) madvise(addr, size, MADV_DONTNEED);
}
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (archive_entry_is_playable(ctx->entry))
if (filename_is_playable_file(archive_entry_pathname(ctx->entry)))
count++;
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
return count;
}
@@ -185,10 +179,18 @@ static void archive_release_ctx(archive_ctx *ctx) {
}
}
static archive_ctx *archive_alloc_ctx() {
archive_ctx *ctx = calloc(1, sizeof(archive_ctx));
static int archive_alloc_ctx(archive_ctx **ctxptr) {
archive_ctx *ctx;
int err;
ctx = calloc(1, sizeof(archive_ctx));
if (!ctx)
return -ENOMEM;
ctx->arc = archive_read_new();
ctx->using = 1;
if (!ctx->arc) {
free(ctx);
return -ENOMEM;
}
archive_read_support_format_tar(ctx->arc);
archive_read_support_format_7zip(ctx->arc);
archive_read_support_format_rar5(ctx->arc);
@@ -198,19 +200,19 @@ static archive_ctx *archive_alloc_ctx() {
archive_read_set_option(ctx->arc, "zip", "ignorecrc32", "1");
if (passwd)
archive_read_add_passphrase(ctx->arc, passwd);
int err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err < ARCHIVE_OK) {
LOGE("%s%s", "Open archive failed: ", archive_error_string(ctx->arc));
err = archive_read_open_memory(ctx->arc, archiveAddr, archiveSize);
if (err) {
archive_read_free(ctx->arc);
free(ctx);
return NULL;
return err;
}
return ctx;
*ctxptr = ctx;
return 0;
}
static int archive_skip_to_index(archive_ctx *ctx, int index) {
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
if (!archive_entry_is_playable(ctx->entry))
if (!filename_is_playable_file(archive_entry_pathname(ctx->entry)))
continue;
if (ctx->next_index++ == index) {
return ctx->next_index - 1;
@@ -222,7 +224,8 @@ static int archive_skip_to_index(archive_ctx *ctx, int index) {
static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
int ret;
archive_ctx *ctx = NULL;
pthread_mutex_lock(&ctx_pool_mutex);
mcs_lock_t local_lock;
lock_mcs(&ctx_lock, &local_lock);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i])
continue;
@@ -237,14 +240,16 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
if (ctx)
ctx->using = 1;
pthread_mutex_unlock(&ctx_pool_mutex);
unlock_mcs(&ctx_lock, &local_lock);
if (!ctx) {
archive_ctx *victimCtx = NULL;
int victimIdx = 0;
int replace = 1;
ctx = archive_alloc_ctx();
pthread_mutex_lock(&ctx_pool_mutex);
ret = archive_alloc_ctx(&ctx);
if (ret)
return ret;
lock_mcs(&ctx_lock, &local_lock);
for (int i = 0; i < CTX_POOL_SIZE; i++) {
if (!ctx_pool[i]) {
ctx_pool[i] = ctx;
@@ -259,13 +264,13 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
}
if (replace) ctx_pool[victimIdx] = ctx;
pthread_mutex_unlock(&ctx_pool_mutex);
unlock_mcs(&ctx_lock, &local_lock);
if (replace) archive_release_ctx(victimCtx);
}
ret = archive_skip_to_index(ctx, idx);
if (ret != idx) {
ret = archive_errno(ctx->arc);
LOGE("Skip to index failed: %s", archive_error_string(ctx->arc));
LOGE("Skip to index failed:%s", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
return ret;
}
@@ -274,118 +279,132 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
}
JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd,
jlong size) {
EH_UNUSED(env);
EH_UNUSED(thiz);
archive_ctx *ctx = NULL;
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
int mmap_flags = MAP_PRIVATE;
archiveAddr = mmap64(0, size, PROT_READ, mmap_flags, fd, 0);
if (archiveAddr == MAP_FAILED) {
LOGE("%s%s", "mmap failed with error ", strerror(errno));
LOGE("%s%s", "mmap64 failed with error ", strerror(errno));
return 0;
}
archiveSize = size;
madvise_log_if_error(archiveAddr, archiveSize, MADV_WILLNEED);
ctx_pool = calloc(CTX_POOL_SIZE, sizeof(archive_ctx **));
ctx = archive_alloc_ctx();
if (!ctx) return 0;
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " images in archive");
if (!entryCount) {
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
archive_release_ctx(ctx);
if (!ctx_pool) {
LOGE("Allocate archive ctx pool failed:ENOMEM");
return 0;
}
long r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entryCount = archive_list_all_entries(ctx);
LOGI("%s%zu%s", "Found ", entryCount, " image entries in archive");
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
// We must read through the file|vm then we can know whether it is encrypted
int encryptRet = archive_read_has_encrypted_entries(ctx->arc);
switch (encryptRet) {
case 1: // At lease 1 encrypted entry
need_encrypt = true;
break;
case 0: // format supports but no encrypted entry found
default:
need_encrypt = false;
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
}
int format = archive_format(ctx->arc);
switch (format) {
case ARCHIVE_FORMAT_ZIP:
case ARCHIVE_FORMAT_RAR_V5:
madvise_log_if_error(archiveAddr, archiveSize, MADV_SEQUENTIAL);
break;
case ARCHIVE_FORMAT_7ZIP: // Seek is bad
madvise_log_if_error(archiveAddr, archiveSize, MADV_RANDOM);
break;
default:;
}
archive_release_ctx(ctx);
ctx = archive_alloc_ctx();
if (!ctx) return 0;
entries = calloc(entryCount, sizeof(entry));
archive_map_entries_index(ctx, sort_entries);
r = archive_alloc_ctx(&ctx);
if (r) {
if (r == -ENOMEM)
LOGE("%s", "Mem alloc failed");
r = 0;
LOGE("%s%s", "Archive open failed:", archive_error_string(ctx->arc));
} else {
entries = calloc(entryCount, sizeof(entry));
r = archive_map_entries_index(ctx);
if (!archive_prealloc_mempool()) {
r = 0;
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
}
}
archive_release_ctx(ctx);
return (int) entryCount;
return r;
}
#pragma clang diagnostic push
#pragma ide diagnostic ignored "ConstantConditionsOC"
#pragma ide diagnostic ignored "UnreachableCode"
JNIEXPORT jobject JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz, jint index) {
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
entry *entry = &entries[index];
ssize_t size = entry->size;
if (entry->addr) {
return (*env)->NewDirectByteBuffer(env, entry->addr, size);
} else {
void *addr = MEMPOOL_ADDR_BY_SORTED_IDX(index);
size_t size = entries[index].size;
if (!MEMPOOL_IN_CORE_AND_LOCK(index)) {
index = entries[index].index;
archive_ctx *ctx = NULL;
if (!archive_get_ctx(&ctx, entry->index)) {
void *addr = acquire_decode_buffer();
ssize_t bytes = archive_read_data(ctx->arc, addr, size);
ctx->using = 0;
if (bytes == size) {
return (*env)->NewDirectByteBuffer(env, addr, size);
} else {
if (bytes < 0) {
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
} else {
LOGE("%s", "No enough data read, WTF?");
}
}
release_decode_buffer(addr);
}
int ret;
ret = archive_get_ctx(&ctx, index);
if (ret) return 0;
ret = archive_read_data(ctx->arc, addr, size);
ctx->using = 0;
if (ret == size)
return (*env)->NewDirectByteBuffer(env, addr, size);
if (ret != size)
LOGE("%s", "No enough data read, WTF?");
if (ret < 0)
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
mempool_release_pages(addr, size);
} else {
return (*env)->NewDirectByteBuffer(env, addr, size);
}
return 0;
}
#pragma clang diagnostic pop
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
EH_UNUSED(env);
EH_UNUSED(thiz);
if (ctx_pool) {
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
free(ctx_pool);
ctx_pool = NULL;
}
for (int i = 0; i < CTX_POOL_SIZE; i++)
archive_release_ctx(ctx_pool[i]);
free(passwd);
free(ctx_pool);
passwd = NULL;
need_encrypt = false;
if (archiveAddr != MAP_FAILED) {
munmap(archiveAddr, archiveSize);
archiveAddr = MAP_FAILED;
}
for (int i = 0; i < MAX_PARALLEL_DECOMP; ++i) {
free(decode_buffer[i]);
decode_buffer[i] = NULL;
}
max_file_size = 0;
if (entries) {
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
entries = NULL;
munmap(archiveAddr, archiveSize);
munmap(mempool, MEMPOOL_SIZE);
free(mempoolofs);
for (int i = 0; i < entryCount; ++i) {
free((void *) entries[i].filename);
}
free(entries);
}
JNIEXPORT jboolean JNICALL
@@ -396,24 +415,28 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) {
}
JNIEXPORT jboolean JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz, jstring str) {
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
jstring str) {
EH_UNUSED(thiz);
struct archive_entry *entry;
archive_ctx *ctx;
jboolean ret = true;
int len = (*env)->GetStringUTFLength(env, str);
passwd = realloc(passwd, len + 1);
(*env)->GetStringUTFRegion(env, str, 0, len, passwd);
passwd[len] = 0;
ctx = archive_alloc_ctx();
char tmpBuf[4096];
passwd = realloc(passwd, len * sizeof(char));
if (!passwd) {
LOGE("Allocate passwd buffer failed");
return false;
}
strcpy(passwd, (*env)->GetStringUTFChars(env, str, NULL));
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!archive_entry_is_playable(entry))
if (!filename_is_playable_file(archive_entry_pathname(entry)))
continue;
if (!archive_entry_is_encrypted(entry))
continue;
if (archive_read_data(ctx->arc, tmpBuf, 4096) < ARCHIVE_OK) {
LOGE("%s%s", "Archive read failed: ", archive_error_string(ctx->arc));
LOGE("%s%s", "Archive read failed:", archive_error_string(ctx->arc));
ret = false;
}
break;
@@ -423,15 +446,24 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
}
JNIEXPORT jstring JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_getExtension(JNIEnv *env, jclass thiz, jint index) {
Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
jint index) {
EH_UNUSED(env);
EH_UNUSED(thiz);
const char *ext = strrchr(entries[index].filename, '.') + 1;
return (*env)->NewStringUTF(env, ext);
index = entries[index].index;
archive_ctx *ctx = NULL;
int ret;
ret = archive_get_ctx(&ctx, index);
if (ret)
return NULL;
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname(ctx->entry));
ctx->using = 0;
return str;
}
JNIEXPORT jboolean JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, jint index, jint fd) {
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
jint index, jint fd) {
EH_UNUSED(env);
EH_UNUSED(thiz);
index = entries[index].index;
@@ -439,52 +471,16 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz, jint
int ret;
ret = archive_get_ctx(&ctx, index);
if (!ret) {
ret = archive_read_data_into_fd(ctx->arc, fd);
archive_read_data_into_fd(ctx->arc, fd);
ctx->using = 0;
}
return ret == ARCHIVE_OK;
}
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz, jobject buffer) {
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
jobject buffer) {
EH_UNUSED(thiz);
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
if (!ADDR_IN_FILE_MAPPING(addr)) {
release_decode_buffer(addr);
}
}
JNIEXPORT void JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_archiveFdBatch(JNIEnv *env, jclass clazz, jintArray fd_batch, jobjectArray names, jint arc_fd, jint size) {
EH_UNUSED(clazz);
struct archive *arc = archive_write_new();
struct stat st;
char buff[8192];
jint fdBatch[size];
(*env)->GetIntArrayRegion(env, fd_batch, 0, size, fdBatch);
archive_write_set_format_zip(arc);
archive_write_zip_set_compression_store(arc);
archive_write_open_fd(arc, arc_fd);
struct archive_entry *entry = archive_entry_new();
for (int i = 0; i < size; i++) {
int fd = fdBatch[i];
jobject name = (*env)->GetObjectArrayElement(env, names, i);
const char *cname = (*env)->GetStringUTFChars(env, name, false);
archive_entry_set_pathname(entry, cname);
(*env)->ReleaseStringUTFChars(env, name, cname);
fstat(fd, &st);
archive_entry_copy_stat(entry, &st);
archive_entry_set_perm(entry, 0644);
archive_write_header(arc, entry);
size_t len;
do {
len = read(fd, buff, sizeof(buff));
archive_write_data(arc, buff, len);
} while (len > 0);
archive_write_finish_entry(arc);
archive_entry_clear(entry);
}
archive_entry_free(entry);
archive_write_close(arc);
archive_write_free(arc);
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
mempool_release_pages(addr, size);
}
+1 -1
View File
@@ -37,7 +37,7 @@ static void doRewrite(byte *addr, size_t size) {
byte *end = addr + i + 4;
if (end[4] != 0) continue;
int frameDelay = end[2] << 8 | end[1];
if (frameDelay >= MINIMUM_FRAME_DELAY)
if (frameDelay > MINIMUM_FRAME_DELAY)
break; // Quit if the first block looks normal, for performance
end[1] = DEFAULT_FRAME_DELAY;
end[2] = 0;
-39
View File
@@ -1,39 +0,0 @@
#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);
}
-7
View File
@@ -1,7 +0,0 @@
{
global:
JNI_OnLoad;
Java_*;
local:
*;
};
+85 -24
View File
@@ -30,6 +30,7 @@
* negative chars in their default char type.
*/
#include <stddef.h> /* size_t */
#include <ctype.h>
#include "strnatcmp.h"
@@ -38,21 +39,28 @@
/* These are defined as macros to make it easier to adapt this code to
* different characters types or comparison functions. */
static inline int
nat_isdigit(const char *a)
nat_isdigit(nat_char a)
{
return isdigit((unsigned char) *a);
return isdigit((unsigned char) a);
}
static inline int
nat_isspace(const char *a)
nat_isspace(nat_char a)
{
return isspace((unsigned char) *a) || *a == '0' && nat_isdigit(a + 1);
return isspace((unsigned char) a);
}
static inline nat_char
nat_toupper(nat_char a)
{
return toupper((unsigned char) a);
}
static int
compare_right(const char *a, const char *b)
compare_right(nat_char const *a, nat_char const *b)
{
int bias = 0;
@@ -61,11 +69,11 @@ compare_right(const char *a, const char *b)
both numbers to know that they have the same magnitude, so we
remember it in BIAS. */
for (;; a++, b++) {
if (!nat_isdigit(a) && !nat_isdigit(b))
if (!nat_isdigit(*a) && !nat_isdigit(*b))
return bias;
if (!nat_isdigit(a))
if (!nat_isdigit(*a))
return -1;
if (!nat_isdigit(b))
if (!nat_isdigit(*b))
return +1;
if (*a < *b) {
if (!bias)
@@ -76,41 +84,94 @@ compare_right(const char *a, const char *b)
} else if (!*a && !*b)
return bias;
}
return 0;
}
int
strnatcmp(const char *a, const char *b)
static int
compare_left(nat_char const *a, nat_char const *b)
{
int result;
/* Compare two left-aligned numbers: the first to have a
different value wins. */
for (;; a++, b++) {
if (!nat_isdigit(*a) && !nat_isdigit(*b))
return 0;
if (!nat_isdigit(*a))
return -1;
if (!nat_isdigit(*b))
return +1;
if (*a < *b)
return -1;
if (*a > *b)
return +1;
}
while (1) {
/* skip over leading spaces or zeros */
while (nat_isspace(a))
a++;
return 0;
}
while (nat_isspace(b))
b++;
static int
strnatcmp0(nat_char const *a, nat_char const *b, int fold_case)
{
int ai, bi;
nat_char ca, cb;
int fractional, result;
ai = bi = 0;
while (1) {
ca = a[ai]; cb = b[bi];
/* skip over leading spaces or zeros */
while (nat_isspace(ca))
ca = a[++ai];
while (nat_isspace(cb))
cb = b[++bi];
/* process run of digits */
if (nat_isdigit(a) && nat_isdigit(b)) {
if ((result = compare_right(a, b)) != 0)
return result;
if (nat_isdigit(ca) && nat_isdigit(cb)) {
fractional = (ca == '0' || cb == '0');
if (fractional) {
if ((result = compare_left(a+ai, b+bi)) != 0)
return result;
} else {
if ((result = compare_right(a+ai, b+bi)) != 0)
return result;
}
}
if (!*a && !*b) {
if (!ca && !cb) {
/* The strings compare the same. Perhaps the caller
will want to call strcmp to break the tie. */
return 0;
}
if (*a < *b)
if (fold_case) {
ca = nat_toupper(ca);
cb = nat_toupper(cb);
}
if (ca < cb)
return -1;
if (*a > *b)
if (ca > cb)
return +1;
a++; b++;
++ai; ++bi;
}
}
int
strnatcmp(nat_char const *a, nat_char const *b) {
return strnatcmp0(a, b, 0);
}
/* Compare, recognizing numeric string and ignoring case. */
int
strnatcasecmp(nat_char const *a, nat_char const *b) {
return strnatcmp0(a, b, 1);
}
+8 -1
View File
@@ -24,7 +24,14 @@
extern "C" {
#endif
int strnatcmp(const char *a, const char *b);
/* CUSTOMIZATION SECTION
*
* You can change this typedef, but must then also change the inline
* functions in strnatcmp.c */
typedef char nat_char;
int strnatcmp(nat_char const *a, nat_char const *b);
int strnatcasecmp(nat_char const *a, nat_char const *b);
#ifdef __cplusplus
}
+1
View File
@@ -0,0 +1 @@
/nettle
+238
View File
@@ -0,0 +1,238 @@
cmake_minimum_required(VERSION 3.4.1)
project(nettle C)
set(LIBNETTLE_DEFINITIONS
-DHAVE_CONFIG_H)
set(LIBNETTLE_SOURCES
nettle/aes-decrypt-internal.c
nettle/aes-decrypt.c
nettle/aes-decrypt-table.c
nettle/aes128-decrypt.c
nettle/aes192-decrypt.c
nettle/aes256-decrypt.c
nettle/aes-encrypt-internal.c
nettle/aes-encrypt.c
nettle/aes-encrypt-table.c
nettle/aes128-encrypt.c
nettle/aes192-encrypt.c
nettle/aes256-encrypt.c
nettle/aes-invert-internal.c
nettle/aes-set-key-internal.c
nettle/aes-set-encrypt-key.c
nettle/aes-set-decrypt-key.c
nettle/aes128-set-encrypt-key.c
nettle/aes128-set-decrypt-key.c
nettle/aes128-meta.c
nettle/aes192-set-encrypt-key.c
nettle/aes192-set-decrypt-key.c
nettle/aes192-meta.c
nettle/aes256-set-encrypt-key.c
nettle/aes256-set-decrypt-key.c
nettle/aes256-meta.c
nettle/nist-keywrap.c
nettle/arcfour.c
nettle/arctwo.c
nettle/arctwo-meta.c
nettle/blowfish.c
nettle/blowfish-bcrypt.c
nettle/base16-encode.c
nettle/base16-decode.c
nettle/base16-meta.c
nettle/base64-encode.c
nettle/base64-decode.c
nettle/base64-meta.c
nettle/base64url-encode.c
nettle/base64url-decode.c
nettle/base64url-meta.c
nettle/buffer.c
nettle/buffer-init.c
nettle/camellia-crypt-internal.c
nettle/camellia-table.c
nettle/camellia-absorb.c
nettle/camellia-invert-key.c
nettle/camellia128-set-encrypt-key.c
nettle/camellia128-crypt.c
nettle/camellia128-set-decrypt-key.c
nettle/camellia128-meta.c
nettle/camellia192-meta.c
nettle/camellia256-set-encrypt-key.c
nettle/camellia256-crypt.c
nettle/camellia256-set-decrypt-key.c
nettle/camellia256-meta.c
nettle/cast128.c
nettle/cast128-meta.c
nettle/cbc.c
nettle/cbc-aes128-encrypt.c
nettle/cbc-aes192-encrypt.c
nettle/cbc-aes256-encrypt.c
nettle/ccm.c
nettle/ccm-aes128.c
nettle/ccm-aes192.c
nettle/ccm-aes256.c
nettle/cfb.c
nettle/siv-cmac.c
nettle/siv-cmac-aes128.c
nettle/siv-cmac-aes256.c
nettle/cnd-memcpy.c
nettle/chacha-crypt.c
nettle/chacha-core-internal.c
nettle/chacha-poly1305.c
nettle/chacha-poly1305-meta.c
nettle/chacha-set-key.c
nettle/chacha-set-nonce.c
nettle/ctr.c
nettle/ctr16.c
nettle/des.c
nettle/des3.c
nettle/eax.c
nettle/eax-aes128.c
nettle/eax-aes128-meta.c
nettle/ghash-set-key.c
nettle/ghash-update.c
nettle/gcm.c
nettle/gcm-aes.c
nettle/gcm-aes128.c
nettle/gcm-aes128-meta.c
nettle/gcm-aes192.c
nettle/gcm-aes192-meta.c
nettle/gcm-aes256.c
nettle/gcm-aes256-meta.c
nettle/gcm-camellia128.c
nettle/gcm-camellia128-meta.c
nettle/gcm-camellia256.c
nettle/gcm-camellia256-meta.c
nettle/cmac.c
nettle/cmac64.c
nettle/cmac-aes128.c
nettle/cmac-aes256.c
nettle/cmac-des3.c
nettle/cmac-aes128-meta.c
nettle/cmac-aes256-meta.c
nettle/cmac-des3-meta.c
nettle/gost28147.c
nettle/gosthash94.c
nettle/gosthash94-meta.c
nettle/hmac.c
nettle/hmac-gosthash94.c
nettle/hmac-md5.c
nettle/hmac-ripemd160.c
nettle/hmac-sha1.c
nettle/hmac-sha224.c
nettle/hmac-sha256.c
nettle/hmac-sha384.c
nettle/hmac-sha512.c
nettle/hmac-streebog.c
nettle/hmac-sm3.c
nettle/hmac-md5-meta.c
nettle/hmac-ripemd160-meta.c
nettle/hmac-sha1-meta.c
nettle/hmac-sha224-meta.c
nettle/hmac-sha256-meta.c
nettle/hmac-sha384-meta.c
nettle/hmac-sha512-meta.c
nettle/hmac-streebog-meta.c
nettle/hmac-sm3-meta.c
nettle/knuth-lfib.c
nettle/hkdf.c
nettle/md2.c
nettle/md2-meta.c
nettle/md4.c
nettle/md4-meta.c
nettle/md5.c
nettle/md5-compress.c
nettle/md5-compat.c
nettle/md5-meta.c
nettle/memeql-sec.c
nettle/memxor.c
nettle/memxor3.c
nettle/nettle-lookup-hash.c
nettle/nettle-meta-aeads.c
nettle/nettle-meta-armors.c
nettle/nettle-meta-ciphers.c
nettle/nettle-meta-hashes.c
nettle/nettle-meta-macs.c
nettle/pbkdf2.c
nettle/pbkdf2-hmac-gosthash94.c
nettle/pbkdf2-hmac-sha1.c
nettle/pbkdf2-hmac-sha256.c
nettle/pbkdf2-hmac-sha384.c
nettle/pbkdf2-hmac-sha512.c
nettle/poly1305-aes.c
nettle/poly1305-internal.c
nettle/realloc.c
nettle/ripemd160.c
nettle/ripemd160-compress.c
nettle/ripemd160-meta.c
nettle/salsa20-core-internal.c
nettle/salsa20-crypt-internal.c
nettle/salsa20-crypt.c
nettle/salsa20r12-crypt.c
nettle/salsa20-set-key.c
nettle/salsa20-set-nonce.c
nettle/salsa20-128-set-key.c
nettle/salsa20-256-set-key.c
nettle/sha1.c
nettle/sha1-compress.c
nettle/sha1-meta.c
nettle/sha256.c
nettle/sha256-compress-n.c
nettle/sha224-meta.c
nettle/sha256-meta.c
nettle/sha512.c
nettle/sha512-compress.c
nettle/sha384-meta.c
nettle/sha512-meta.c
nettle/sha512-224-meta.c
nettle/sha512-256-meta.c
nettle/sha3.c
nettle/sha3-permute.c
nettle/sha3-224.c
nettle/sha3-224-meta.c
nettle/sha3-256.c
nettle/sha3-256-meta.c
nettle/sha3-384.c
nettle/sha3-384-meta.c
nettle/sha3-512.c
nettle/sha3-512-meta.c
nettle/shake256.c
nettle/sm3.c
nettle/sm3-meta.c
nettle/serpent-set-key.c
nettle/serpent-encrypt.c
nettle/serpent-decrypt.c
nettle/serpent-meta.c
nettle/streebog.c
nettle/streebog-meta.c
nettle/twofish.c
nettle/twofish-meta.c
nettle/umac-nh.c
nettle/umac-nh-n.c
nettle/umac-l2.c
nettle/umac-l3.c
nettle/umac-poly64.c
nettle/umac-poly128.c
nettle/umac-set-key.c
nettle/umac32.c
nettle/umac64.c
nettle/umac96.c
nettle/umac128.c
nettle/version.c
nettle/write-be32.c
nettle/write-le32.c
nettle/write-le64.c
nettle/yarrow256.c
nettle/yarrow_key_event.c
nettle/xts.c
nettle/xts-aes128.c
nettle/xts-aes256.c
)
set(LIBNETTLE_INCLUDES
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/nettle
)
add_library(nettle STATIC ${LIBNETTLE_SOURCES})
target_include_directories(nettle PUBLIC ${LIBNETTLE_INCLUDES})
target_compile_definitions(nettle PRIVATE ${LIBNETTLE_DEFINITIONS})
+280
View File
@@ -0,0 +1,280 @@
/* 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
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+64
View File
@@ -0,0 +1,64 @@
/* 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 */
+44
View File
@@ -0,0 +1,44 @@
#ifndef EHVIEWER_SPINLOCK_H
#define EHVIEWER_SPINLOCK_H
#include <stddef.h>
#include <stdatomic.h>
#define cmpxchg(P, O, N) __sync_val_compare_and_swap((P), (O), (N))
#define barrier() __asm__ __volatile__("":::"memory")
#if defined(__aarch64__) || defined(__arm__)
#define cpu_relax() __asm__ volatile ("wfe");
#else
#define cpu_relax() __asm__ volatile ("pause" ::: "memory");
#endif
typedef struct mcs_lock_t mcs_lock_t;
struct mcs_lock_t {
mcs_lock_t *next;
int spin;
};
typedef struct mcs_lock_t *mcs_lock;
static inline void lock_mcs(_Atomic mcs_lock *m, mcs_lock_t *me) {
mcs_lock_t *tail;
me->next = NULL;
me->spin = 0;
tail = atomic_exchange(m, me);
if (!tail) return;
tail->next = me;
barrier();
while (!me->spin) cpu_relax();
return;
}
static inline void unlock_mcs(mcs_lock *m, mcs_lock_t *me) {
if (!me->next) {
if (cmpxchg(m, me, NULL) == me) return;
while (!me->next) cpu_relax();
}
me->next->spin = 1;
}
#endif //EHVIEWER_SPINLOCK_H
@@ -0,0 +1,989 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.compose.material3
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import com.hippo.ehviewer.util.isAtLeastT
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults2 {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
}
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState2
@Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)",
),
)
constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = SheetValue.Hidden,
val confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = SheetValue.Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != SheetValue.PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != SheetValue.Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(SheetValue.Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(SheetValue.PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
else -> SheetValue.Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(SheetValue.Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.animateTo(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState2].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
},
)
/**
* The default [Saver] implementation for [SheetState2].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
),
)
@Suppress("Deprecation")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
},
)
}
}
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param properties [ModalBottomSheetProperties2] for further customization of this
* modal bottom sheet's behavior.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetFix(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState2 = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
LaunchedEffect(sheetState) {
snapshotFlow {
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
}.drop(1).collectLatest {
if (it) onDismissRequest()
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
val popupOnDismissRequest: () -> Unit = {
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
scope.launch { sheetState.partialExpand() }
} else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
}
}
ModalBottomSheetPopup(
properties = properties,
onDismissRequest = popupOnDismissRequest,
windowInsets = windowInsets,
) {
val configuration = LocalConfiguration.current
val widthDp = configuration.screenWidthDp.dp
val height = configuration.screenHeightDp.dp
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
sheetState.hide()
onDismissRequest()
}
val predictiveModifier = Modifier.graphicsLayer {
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
scaleX = scale
scaleY = scale
}.offset {
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
IntOffset(ofsX, ofsY)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != SheetValue.Hidden,
)
Surface(
modifier = modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt(),
)
} then predictiveModifier
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss,
)
},
)
.anchoredDraggable(
state = sheetState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
)
.modalBottomSheetAnchors(
sheetState = sheetState,
fullHeight = fullHeight.toFloat(),
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState2,
orientation: Orientation,
onFling: (velocity: Float) -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
@ExperimentalMaterial3Api
class ModalBottomSheetProperties2(
val securePolicy: SecureFlagPolicy,
val isFocusable: Boolean,
val shouldDismissOnBackPress: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModalBottomSheetProperties2) return false
if (securePolicy != other.securePolicy) return false
if (isFocusable != other.isFocusable) return false
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
return true
}
override fun hashCode(): Int {
var result = securePolicy.hashCode()
result = 31 * result + isFocusable.hashCode()
result = 31 * result + shouldDismissOnBackPress.hashCode()
return result
}
}
/**
* Default values for [ModalBottomSheetFix]
*/
@Immutable
@ExperimentalMaterial3Api
object ModalBottomSheetDefaults2 {
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
fun properties(
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
isFocusable: Boolean = true,
shouldDismissOnBackPress: Boolean = true,
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
}
/**
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState2 {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = SheetState2.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density,
),
) {
SheetState2(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState,
)
}
}
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean,
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetAnchors(
sheetState: SheetState2,
fullHeight: Float,
) = onSizeChanged { sheetSize ->
val newAnchors = DraggableAnchors {
SheetValue.Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
SheetValue.PartiallyExpanded at fullHeight / 2f
}
if (sheetSize.height != 0) {
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
SheetValue.Hidden -> SheetValue.Hidden
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) {
SheetValue.PartiallyExpanded
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
newTarget
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
/**
* Popup specific for modal bottom sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ModalBottomSheetPopup(
properties: ModalBottomSheetProperties2,
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val layoutDirection = LocalLayoutDirection.current
val configuration = LocalConfiguration.current
val modalBottomSheetWindow = remember(configuration) {
ModalBottomSheetWindow2(
properties = properties,
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id,
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.then(
// TODO(b/290893168): Figure out a solution for APIs < 30.
if (Build.VERSION.SDK_INT >= 33) {
Modifier.imePadding()
} else {
Modifier
},
),
) {
currentContent()
}
},
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheetFix] */
@SuppressLint("ViewConstructor")
@OptIn(ExperimentalMaterial3Api::class)
private class ModalBottomSheetWindow2(
private val properties: ModalBottomSheetProperties2,
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) : AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector,
OnBackPressedDispatcherOwner {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
override val onBackPressedDispatcher = OnBackPressedDispatcher {
onDismissRequest()
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() = context.resources.displayMetrics.widthPixels
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title,
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
// Security flag
val secureFlagEnabled =
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
if (secureFlagEnabled) {
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
}
// Focusable
if (!properties.isFocusable) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
}
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
content()
}
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit,
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
if (isAtLeastT) {
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
}
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
override fun onGlobalLayout() {
// No-op
}
override fun setLayoutDirection(layoutDirection: Int) {
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
// from the context's locale, but we have one already from the parent composition.
}
// Sets the "real" layout direction for our content that we obtain from the parent composition.
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
val direction = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
super.setLayoutDirection(direction)
}
}
// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
return when (this) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
}
}
@@ -1,4 +1,4 @@
@file:Suppress("ktlint:standard:property-naming")
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.compose.material3
@@ -7,11 +7,13 @@ import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,16 +23,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlurEffect
@@ -39,26 +38,44 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.lerp
import com.ehviewer.core.ui.util.animateFloatMergeOneWayPredictiveBackAsState
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
private val ContainerWidth = 360.0.dp
@Suppress("NotCloseable")
@Stable
class DrawerState2(initialValue: DrawerValue) {
class DrawerState2(
initialValue: DrawerValue,
val confirmStateChange: (DrawerValue) -> Boolean = { true },
) {
val anchoredDraggableState = AnchoredDraggableState(initialValue)
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
)
/**
* Whether the drawer is open.
@@ -81,7 +98,7 @@ class DrawerState2(initialValue: DrawerValue) {
*/
val currentValue: DrawerValue
get() {
return anchoredDraggableState.settledValue
return anchoredDraggableState.currentValue
}
/**
@@ -200,10 +217,11 @@ class DrawerState2(initialValue: DrawerValue) {
/**
* The default [Saver] implementation for [DrawerState2].
*/
fun Saver() = Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it) },
)
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
Saver<DrawerState2, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState2(it, confirmStateChange) },
)
}
}
@@ -211,57 +229,82 @@ class DrawerState2(initialValue: DrawerValue) {
* Create and [remember] a [DrawerState2].
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberDrawerState2(
fun rememberDrawerState(
initialValue: DrawerValue,
): DrawerState2 = rememberSaveable(saver = DrawerState2.Saver()) {
DrawerState2(initialValue)
confirmStateChange: (DrawerValue) -> Boolean = { true },
): DrawerState2 {
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
DrawerState2(initialValue, confirmStateChange)
}
}
@Composable
fun ModalSideDrawer(
fun ModalNavigationDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState2(DrawerValue.Closed),
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var maxValue by remember { mutableFloatStateOf(with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() }) }
val minValue = 0f
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
},
)
}
Box(
modifier.fillMaxSize().anchoredHorizontalDraggable(
state = drawerState.anchoredDraggableState,
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = drawerState.anchoredDraggableState,
animationSpec = AnimationSpec,
),
),
) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
lerp(0, 10, step).dp
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}.collectAsState(0.dp)
}
} else {
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
@@ -274,12 +317,146 @@ fun ModalSideDrawer(
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled) {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(maxValue, minValue, drawerState.requireOffset())
calculateFraction(minValue, maxValue, drawerState.requireOffset())
},
color = scrimColor,
)
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
drawerState.close()
}
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
val predictiveModifier = if (drawerState.isOpen) {
if (predictiveState > 0) {
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val multiplierX = lerp(1f, 1.05f, predictiveState)
val multiplierY = lerp(1f, 0.95f, predictiveState)
val scaledWidth = (multiplierX * placeable.width).roundToInt()
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
shape = DrawerDefaults.shape,
)
} else {
Modifier.graphicsLayer {
val scale = lerp(1f, 0.95f, -predictiveState)
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = -it.size.width.toFloat()
}
}
}
Box(
modifier = Modifier.offset {
IntOffset(drawerState.requireOffset().roundToInt(), 0)
} then predictiveModifier.align(Alignment.CenterStart),
contentAlignment = Alignment.CenterEnd,
) {
drawerContent()
}
}
}
@Composable
fun SideDrawer(
drawerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit,
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drag.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
val blurModifier = Modifier.graphicsLayer {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val radius = lerp(0.dp, 10.dp, step)
if (radius != 0.dp) {
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
shape = RectangleShape
clip = true
}
}
Box(modifier = blurModifier) {
content()
}
Scrim(
open = drawerState.isOpen,
onClose = {
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
},
color = scrimColor,
)
@@ -297,7 +474,7 @@ fun ModalSideDrawer(
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(placeable.width - scaledWidth, 0)
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
@@ -309,20 +486,14 @@ fun ModalSideDrawer(
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, maxValue * 0.05f, predictiveState).roundToInt(), 0)
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onSizeChanged {
val width = it.width
if (width != 0) {
maxValue = width.toFloat()
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
},
)
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = it.size.width.toFloat()
}
}
}
@@ -0,0 +1,502 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatorMutex
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
*
* A search bar represents a floating search field that allows users to enter a keyword or phrase
* and get relevant information. It can be used as a way to navigate through an app via search
* queries.
*
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
*
* ![Search bar image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
*
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
* any [Constraints] that limit its size, and the host activity should set
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
*
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
* can be used instead.
*
* @param state the query text to be shown in the search bar's input field
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
* action.
* @param active whether this search bar is active
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
* @param modifier the [Modifier] to be applied to this search bar
* @param enabled controls the enabled state of this search bar. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
* always be [SearchBarDefaults.fullScreenShape].
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
* in different states. See [SearchBarDefaults.colors].
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
* value will result in a darker color in light theme and lighter color in dark theme. See also:
* [Surface].
* @param shadowElevation the elevation for the shadow below the search bar
* @param windowInsets the window insets that the search bar will respect
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this search bar. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this search bar in different states.
* @param content the content of this search bar that will be displayed below the input field
*/
@ExperimentalMaterial3Api
@Composable
fun SearchBar(
state: TextFieldState,
onSearch: () -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
title: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
) {
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
var firstBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
var currentBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
val useFullScreenShape by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
}
val animatedShape = remember(useFullScreenShape, shape) {
when {
shape == defaultInputFieldShape ->
// The shape can only be animated if it's the default spec value
GenericShape { size, _ ->
val radius = with(density) {
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
}
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
}
useFullScreenShape -> defaultFullScreenShape
else -> shape
}
}
// The main animation complexity is allowing the component to smoothly expand while keeping the
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
// support animation and thus is not suitable. Instead, we convert the insets to a padding
// applied to the Surface, which gradually becomes padding applied to the input field as the
// animation proceeds.
val unconsumedInsets = remember { MutableWindowInsets() }
val topPadding = remember(density) {
derivedStateOf {
SearchBarVerticalPadding +
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
}
}
Surface(
shape = animatedShape,
color = colors.containerColor,
contentColor = contentColorFor(colors.containerColor),
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
modifier = modifier
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
.zIndex(1f)
.onConsumedWindowInsetsChanged { consumedInsets ->
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
}
.consumeWindowInsets(unconsumedInsets)
.layout { measurable, constraints ->
val animatedTopPadding =
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
val startWidth: Int
val startHeight: Int
if (currentBackEvent == null) {
startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
.coerceAtMost(constraints.maxHeight)
} else {
startWidth =
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
startHeight =
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
}
val endWidth = constraints.maxWidth
val endHeight = constraints.maxHeight
val width = lerp(startWidth, endWidth, animationProgress.value)
val height =
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
constraints,
minOffsetMargin,
currentBackEvent,
animationProgress.value,
)
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
constraints,
minOffsetMargin,
currentBackEvent,
firstBackEvent,
height,
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
)
val placeable = measurable.measure(
Constraints
.fixed(width, height)
.offset(
vertical = -animatedTopPadding,
),
)
layout(width, height) {
placeable.placeRelative(
predictiveBackOffsetX,
animatedTopPadding + predictiveBackOffsetY,
)
}
},
) {
Column {
val animatedInputFieldPadding = remember {
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
}
SearchBarInputField(
state = state,
onSearch = onSearch,
onActiveChange = onActiveChange,
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
enabled = enabled,
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
colors = colors.inputFieldColors,
interactionSource = interactionSource,
)
val showResults by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
}
if (showResults) {
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
HorizontalDivider(color = colors.dividerColor)
content()
}
}
}
}
val isFocused = interactionSource.collectIsFocusedAsState().value
val shouldClearFocus = !active && isFocused
if (shouldClearFocus) {
LaunchedEffect(true) {
// Not strictly needed according to the motion spec, but since the animation already has
// a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
LaunchedEffect(active) {
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
val animationSpec =
if (animationInProgress) {
AnimationPredictiveBackExitFloatSpec
} else if (active) {
AnimationEnterFloatSpec
} else {
AnimationExitFloatSpec
}
animationProgress.animateTo(
targetValue = if (active) 1f else 0f,
animationSpec = animationSpec,
)
}
val mutatorMutex = remember { MutatorMutex() }
if (active) {
PredictiveBackHandler(true) { progress ->
mutatorMutex.mutate {
var canceled = false
try {
progress.collect { backEvent ->
if (firstBackEvent == null) {
firstBackEvent = backEvent
}
currentBackEvent = backEvent
val interpolatedProgress = EaseOut.transform(backEvent.progress)
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
}
} catch (e: CancellationException) {
canceled = true
} finally {
firstBackEvent = null
currentBackEvent = null
onActiveChange(canceled)
}
}
}
}
}
private fun calculatePredictiveBackOffsetX(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
progress: Float,
): Int {
if (currentBackEvent == null) {
return 0
}
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
val maxOffsetX =
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
return (maxOffsetX * (1 - progress)).toInt() * directionMultiplier
}
private fun calculatePredictiveBackOffsetY(
constraints: Constraints,
minMargin: Int,
currentBackEvent: BackEventCompat?,
firstBackEvent: BackEventCompat?,
height: Int,
maxOffsetY: Int,
): Int {
if (firstBackEvent == null || currentBackEvent == null) {
return 0
}
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
val yProgress = abs(yDelta) / constraints.maxHeight
val directionMultiplier = sign(yDelta)
return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt()
}
@Composable
private fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
val focused by interactionSource.collectIsFocusedAsState()
val textColor = LocalTextStyle.current.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
BasicTextField2(
state = state,
modifier = modifier
.height(InputFieldHeight)
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
.semantics {
onClick {
focusRequester.requestFocus()
true
}
},
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
interactionSource = interactionSource,
decorator = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
}
},
trailingIcon = trailingIcon?.let { trailing ->
{
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
}
},
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
)
},
)
}
@Stable
private class AnimatedPaddingValues2(
val animationProgress: State<Float>,
val topPadding: State<Dp>,
) : PaddingValues {
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
}
// Measurement specs
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
internal val SearchBarMinWidth: Dp = 360.dp
private val SearchBarMaxWidth: Dp = 720.dp
internal val SearchBarVerticalPadding: Dp = 8.dp
internal val SearchBarHorizontalPadding: Dp = 16.dp
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
private val SearchBarIconOffsetX: Dp = 4.dp
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.dp
// Animation specs
private const val AnimationEnterDurationMillis: Int = 600
private const val AnimationExitDurationMillis: Int = 350
private const val AnimationDelayMillis: Int = 100
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationEnterDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationEnterEasing,
)
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationExitEasing,
)
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
easing = AnimationExitEasing,
)
@@ -0,0 +1,63 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphNavigator
import androidx.navigation.Navigator
import androidx.navigation.NavigatorProvider
/**
* Custom subclass of [NavGraphNavigator] that adds support for defining
* transitions at the navigation graph level.
*/
@Navigator.Name("navigation")
internal class ComposeNavGraphNavigator(
navigatorProvider: NavigatorProvider,
) : NavGraphNavigator(navigatorProvider) {
override fun createDestination(): NavGraph {
return ComposeNavGraph(this)
}
internal class ComposeNavGraph(
navGraphNavigator: Navigator<out NavGraph>,
) : NavGraph(navGraphNavigator) {
internal var enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
internal var popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
}
}
@@ -0,0 +1,128 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.compose.ComposeNavigator.Destination
/**
* Navigator that navigates through [Composable]s. Every destination using this Navigator must
* set a valid [Composable] by setting it directly on an instantiated [Destination] or calling
* [composable].
*/
@Navigator.Name("composable")
public class ComposeNavigator : Navigator<Destination>() {
/**
* Get the map of transitions currently in progress from the [state].
*/
internal val transitionsInProgress get() = state.transitionsInProgress
/**
* Get the back stack from the [state].
*/
public val backStack get() = state.backStack
internal val isPop = mutableStateOf(false)
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?,
) {
entries.forEach { entry ->
state.pushWithTransition(entry)
}
isPop.value = false
}
override fun createDestination(): Destination {
return Destination(this) { }
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
isPop.value = true
}
/**
* Callback to mark a navigation in transition as complete.
*
* This should be called in conjunction with [navigate] and [popBackStack] as those
* calls merely start a transition to the target destination, and requires manually marking
* the transition as complete by calling this method.
*
* Failing to call this method could result in entries being prevented from reaching their
* final [Lifecycle.State].
*/
public fun onTransitionComplete(entry: NavBackStackEntry) {
state.markTransitionComplete(entry)
}
/**
* NavDestination specific to [ComposeNavigator]
*/
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: ComposeNavigator,
internal val content:
@Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit,
) : NavDestination(navigator) {
@Deprecated(
message = "Deprecated in favor of Destination that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
constructor(
navigator: ComposeNavigator,
content: @Composable (NavBackStackEntry) -> @JvmSuppressWildcards Unit,
) : this(navigator, content = { entry -> content(entry) })
internal var enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
internal var popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null
internal var popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null
}
internal companion object {
internal const val NAME = "composable"
}
}
@@ -0,0 +1,137 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.DialogNavigator.Destination
/**
* Show each [Destination] on the [DialogNavigator]'s back stack as a [Dialog].
*
* Note that [NavHost] will call this for you; you do not need to call it manually.
*/
@Composable
public fun DialogHost(dialogNavigator: DialogNavigator) {
val saveableStateHolder = rememberSaveableStateHolder()
val dialogBackStack by dialogNavigator.backStack.collectAsState()
val visibleBackStack = rememberVisibleList(dialogBackStack)
visibleBackStack.PopulateVisibleList(dialogBackStack)
val transitionInProgress by dialogNavigator.transitionInProgress.collectAsState()
val dialogsToDispose = remember { mutableStateListOf<NavBackStackEntry>() }
visibleBackStack.forEach { backStackEntry ->
val destination = backStackEntry.destination as Destination
Dialog(
onDismissRequest = { dialogNavigator.dismiss(backStackEntry) },
properties = destination.dialogProperties,
) {
DisposableEffect(backStackEntry) {
dialogsToDispose.add(backStackEntry)
onDispose {
dialogNavigator.onTransitionComplete(backStackEntry)
dialogsToDispose.remove(backStackEntry)
}
}
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
backStackEntry.LocalOwnersProvider(saveableStateHolder) {
destination.content(backStackEntry)
}
}
}
// Dialogs may have been popped before it was composed. To prevent leakage, we need to
// mark popped entries as complete here. Check that we don't accidentally complete popped
// entries that were composed, unless they were disposed of already.
LaunchedEffect(transitionInProgress, dialogsToDispose) {
transitionInProgress.forEach { entry ->
if (!dialogNavigator.backStack.value.contains(entry) &&
!dialogsToDispose.contains(entry)
) {
dialogNavigator.onTransitionComplete(entry)
}
}
}
}
@Composable
internal fun MutableList<NavBackStackEntry>.PopulateVisibleList(
backStack: Collection<NavBackStackEntry>,
) {
val isInspecting = LocalInspectionMode.current
backStack.forEach { entry ->
DisposableEffect(entry.lifecycle) {
val observer = LifecycleEventObserver { _, event ->
// show dialog in preview
if (isInspecting && !contains(entry)) {
add(entry)
}
// ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack
if (event == Lifecycle.Event.ON_START) {
// We want to treat the visible lists as Sets but we want to keep
// the functionality of mutableStateListOf() so that we recompose in response
// to adds and removes.
if (!contains(entry)) {
add(entry)
}
}
if (event == Lifecycle.Event.ON_STOP) {
remove(entry)
}
}
entry.lifecycle.addObserver(observer)
onDispose {
entry.lifecycle.removeObserver(observer)
}
}
}
}
@Composable
internal fun rememberVisibleList(
backStack: Collection<NavBackStackEntry>,
): SnapshotStateList<NavBackStackEntry> {
// show dialog in preview
val isInspecting = LocalInspectionMode.current
return remember(backStack) {
mutableStateListOf<NavBackStackEntry>().also {
it.addAll(
backStack.filter { entry ->
if (isInspecting) {
true
} else {
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
},
)
}
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.FloatingWindow
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.compose.DialogNavigator.Destination
/**
* Navigator that navigates through [Composable]s that will be hosted within a
* [Dialog]. Every destination using this Navigator must set a valid [Composable] by setting it
* directly on an instantiated [Destination] or calling [dialog].
*/
@Navigator.Name("dialog")
public class DialogNavigator : Navigator<Destination>() {
/**
* Get the back stack from the [state].
*/
internal val backStack get() = state.backStack
/**
* Get the transitioning dialogs from the [state].
*/
internal val transitionInProgress get() = state.transitionsInProgress
/**
* Dismiss the dialog destination associated with the given [backStackEntry].
*/
internal fun dismiss(backStackEntry: NavBackStackEntry) {
popBackStack(backStackEntry, false)
}
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?,
) {
entries.forEach { entry ->
state.push(entry)
}
}
override fun createDestination(): Destination {
return Destination(this) { }
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
// When popping, the incoming dialog is marked transitioning to hold it in
// STARTED. With pop complete, we can remove it from transition so it can move to RESUMED.
val popIndex = state.transitionsInProgress.value.indexOf(popUpTo)
// do not mark complete for entries up to and including popUpTo
state.transitionsInProgress.value.forEachIndexed { index, entry ->
if (index > popIndex) onTransitionComplete(entry)
}
}
internal fun onTransitionComplete(entry: NavBackStackEntry) {
state.markTransitionComplete(entry)
}
/**
* NavDestination specific to [DialogNavigator]
*/
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: DialogNavigator,
internal val dialogProperties: DialogProperties = DialogProperties(),
internal val content: @Composable (NavBackStackEntry) -> Unit,
) : NavDestination(navigator), FloatingWindow
internal companion object {
internal const val NAME = "dialog"
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.navigation.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.saveable.SaveableStateHolder
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import java.lang.ref.WeakReference
import java.util.UUID
/**
* Provides [this] [NavBackStackEntry] as [LocalViewModelStoreOwner], [LocalLifecycleOwner] and
* [LocalSavedStateRegistryOwner] to the [content] and saves the [content]'s saveable states with
* the given [saveableStateHolder].
*
* @param saveableStateHolder The [SaveableStateHolder] that holds the saved states. The same
* holder should be used for all [NavBackStackEntry]s in the encapsulating [Composable] and the
* holder should be hoisted.
* @param content The content [Composable]
*/
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
saveableStateHolder: SaveableStateHolder,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalViewModelStoreOwner provides this,
LocalLifecycleOwner provides this,
LocalSavedStateRegistryOwner provides this,
) {
saveableStateHolder.SaveableStateProvider(content)
}
}
@Composable
private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) {
val viewModel = viewModel<BackStackEntryIdViewModel>()
// Stash a reference to the SaveableStateHolder in the ViewModel so that
// it is available when the ViewModel is cleared, marking the permanent removal of this
// NavBackStackEntry from the back stack. Which, because of animations,
// only happens after this leaves composition. Which means we can't rely on
// DisposableEffect to clean up this reference (as it'll be cleaned up too early)
viewModel.saveableStateHolderRef = WeakReference(this)
SaveableStateProvider(viewModel.id, content)
}
internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() {
private val IdKey = "SaveableStateHolder_BackStackEntryKey"
// we create our own id for each back stack entry to support multiple entries of the same
// destination. this id will be restored by SavedStateHandle
val id: UUID = handle.get<UUID>(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) }
lateinit var saveableStateHolderRef: WeakReference<SaveableStateHolder>
// onCleared will be called on the entries removed from the back stack. here we notify
// SaveableStateProvider that we should remove any state is had associated with this
// destination as it is no longer needed.
override fun onCleared() {
super.onCleared()
saveableStateHolderRef.get()?.removeState(id)
saveableStateHolderRef.clear()
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
import androidx.navigation.get
/**
* Add the [Composable] to the [NavGraphBuilder]
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param content composable for the destination
*/
@Deprecated(
message = "Deprecated in favor of composable builder that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit,
) {
addDestination(
ComposeNavigator.Destination(provider[ComposeNavigator::class]) {
entry ->
content(entry)
}.apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
/**
* Add the [Composable] to the [NavGraphBuilder]
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param enterTransition callback to determine the destination's enter transition
* @param exitTransition callback to determine the destination's exit transition
* @param popEnterTransition callback to determine the destination's popEnter transition
* @param popExitTransition callback to determine the destination's popExit transition
* @param content composable for the destination
*/
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = null,
exitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = null,
popEnterTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? =
enterTransition,
popExitTransition: (
@JvmSuppressWildcards
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? =
exitTransition,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
addDestination(
ComposeNavigator.Destination(
provider[ComposeNavigator::class],
content,
).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
this.enterTransition = enterTransition
this.exitTransition = exitTransition
this.popEnterTransition = popEnterTransition
this.popExitTransition = popExitTransition
},
)
}
/**
* Construct a nested [NavGraph]
*
* @sample androidx.navigation.compose.samples.NestedNavInGraphWithArgs
*
* @param startDestination the starting destination's route for this NavGraph
* @param route the destination's unique route
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param builder the builder used to construct the graph
*/
@Deprecated(
message = "Deprecated in favor of navigation builder that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
public fun NavGraphBuilder.navigation(
startDestination: String,
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
builder: NavGraphBuilder.() -> Unit,
) {
addDestination(
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
/**
* Construct a nested [NavGraph]
*
* @param startDestination the starting destination's route for this NavGraph
* @param route the destination's unique route
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param enterTransition callback to define enter transitions for destination in this NavGraph
* @param exitTransition callback to define exit transitions for destination in this NavGraph
* @param popEnterTransition callback to define pop enter transitions for destination in this
* NavGraph
* @param popExitTransition callback to define pop exit transitions for destination in this NavGraph
* @param builder the builder used to construct the graph
*
* @return the newly constructed nested NavGraph
*/
public fun NavGraphBuilder.navigation(
startDestination: String,
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
null,
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
null,
popEnterTransition: (
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
)? = enterTransition,
popExitTransition: (
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
)? = exitTransition,
builder: NavGraphBuilder.() -> Unit,
) {
addDestination(
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
if (this is ComposeNavGraphNavigator.ComposeNavGraph) {
this.enterTransition = enterTransition
this.exitTransition = exitTransition
this.popEnterTransition = popEnterTransition
this.popExitTransition = popExitTransition
}
},
)
}
/**
* Add the [Composable] to the [NavGraphBuilder] that will be hosted within a
* [androidx.compose.ui.window.Dialog]. This is suitable only when this dialog represents
* a separate screen in your app that needs its own lifecycle and saved state, independent
* of any other destination in your navigation graph. For use cases such as `AlertDialog`,
* you should use those APIs directly in the [composable] destination that wants to show that
* dialog.
*
* @param route route for the destination
* @param arguments list of arguments to associate with destination
* @param deepLinks list of deep links to associate with the destinations
* @param dialogProperties properties that should be passed to [androidx.compose.ui.window.Dialog].
* @param content composable content for the destination that will be hosted within the Dialog
*/
public fun NavGraphBuilder.dialog(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
dialogProperties: DialogProperties = DialogProperties(),
content: @Composable (NavBackStackEntry) -> Unit,
) {
addDestination(
DialogNavigator.Destination(
provider[DialogNavigator::class],
dialogProperties,
content,
).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
},
)
}
@@ -0,0 +1,424 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.navigation.compose
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
import androidx.navigation.createGraph
import androidx.navigation.get
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import kotlin.math.roundToLong
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
* @sample androidx.navigation.compose.samples.NavScaffold
*
* @param navController the navController for this host
* @param startDestination the route for the start destination
* @param modifier The modifier to be applied to the layout.
* @param route the route for the graph
* @param builder the builder used to construct the graph
*/
@Deprecated(
message = "Deprecated in favor of NavHost that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit,
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
},
modifier,
)
}
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
* contents of the builder cannot be changed.
*
* @param navController the navController for this host
* @param startDestination the route for the start destination
* @param modifier The modifier to be applied to the layout.
* @param contentAlignment The [Alignment] of the [AnimatedContent]
* @param route the route for the graph
* @param enterTransition callback to define enter transitions for destination in this host
* @param exitTransition callback to define exit transitions for destination in this host
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
* @param builder the builder used to construct the graph
*/
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
route: String? = null,
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
builder: NavGraphBuilder.() -> Unit,
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, builder)
},
modifier,
contentAlignment,
enterTransition,
exitTransition,
popEnterTransition,
popExitTransition,
)
}
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* The graph passed into this method is [remember]ed. This means that for this NavHost, the graph
* cannot be changed.
*
* @param navController the navController for this host
* @param graph the graph for this host
* @param modifier The modifier to be applied to the layout.
*/
@Deprecated(
message = "Deprecated in favor of NavHost that supports AnimatedContent",
level = DeprecationLevel.HIDDEN,
)
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
) = NavHost(navController, graph, modifier)
/**
* Provides in place in the Compose hierarchy for self contained navigation to occur.
*
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
* the provided [navController].
*
* @param navController the navController for this host
* @param graph the graph for this host
* @param modifier The modifier to be applied to the layout.
* @param contentAlignment The [Alignment] of the [AnimatedContent]
* @param enterTransition callback to define enter transitions for destination in this host
* @param exitTransition callback to define exit transitions for destination in this host
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
*/
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
public fun NavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
// Then set the graph
navController.graph = graph
// Find the ComposeNavigator, returning early if it isn't found
// (such as is the case when using TestNavHostController)
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
ComposeNavigator.NAME,
) as? ComposeNavigator ?: return
val currentBackStack by composeNavigator.backStack.collectAsState()
DisposableEffect(lifecycleOwner) {
// Setup the navController with proper owners
navController.setLifecycleOwner(lifecycleOwner)
onDispose { }
}
val saveableStateHolder = rememberSaveableStateHolder()
val allVisibleEntries by navController.visibleEntries.collectAsState()
// Intercept back only when there's a destination to pop
val visibleEntries by remember {
derivedStateOf {
allVisibleEntries.filter { entry ->
entry.destination.navigatorName == ComposeNavigator.NAME
}
}
}
val backStackEntry: NavBackStackEntry? = visibleEntries.lastOrNull()
val zIndices = remember { mutableMapOf<String, Float>() }
if (backStackEntry != null) {
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
val targetDestination = targetState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopEnterTransition(this)
} ?: popEnterTransition.invoke(this)
} else {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createEnterTransition(this)
} ?: enterTransition.invoke(this)
}
}
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
val initialDestination = initialState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopExitTransition(this)
} ?: popExitTransition.invoke(this)
} else {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createExitTransition(this)
} ?: exitTransition.invoke(this)
}
}
DisposableEffect(true) {
onDispose {
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
}
}
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
val transition = if (enablePredictiveBack) {
val duration = 500000000L
val transitionState = remember { MutableTransitionState(backStackEntry) }
val transition = updateTransition(transitionState = transitionState, label = "entry")
val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } }
fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) {
val fraction = animatedFraction.value
val playTimeNanos = (fraction * duration).roundToLong()
transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos)
}
LaunchedEffect(backStackEntry) {
try {
animatedFraction.animateTo(1f, animationSpec = tween(500, easing = LinearEasing)) {
seekToFraction(transitionState.currentState, backStackEntry)
}
} finally {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
PredictiveBackHandler(currentBackStack.size > 1) { flow ->
// Prev can never be null, i.e. BackHandler should not enabled when prev is null
val prev = currentBackStack.last { it != backStackEntry }
try {
flow.collect {
val interpolated = EaseOut.transform(it.progress)
animatedFraction.snapTo(interpolated)
seekToFraction(backStackEntry, prev)
}
navController.popBackStack()
} catch (e: CancellationException) {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
transition
} else {
BackHandler(currentBackStack.size > 1) {
navController.popBackStack()
}
updateTransition(backStackEntry, label = "entry")
}
transition.AnimatedContent(
modifier,
transitionSpec = {
// If the initialState of the AnimatedContent is not in visibleEntries, we are in
// a case where visible has cleared the old state for some reason, so instead of
// attempting to animate away from the initialState, we skip the animation.
if (initialState in visibleEntries) {
val initialZIndex = zIndices[initialState.id]
?: 0f.also { zIndices[initialState.id] = 0f }
val targetZIndex = when {
targetState.id == initialState.id -> initialZIndex
composeNavigator.isPop.value -> initialZIndex - 1f
else -> initialZIndex + 1f
}.also { zIndices[targetState.id] = it }
ContentTransform(finalEnter(this), finalExit(this), targetZIndex)
} else {
EnterTransition.None togetherWith ExitTransition.None
}
},
contentAlignment,
contentKey = { it.id },
) {
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
it.LocalOwnersProvider(saveableStateHolder) {
(it.destination as ComposeNavigator.Destination)
.content(this, it)
}
}
LaunchedEffect(transition.currentState, transition.targetState) {
if (transition.currentState == transition.targetState) {
visibleEntries.forEach { entry ->
composeNavigator.onTransitionComplete(entry)
}
zIndices
.filter { it.key != transition.targetState.id }
.forEach { zIndices.remove(it.key) }
}
}
}
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
DialogNavigator.NAME,
) as? DialogNavigator ?: return
// Show any dialog destinations
DialogHost(dialogNavigator)
}
private fun NavDestination.createEnterTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): EnterTransition? = when (this) {
is ComposeNavigator.Destination -> this.enterTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.enterTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createExitTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): ExitTransition? = when (this) {
is ComposeNavigator.Destination -> this.exitTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.exitTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createPopEnterTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): EnterTransition? = when (this) {
is ComposeNavigator.Destination -> this.popEnterTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popEnterTransition?.invoke(scope)
else -> null
}
private fun NavDestination.createPopExitTransition(
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
): ExitTransition? = when (this) {
is ComposeNavigator.Destination -> this.popExitTransition?.invoke(scope)
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popExitTransition?.invoke(scope)
else -> null
}
@@ -0,0 +1,86 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.navigation.compose
import android.content.Context
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
/**
* Gets the current navigation back stack entry as a [MutableState]. When the given navController
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
* will trigger a recompose and return the top entry on the back stack.
*
* @return a mutable state of the current back stack entry
*/
@Composable
public fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?> {
return currentBackStackEntryFlow.collectAsState(null)
}
/**
* Creates a NavHostController that handles the adding of the [ComposeNavigator] and
* [DialogNavigator]. Additional [Navigator] instances can be passed through [navigators] to
* be applied to the returned NavController. Note that each [Navigator] must be separately
* remembered before being passed in here: any changes to those inputs will cause the
* NavController to be recreated.
*
* @see NavHost
*/
@Composable
public fun rememberNavController(
vararg navigators: Navigator<out NavDestination>,
): NavHostController {
val context = LocalContext.current
return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) {
createNavController(context)
}.apply {
for (navigator in navigators) {
navigatorProvider.addNavigator(navigator)
}
}
}
private fun createNavController(context: Context) =
NavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavGraphNavigator(navigatorProvider))
navigatorProvider.addNavigator(ComposeNavigator())
navigatorProvider.addNavigator(DialogNavigator())
}
/**
* Saver to save and restore the NavController across config change and process death.
*/
private fun NavControllerSaver(
context: Context,
): Saver<NavHostController, *> = Saver<NavHostController, Bundle>(
save = { it.saveState() },
restore = { createNavController(context).apply { restoreState(it) } },
)
@@ -1,647 +0,0 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.accompanist.web
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.view.ViewGroup.LayoutParams
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.web.LoadingState.Finished
import com.google.accompanist.web.LoadingState.Loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
BoxWithConstraints(modifier) {
// WebView changes it's layout strategy based on
// it's layoutParams. We convert from Compose Modifier to
// layout params here.
val width =
if (constraints.hasFixedWidth) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val height =
if (constraints.hasFixedHeight) {
LayoutParams.MATCH_PARENT
} else {
LayoutParams.WRAP_CONTENT
}
val layoutParams = FrameLayout.LayoutParams(
width,
height,
)
WebView(
state,
layoutParams,
Modifier,
captureBackPresses,
navigator,
onCreated,
onDispose,
client,
chromeClient,
factory,
)
}
}
/**
* A wrapper around the Android View WebView to provide a basic WebView composable.
*
* If you require more customisation you are most likely better rolling your own and using this
* wrapper as an example.
*
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
* is incorrectly sizing, use the layoutParams composable function instead.
*
* @param state The webview state holder where the Uri to load is defined.
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
* @param modifier A compose modifier
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
* the WebView back.
* @param navigator An optional navigator object that can be used to control the WebView's
* navigation from outside the composable.
* @param onCreated Called when the WebView is first created, this can be used to set additional
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
* subsequently overwritten after this lambda is called.
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
* if you need to save and restore state in this WebView.
* @param client Provides access to WebViewClient via subclassing
* @param chromeClient Provides access to WebChromeClient via subclassing
* @param factory An optional WebView factory for using a custom subclass of WebView
*/
@Composable
fun WebView(
state: WebViewState,
layoutParams: FrameLayout.LayoutParams,
modifier: Modifier = Modifier,
captureBackPresses: Boolean = true,
navigator: WebViewNavigator = rememberWebViewNavigator(),
onCreated: (WebView) -> Unit = {},
onDispose: (WebView) -> Unit = {},
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
factory: ((Context) -> WebView)? = null,
) {
val webView = state.webView
BackHandler(captureBackPresses && navigator.canGoBack) {
webView?.goBack()
}
webView?.let { wv ->
LaunchedEffect(wv, navigator) {
with(navigator) {
wv.handleNavigationEvents()
}
}
LaunchedEffect(wv, state) {
snapshotFlow { state.content }.collect { content ->
when (content) {
is WebContent.Url -> {
wv.loadUrl(content.url, content.additionalHttpHeaders)
}
is WebContent.Data -> {
wv.loadDataWithBaseURL(
content.baseUrl,
content.data,
content.mimeType,
content.encoding,
content.historyUrl,
)
}
is WebContent.Post -> {
wv.postUrl(
content.url,
content.postData,
)
}
is WebContent.NavigatorOnly -> {
// NO-OP
}
}
}
}
}
// Set the state of the client and chrome client
// This is done internally to ensure they always are the same instance as the
// parent Web composable
client.state = state
client.navigator = navigator
chromeClient.state = state
AndroidView(
factory = { context ->
(factory?.invoke(context) ?: WebView(context)).apply {
onCreated(this)
this.layoutParams = layoutParams
state.viewState?.let {
this.restoreState(it)
}
webChromeClient = chromeClient
webViewClient = client
}.also { state.webView = it }
},
modifier = modifier,
onRelease = {
onDispose(it)
},
)
}
/**
* AccompanistWebViewClient
*
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebViewClient : WebViewClient() {
open lateinit var state: WebViewState
internal set
open lateinit var navigator: WebViewNavigator
internal set
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
state.loadingState = Loading(0.0f)
state.errorsForCurrentRequest.clear()
state.pageTitle = null
state.pageIcon = null
state.lastLoadedUrl = url
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
state.loadingState = Finished
}
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
navigator.canGoBack = view.canGoBack()
navigator.canGoForward = view.canGoForward()
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest?,
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
if (error != null) {
state.errorsForCurrentRequest.add(WebViewError(request, error))
}
}
}
/**
* AccompanistWebChromeClient
*
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
*
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
* class that can be overriden if further custom behaviour is required.
*/
open class AccompanistWebChromeClient : WebChromeClient() {
open lateinit var state: WebViewState
internal set
override fun onReceivedTitle(view: WebView, title: String?) {
super.onReceivedTitle(view, title)
state.pageTitle = title
}
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
super.onReceivedIcon(view, icon)
state.pageIcon = icon
}
override fun onProgressChanged(view: WebView, newProgress: Int) {
super.onProgressChanged(view, newProgress)
if (state.loadingState is Finished) return
state.loadingState = Loading(newProgress / 100.0f)
}
}
sealed class WebContent {
data class Url(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : WebContent()
data class Data(
val data: String,
val baseUrl: String? = null,
val encoding: String = "utf-8",
val mimeType: String? = null,
val historyUrl: String? = null,
) : WebContent()
data class Post(
val url: String,
val postData: ByteArray,
) : WebContent() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Post
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
data object NavigatorOnly : WebContent()
}
/**
* Sealed class for constraining possible loading states.
* See [Loading] and [Finished].
*/
sealed class LoadingState {
/**
* Describes a WebView that has not yet loaded for the first time.
*/
data object Initializing : LoadingState()
/**
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
* [progress] property which is updated by the webview.
*/
data class Loading(val progress: Float) : LoadingState()
/**
* Describes a webview that has finished loading content.
*/
data object Finished : LoadingState()
}
/**
* A state holder to hold the state for the WebView. In most cases this will be remembered
* using the rememberWebViewState(uri) function.
*/
@Stable
class WebViewState(webContent: WebContent) {
var lastLoadedUrl: String? by mutableStateOf(null)
internal set
/**
* The content being loaded by the WebView
*/
var content: WebContent by mutableStateOf(webContent)
/**
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
*/
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
internal set
/**
* Whether the webview is currently loading data in its main frame
*/
val isLoading: Boolean
get() = loadingState !is Finished
/**
* The title received from the loaded content of the current page
*/
var pageTitle: String? by mutableStateOf(null)
internal set
/**
* the favicon received from the loaded content of the current page
*/
var pageIcon: Bitmap? by mutableStateOf(null)
internal set
/**
* A list for errors captured in the last load. Reset when a new page is loaded.
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
* For more fine grained control use the OnError callback of the WebView.
*/
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
/**
* The saved view state from when the view was destroyed last. To restore state,
* use the navigator and only call loadUrl if the bundle is null.
* See WebViewSaveStateSample.
*/
var viewState: Bundle? = null
internal set
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
// onDestroy is called after the state saver and so can't be used.
internal var webView by mutableStateOf<WebView?>(null)
}
/**
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
*
* @see [rememberWebViewNavigator]
*/
@Stable
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
private sealed interface NavigationEvent {
data object Back : NavigationEvent
data object Forward : NavigationEvent
data object Reload : NavigationEvent
data object StopLoading : NavigationEvent
data class LoadUrl(
val url: String,
val additionalHttpHeaders: Map<String, String> = emptyMap(),
) : NavigationEvent
data class LoadHtml(
val html: String,
val baseUrl: String? = null,
val mimeType: String? = null,
val encoding: String? = "utf-8",
val historyUrl: String? = null,
) : NavigationEvent
data class PostUrl(
val url: String,
val postData: ByteArray,
) : NavigationEvent {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostUrl
if (url != other.url) return false
if (!postData.contentEquals(other.postData)) return false
return true
}
override fun hashCode(): Int {
var result = url.hashCode()
result = 31 * result + postData.contentHashCode()
return result
}
}
}
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
navigationEvents.collect { event ->
when (event) {
is NavigationEvent.Back -> goBack()
is NavigationEvent.Forward -> goForward()
is NavigationEvent.Reload -> reload()
is NavigationEvent.StopLoading -> stopLoading()
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
event.baseUrl,
event.html,
event.mimeType,
event.encoding,
event.historyUrl,
)
is NavigationEvent.LoadUrl -> {
loadUrl(event.url, event.additionalHttpHeaders)
}
is NavigationEvent.PostUrl -> {
postUrl(event.url, event.postData)
}
}
}
}
/**
* True when the web view is able to navigate backwards, false otherwise.
*/
var canGoBack: Boolean by mutableStateOf(false)
internal set
/**
* True when the web view is able to navigate forwards, false otherwise.
*/
var canGoForward: Boolean by mutableStateOf(false)
internal set
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadUrl(
url,
additionalHttpHeaders,
),
)
}
}
fun loadHtml(
html: String,
baseUrl: String? = null,
mimeType: String? = null,
encoding: String? = "utf-8",
historyUrl: String? = null,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.LoadHtml(
html,
baseUrl,
mimeType,
encoding,
historyUrl,
),
)
}
}
fun postUrl(
url: String,
postData: ByteArray,
) {
coroutineScope.launch {
navigationEvents.emit(
NavigationEvent.PostUrl(
url,
postData,
),
)
}
}
/**
* Navigates the webview back to the previous page.
*/
fun navigateBack() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
}
/**
* Navigates the webview forward after going back from a page.
*/
fun navigateForward() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
}
/**
* Reloads the current page in the webview.
*/
fun reload() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
}
/**
* Stops the current page load (if one is loading).
*/
fun stopLoading() {
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
}
}
/**
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
* override.
*/
@Composable
fun rememberWebViewNavigator(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
/**
* A wrapper class to hold errors from the WebView.
*/
@Immutable
data class WebViewError(
/**
* The request the error came from.
*/
val request: WebResourceRequest?,
/**
* The error that was reported.
*/
val error: WebResourceError,
)
/**
* Creates a WebView state that is remembered across Compositions.
*
* @param url The url to load in the WebView
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
* Note that these headers are used for all subsequent requests of the WebView.
*/
@Composable
fun rememberWebViewState(
url: String,
additionalHttpHeaders: Map<String, String> = emptyMap(),
): WebViewState = // Rather than using .apply {} here we will recreate the state, this prevents
// a recomposition loop when the webview updates the url itself.
remember {
WebViewState(
WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
),
)
}.apply {
this.content = WebContent.Url(
url = url,
additionalHttpHeaders = additionalHttpHeaders,
)
}
@@ -17,137 +17,112 @@
package com.hippo.ehviewer
import android.app.Application
import android.content.Context
import android.os.StrictMode
import android.content.ComponentCallbacks2
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composer
import androidx.compose.runtime.tooling.ComposeStackTraceMode
import androidx.collection.LruCache
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.EventListener
import coil3.SingletonImageLoader
import coil3.asImage
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.ConnectivityChecker
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.ehviewer.core.database.SearchDatabase
import com.ehviewer.core.database.roomDb
import com.ehviewer.core.files.deleteContent
import com.ehviewer.core.ui.util.initSETConnection
import com.ehviewer.core.util.isAtLeastO
import com.ehviewer.core.util.isAtLeastP
import com.ehviewer.core.util.isAtLeastS
import com.ehviewer.core.util.launchIO
import com.ehviewer.core.util.logcat
import com.ehviewer.core.util.withUIContext
import coil.ImageLoaderFactory
import coil.decode.ImageDecoderDecoder
import coil.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.coil.AnimatedWebPDecoder
import com.hippo.ehviewer.coil.CropBorderInterceptor
import com.hippo.ehviewer.coil.DetectBorderInterceptor
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.HardwareBitmapInterceptor
import com.hippo.ehviewer.coil.MapExtraInfoInterceptor
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.QrCodeInterceptor
import com.hippo.ehviewer.coil.installCronetHttpUriFetcher
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.cache
import com.hippo.ehviewer.ktbuilder.chunker
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.httpClient
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.Cronet
import com.hippo.ehviewer.ktor.configureClient
import com.hippo.ehviewer.ktor.configureCommon
import com.hippo.ehviewer.ktor.isCronetAvailable
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.legacy.migrateCookies
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.screen.detailCache
import com.hippo.ehviewer.ui.tools.dataStateFlow
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.CrashHandler
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.OSUtils
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import kotlinx.coroutines.Dispatchers
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isCronetSupported
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.launch
import logcat.AndroidLogcatLogger
import logcat.LogPriority
import logcat.LogcatLogger
import logcat.asLog
import moe.tarsin.kt.unreachable
import okhttp3.AsyncDns
import okhttp3.android.AndroidAsyncDns
import okio.Path.Companion.toOkioPath
import splitties.arch.room.roomDb
import splitties.init.appCtx
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
private val lifecycleScope = lifecycle.coroutineScope
class EhApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() = with(lifecycleScope) {
initSETConnection()
class EhApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
// Initialize Settings on first access
launchIO {
val mode = Settings.theme.value
lifecycleScope.launchIO {
val mode = Settings.theme
if (!isAtLeastS) {
withUIContext {
AppCompatDelegate.setDefaultNightMode(mode)
}
}
LogcatLogger.loggers += AndroidLogcatLogger(LogPriority.VERBOSE)
Settings.saveCrashLog.valueFlow().collect {
if (it) {
LogcatLogger.install()
} else {
LogcatLogger.uninstall()
}
}
}
lifecycle.addObserver(lockObserver)
CrashHandler.install()
val handler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { t, e ->
try {
if (Settings.saveCrashLog) {
Crash.saveCrashLog(e)
}
} catch (ignored: Throwable) {
}
handler?.uncaughtException(t, e)
}
super.onCreate()
System.loadLibrary("ehviewer")
launch {
FavouriteStatusRouter.collect { info ->
detailCache[info.gid]?.apply {
favoriteSlot = info.favoriteSlot
favoriteName = info.favoriteName
favoriteNote = info.favoriteNote
if (isAtLeastP) {
System.loadLibrary("ehviewer")
}
System.loadLibrary("ehviewer_rust")
ReadableTime.initialize(this)
lifecycleScope.launchIO {
launchIO {
migrateCookies()
}
launchIO {
EhTagDatabase
}
launchIO {
EhDB
}
launchIO {
DownloadManager.isIdle
}
launchIO {
cleanupDownload()
}
if (Settings.requestNews) {
launchIO {
checkDawn()
}
}
}
launchIO {
EhTagDatabase.launchUpdate()
launch { EhDB }
launch { dataStateFlow.value }
launch { OSUtils.totalMemory }
launch {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode !in Settings.snapshot()) {
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
}
initialized = true
DownloadManager.readMetadataFromLocal()
}
launch {
FileUtils.cleanupDirectory(AppConfig.externalCrashDir)
FileUtils.cleanupDirectory(AppConfig.externalParseErrorDir)
}
launch { cleanupDownload() }
if (Settings.requestNews.value) {
launch { checkDawn() }
}
}
cleanObsoleteCache(this)
if (BuildConfig.DEBUG) {
StrictMode.enableDefaults()
Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.SourceInformation)
} else {
Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.Auto)
Snapshot.registerApplyObserver { anies, _ ->
logcat { anies.toString() }
}
}
}
@@ -155,94 +130,94 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
runCatching {
keepNoMediaFileStatus()
}.onFailure {
logcat(it)
it.printStackTrace()
}
runCatching {
clearTempDir()
}.onFailure {
logcat(it)
it.printStackTrace()
}
}
private fun clearTempDir() {
AppConfig.tempDir.deleteContent()
AppConfig.externalTempDir?.deleteContent()
var dir = AppConfig.tempDir
if (null != dir) {
FileUtils.deleteContent(dir)
}
dir = AppConfig.externalTempDir
if (null != dir) {
FileUtils.deleteContent(dir)
}
}
override fun newImageLoader(context: Context) = context.imageLoader {
interceptorCoroutineContext(Dispatchers.Default)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
galleryDetailCache.evictAll()
}
}
override fun newImageLoader() = imageLoader {
components {
serviceLoaderEnabled(false)
add(
KtorNetworkFetcherFactory(
httpClient = { ktorClient },
connectivityChecker = { ConnectivityChecker.ONLINE },
),
)
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastO) {
add(HardwareBitmapInterceptor)
if (isCronetSupported) {
callFactory { unreachable() }
installCronetHttpUriFetcher()
} else {
allowRgb565(true)
callFactory(baseOkHttpClient)
}
add(CropBorderInterceptor)
add(DetectBorderInterceptor)
add(QrCodeInterceptor)
add(MapExtraInfoInterceptor)
add(AnimatedWebPDecoder.Factory)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
} else {
add(GifDecoder.Factory())
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
add(MergeInterceptor)
}
diskCache { imageCache }
diskCache(imageCache)
crossfade(300)
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
if (drawable != null) error(drawable.asImage(true))
if (BuildConfig.DEBUG) {
logger(DebugLogger())
} else {
eventListener(object : EventListener() {
override fun onError(request: ImageRequest, result: ErrorResult) {
logcat("ImageLoader", LogPriority.ERROR) {
"🚨 Failed - ${request.data}\n${result.throwable.asLog()}"
}
}
})
}
error(R.drawable.image_failed)
if (BuildConfig.DEBUG) logger(DebugLogger())
respectCacheHeaders(false)
}
companion object {
@Volatile
var initialized = false
private set
val ktorClient by lazy {
if (isCronetAvailable && Settings.enableCronet.value) {
HttpClient(Cronet) {
engine { configureClient(Settings.enableQuic.value) }
configureCommon()
}
} else {
HttpClient(OkHttp) {
engine { configureClient() }
configureCommon()
val baseOkHttpClient by lazy {
httpClient {
cookieJar(EhCookieStore)
if (isAtLeastQ) {
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
}
chunker { alwaysReadResponseBody(false) }
addInterceptor(UncaughtExceptionInterceptor())
addInterceptor(CloudflareInterceptor(appCtx))
}
}
val noRedirectKtorClient by lazy {
HttpClient(ktorClient.engine) {
configureCommon(redirect = false)
val noRedirectOkHttpClient by lazy {
httpClient(baseOkHttpClient) {
followRedirects(false)
}
}
// Never use this okhttp client to download large blobs!!!
val okHttpClient by lazy {
httpClient(baseOkHttpClient) {
cache(
appCtx.cacheDir.toOkioPath() / "http_cache",
20L * 1024L * 1024L,
)
}
}
val galleryDetailCache by lazy {
LruCache<Long, GalleryDetail>(25).also {
lifecycleScope.launch {
FavouriteStatusRouter.globalFlow.collect { (gid, slot) -> it[gid]?.favoriteSlot = slot }
}
}
}
val imageCache by lazy {
diskCache {
directory(appCtx.cacheDir.toOkioPath() / "image_cache")
maxSizeBytes(Settings.readCacheSize.value.coerceIn(320, 5120).toLong() * 1024 * 1024)
maxSizeBytes(Settings.readCacheSize.coerceIn(320, 5120).toLong() * 1024 * 1024)
}
}
+104 -118
View File
@@ -15,62 +15,44 @@
*/
package com.hippo.ehviewer
import androidx.room.execSQL
import androidx.room.useWriterConnection
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import arrow.fx.coroutines.resourceScope
import com.ehviewer.core.data.model.asEntity
import com.ehviewer.core.database.EhDatabase
import com.ehviewer.core.database.Schema17to18
import com.ehviewer.core.database.getDatabasePath
import com.ehviewer.core.database.model.DownloadArtist
import com.ehviewer.core.database.model.DownloadDirname
import com.ehviewer.core.database.model.DownloadInfo
import com.ehviewer.core.database.model.DownloadLabel
import com.ehviewer.core.database.model.Filter
import com.ehviewer.core.database.model.GalleryEntity
import com.ehviewer.core.database.model.HistoryInfo
import com.ehviewer.core.database.model.LocalFavoriteInfo
import com.ehviewer.core.database.model.ProgressInfo
import com.ehviewer.core.database.model.QuickSearch
import com.ehviewer.core.database.roomDb
import com.ehviewer.core.files.delete
import com.ehviewer.core.files.sendTo
import com.ehviewer.core.model.GalleryInfo
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadDirname
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
import com.hippo.ehviewer.dao.EhDatabase
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.HistoryInfo
import com.hippo.ehviewer.dao.LocalFavoriteInfo
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import kotlinx.coroutines.flow.Flow
import okio.Path
import splitties.arch.room.roomDb
object EhDB {
private const val DB_NAME = "eh.db"
private val db = roomDb<EhDatabase>(DB_NAME) {
addMigrations(Schema17to18())
}
private val db = roomDb<EhDatabase>("eh.db")
suspend fun putGalleryInfo(galleryInfo: GalleryEntity) {
private suspend fun putGalleryInfo(galleryInfo: BaseGalleryInfo) {
db.galleryDao().upsert(galleryInfo)
}
private suspend fun deleteGalleryInfo(galleryInfo: GalleryEntity) {
private suspend fun deleteGalleryInfo(galleryInfo: BaseGalleryInfo) {
runCatching { db.galleryDao().delete(galleryInfo) }
}
suspend fun updateGalleryInfo(galleryInfoList: List<GalleryEntity>) {
db.galleryDao().update(galleryInfoList)
}
fun getReadProgressFlow(gid: Long) = db.progressDao().getPageFlow(gid)
suspend fun getReadProgress(gid: Long) = db.progressDao().getPage(gid)
suspend fun putReadProgress(gid: Long, page: Int) = db.progressDao().upsert(ProgressInfo(gid, page))
suspend fun clearProgressInfo() = db.progressDao().deleteAll()
suspend fun getAllDownloadInfo() = db.downloadsDao().joinList().onEach {
if (it.state == DownloadInfo.STATE_WAIT || it.state == DownloadInfo.STATE_DOWNLOAD) {
it.state = DownloadInfo.STATE_NONE
}
}
suspend fun updateDownloadInfo(downloadInfo: Collection<DownloadInfo>) {
suspend fun updateDownloadInfo(downloadInfo: List<DownloadInfo>) {
val dao = db.downloadsDao()
dao.update(downloadInfo.map(DownloadInfo::downloadInfo))
}
@@ -83,17 +65,17 @@ object EhDB {
suspend fun removeDownloadInfo(downloadInfo: DownloadInfo) {
val dao = db.downloadsDao()
dao.delete(downloadInfo.downloadInfo)
dao.fill(downloadInfo.position)
deleteGalleryInfo(downloadInfo.galleryInfo)
}
suspend fun randomLocalFav() = db.localFavoritesDao().random()
suspend fun removeDownloadInfo(downloadInfo: List<DownloadInfo>) {
val dao = db.downloadsDao()
downloadInfo.forEach {
dao.delete(it.downloadInfo)
deleteGalleryInfo(it.galleryInfo)
}
dao.fill(downloadInfo.first().position, downloadInfo.size)
}
suspend fun getDownloadDirname(gid: Long): String? {
@@ -117,12 +99,6 @@ object EhDB {
dao.insertOrIgnore(downloadDirnameList)
}
val downloadsCountByLabel
get() = db.downloadsDao().countByLabel()
val downloadsCountByArtist
get() = db.downloadsDao().countByArtist()
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
suspend fun addDownloadLabel(raw: DownloadLabel): DownloadLabel {
@@ -149,22 +125,12 @@ object EhDB {
dao.fill(raw.position)
}
suspend fun searchDownloadLabel(keyword: String, limit: Int) = db.downloadLabelDao().search("%$keyword%", limit)
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insertOrIgnore(artists)
}
}
suspend fun removeLocalFavorites(galleryInfo: GalleryInfo) {
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
deleteGalleryInfo(galleryInfo.asEntity())
deleteGalleryInfo(galleryInfo)
}
suspend fun removeLocalFavorites(galleryInfoList: Collection<GalleryInfo>) {
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
removeLocalFavorites(it)
}
@@ -175,12 +141,12 @@ object EhDB {
return dao.contains(gid)
}
suspend fun putLocalFavorites(galleryInfo: GalleryInfo) {
putGalleryInfo(galleryInfo.asEntity())
suspend fun putLocalFavorites(galleryInfo: BaseGalleryInfo) {
putGalleryInfo(galleryInfo)
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
}
suspend fun putLocalFavorites(galleryInfoList: Collection<GalleryInfo>) {
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
putLocalFavorites(it)
}
@@ -216,18 +182,16 @@ object EhDB {
val historyLazyList
get() = db.historyDao().joinListLazy()
fun searchHistory(keyword: String) = db.historyDao().joinListLazy("*$keyword*")
val localFavLazyList
get() = db.localFavoritesDao().joinListLazy()
val localFavCount: Flow<Int>
get() = db.localFavoritesDao().count()
fun searchLocalFav(keyword: String) = db.localFavoritesDao().joinListLazy("*$keyword*")
fun searchLocalFav(keyword: String) = db.localFavoritesDao().joinListLazy("%$keyword%")
suspend fun putHistoryInfo(galleryInfo: GalleryInfo) {
putGalleryInfo(galleryInfo.asEntity())
suspend fun putHistoryInfo(galleryInfo: BaseGalleryInfo) {
putGalleryInfo(galleryInfo)
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
}
@@ -244,7 +208,7 @@ object EhDB {
dao.insertOrIgnore(historyInfoList)
}
suspend fun deleteHistoryInfo(galleryInfo: GalleryEntity) {
suspend fun deleteHistoryInfo(galleryInfo: BaseGalleryInfo) {
val dao = db.historyDao()
dao.deleteByKey(galleryInfo.gid)
deleteGalleryInfo(galleryInfo)
@@ -278,61 +242,83 @@ object EhDB {
db.filterDao().update(filter)
}
suspend fun exportDB(file: Path) {
db.useWriterConnection { conn ->
conn.execSQL("PRAGMA wal_checkpoint(FULL)")
conn.execSQL("VACUUM")
suspend fun exportDB(context: Context, uri: Uri) {
val ehExportName = "eh.export.db"
resource {
context.deleteDatabase(ehExportName)
roomDb<EhDatabase>(ehExportName)
} release {
it.close()
context.deleteDatabase(ehExportName)
} use { newDb ->
db.galleryDao().list().let { newDb.galleryDao().insertOrIgnore(it) }
db.downloadLabelDao().list().let { newDb.downloadLabelDao().insert(it) }
db.downloadDirnameDao().list().let { newDb.downloadDirnameDao().insertOrIgnore(it) }
db.downloadsDao().list().let { newDb.downloadsDao().insert(it) }
db.historyDao().list().let { newDb.historyDao().insertOrIgnore(it) }
db.quickSearchDao().list().let { newDb.quickSearchDao().insert(it) }
db.localFavoritesDao().list().let { newDb.localFavoritesDao().insertOrIgnore(it) }
db.filterDao().list().let { newDb.filterDao().insert(it) }
newDb.close()
val dbFile = context.getDatabasePath(ehExportName)
context.contentResolver.openFileDescriptor(uri, "rw")!!.use { toFd ->
ParcelFileDescriptor.open(dbFile, MODE_READ_ONLY).use { fromFd ->
fromFd sendTo toFd
}
}
}
val dbFile = getDatabasePath(DB_NAME)
dbFile sendTo file
}
suspend fun importDB(file: Path) = resourceScope {
suspend fun importDB(context: Context, uri: Uri) {
val tempDBName = "tmp.db"
val dbFile = getDatabasePath(tempDBName)
file sendTo dbFile
val oldDB = resource {
roomDb<EhDatabase>(tempDBName) {
addMigrations(Schema17to18())
resource {
context.deleteDatabase(tempDBName)
roomDb<EhDatabase>(tempDBName) { createFromInputStream { context.contentResolver.openInputStream(uri) } }
} release {
it.close()
context.deleteDatabase(tempDBName)
} use { oldDB ->
runCatching {
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
}
runCatching {
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
}
runCatching {
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
}
runCatching {
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
}
runCatching {
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
}
runCatching {
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
}
runCatching {
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
}
runCatching {
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
}
} release { db ->
db.close()
dbFile.delete()
}
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q ->
q.id = null
q.position = index + offset
}
importQuickSearch(importList)
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
oldDB.filterDao().list().forEach {
addFilter(it)
}
}
}
+119 -139
View File
@@ -9,16 +9,9 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.ehviewer.core.network.EhCookieStore
import com.ehviewer.core.preferences.DataStorePreferences
import com.ehviewer.core.preferences.PrefDelegate
import com.ehviewer.core.preferences.edit
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.download.SortMode
import com.hippo.ehviewer.util.AppConfig
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
import kotlin.reflect.KProperty
import kotlinx.coroutines.flow.Flow
@@ -28,12 +21,47 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.map
import splitties.preferences.BoolPref
import splitties.preferences.DataStorePreferences
import splitties.preferences.FloatPref
import splitties.preferences.IntPref
import splitties.preferences.LongPref
import splitties.preferences.PrefDelegate
import splitties.preferences.StringOrNullPref
import splitties.preferences.StringPref
import splitties.preferences.StringSetOrNullPref
import splitties.preferences.StringSetPref
import splitties.preferences.edit
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
is BoolPref -> value
is IntPref -> value
is FloatPref -> value
is LongPref -> value
is StringPref -> value
is StringOrNullPref -> value
is StringSetPref -> value
is StringSetOrNullPref -> value
} as T
@Suppress("UNCHECKED_CAST")
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
is BoolPref -> value = newValue as Boolean
is IntPref -> value = newValue as Int
is FloatPref -> value = newValue as Float
is LongPref -> value = newValue as Long
is StringPref -> value = newValue as String
is StringOrNullPref -> value = newValue as? String
is StringSetPref -> value = newValue as Set<String?>
is StringSetOrNullPref -> value = newValue as? Set<String?>
}
@Stable
@Composable
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
val flow = remember { valueFlow().map { transform(it) } }
val init = value
val init = getValue()
return flow.collectAsState(transform(init))
}
@@ -41,190 +69,142 @@ inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @Disallo
@Composable
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
val flow = remember { valueFlow() }
val init = value
val init = getValue()
return flow.collectAsState(init)
}
@Stable
@Composable
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
val readOnly = collectAsState()
val flow = remember { valueFlow() }
val init = getValue()
val readOnly = flow.collectAsState(init)
return remember {
object : MutableState<T> {
override var value: T
get() = readOnly.value
set(value) {
this@asMutableState.value = value
setValue(value)
}
override fun component1() = readOnly.value
override fun component2() = this@asMutableState::value::set
override fun component1() = TODO()
override fun component2() = TODO()
}
}
}
object Settings : DataStorePreferences(null) {
@Suppress("ktlint:standard:backing-property-naming")
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
private val _favFlow = MutableSharedFlow<Unit>()
val favChangesFlow = _favFlow.debounce(1000)
var favCat by stringArrayPref("fav_cat", 10, "Favorites").emitTo(_favFlow)
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
// Eh
val gallerySite = intPref("gallery_site_2", 0).observed(::updateWhenGallerySiteChanges)
val defaultFavSlot = intPref("default_favorite_slot", -2)
val theme = intPref("theme_2", -1).observed(::updateWhenThemeChanges)
val blackDarkTheme = boolPref("black_dark_theme", false)
val harmonizeCategoryColor = boolPref("harmonize_category_color", true)
val launchPage = intPref("launch_page_2", 0)
val listMode = intPref("list_mode_2", 0)
val listThumbSize = intPref("list_tile_size", 40)
val detailSize = intPref("detail_size_2", 0)
val thumbColumns = intPref("thumb_columns", 3)
val showGalleryPages = boolPref("show_gallery_pages", true)
val showReadingProgress = boolPref("show_reading_progress", false)
val showVoteStatus = boolPref("show_vote_status", false)
val showComments = boolPref("show_gallery_comments", true)
val commentThreshold = intPref("comment_threshold", -100)
val showTagTranslations = boolPref("show_tag_translations", false).observed(::updateWhenTagTranslationChanges)
val listThumbSize = intPref("list_tile_size", 40)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val showJpnTitle = boolPref("show_jpn_title", false)
val requestNews = boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
val hideHvEvents = boolPref("hide_hv_events", false)
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
// Download
val mediaScan = boolPref("media_scan", false).observed(::updateWhenKeepMediaStatusChanges)
val multiThreadDownload = intPref("download_thread_2", 3)
val downloadDelay = intPref("download_delay_3", 1000)
val timeoutSpeed = intPref("timeout_speed_level", 6)
val preloadImage = intPref("preload_image_2", 5)
val downloadOriginImage = boolPref("download_origin_image", false)
val saveAsCbz = boolPref("save_as_cbz", false)
val archiveMetadata = boolPref("archive_metadata", true)
// Privacy
val security = boolPref("require_unlock", false)
val securityDelay = intPref("require_unlock_delay", 0)
val enabledSecurity = boolPref("enable_secure", false)
// Advanced
val saveParseErrorBody = boolPref("save_parse_error_body", true)
val saveCrashLog = boolPref("save_crash_log", true)
val readCacheSize = intPref("read_cache_size_2", 640)
val enableCronet = boolPref("enable_cronet", true)
val enableQuic = boolPref("enable_quic", true)
val hardwareBitmapThreshold = intPref("hardware_bitmap_threshold", 16384)
val preloadThumbAggressively = boolPref("preload_thumb_aggressively", false)
val animateItems = boolPref("animate_items", true)
val desktopSite = boolPref("desktop_site", true)
// About
val backupBeforeUpdate = boolPref("backup_before_update", false)
val useCIUpdateChannel = boolPref("ci_update_channel", AppConfig.isSnapshot)
val updateIntervalDays = intPref("update_interval_days", 7)
// Misc
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
val needSignIn = boolPref("need_sign_in", true)
val gridView = boolPref("grid_view", false)
val qSSaveProgress = boolPref("qs_save_progress", true)
val displayName = stringOrNullPref("display_name")
val avatar = stringOrNullPref("avatar")
val recentDownloadLabel = stringOrNullPref("recent_download_label")
var downloadScheme by stringOrNullPref("image_scheme")
var downloadAuthority by stringOrNullPref("image_authority")
var downloadPath by stringOrNullPref("image_path")
var downloadQuery by stringOrNullPref("image_query")
var downloadFragment by stringOrNullPref("image_fragment")
var archivePasswds by stringSetPref("archive_passwds")
val needSignInFlow: Flow<Boolean>
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
var downloadPath by stringOrNullPref("image_path", null)
var downloadQuery by stringOrNullPref("image_query", null)
var downloadFragment by stringOrNullPref("image_fragment", null)
var archivePasswds by stringSetOrNullPref("archive_passwds")
var downloadDelay by intPref("download_delay_3", 1000)
var gallerySite by intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
var multiThreadDownload by intPref("download_thread_2", 3)
var preloadImage by intPref("preload_image_2", 5)
var downloadTimeout by intPref("download_timeout", 60)
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
var thumbResolution by intPref("thumb_resolution_2", 0)
var readCacheSize by intPref("read_cache_size_2", 640)
var launchPage by intPref("launch_page_2", 0)
var commentThreshold by intPref("comment_threshold", -101)
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() }
var hideHvEvents by boolPref("hide_hv_events", false)
var showJpnTitle by boolPref("show_jpn_title", false)
var showGalleryPages by boolPref("show_gallery_pages", true)
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var enabledSecurity by boolPref("enable_secure", false)
var useCIUpdateChannel by boolPref("ci_update_channel", false)
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
var qSSaveProgress by boolPref("qs_save_progress", true)
var saveParseErrorBody by boolPref("save_parse_error_body", true)
var saveCrashLog by boolPref("save_crash_log", true)
var security by boolPref("require_unlock", false)
var removeImageFiles by boolPref("include_pic", true)
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var enableCronet by boolPref("enable_cronet", true)
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var lastDawnDays by intPref("last_dawn_days", 0)
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
var updateIntervalDays by intPref("update_interval_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var defaultDownloadLabel by stringOrNullPref("default_download_label")
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastDawnDay by longPref("last_dawn_day", 0)
var lastUpdateDay by longPref("last_update_day", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
// Reader
val cropBorder = boolPref("crop_borders", false)
val colorFilter = boolPref("pref_color_filter_key", false)
val colorFilterValue = intPref("color_filter_value", 0)
val colorFilterMode = intPref("color_filter_mode", 0)
val customBrightness = boolPref("pref_custom_brightness_key", false)
val customBrightnessValue = intPref("custom_brightness_value", 0)
val readingMode = intPref("pref_default_reading_mode_key", ReadingModeType.DEFAULT.prefValue)
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.DEFAULT.prefValue)
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
val showPageNumber = boolPref("pref_show_page_number_key", true)
val readerTheme = intPref("pref_reader_theme_key", 1)
val doubleTapToZoom = boolPref("pref_double_tap_to_zoom", true)
val fullscreen = boolPref("fullscreen", true)
val cutoutShort = boolPref("cutout_short", true)
val keepScreenOn = boolPref("pref_keep_screen_on_key", true)
val readerLongTapAction = boolPref("reader_long_tap", true)
val pageTransitions = boolPref("pref_enable_transitions_key", true)
val readWithVolumeKeys = boolPref("reader_volume_keys", false)
val readWithVolumeKeysInverted = boolPref("reader_volume_keys_inverted", false)
val grayScale = boolPref("pref_grayscale", false)
val invertedColors = boolPref("pref_inverted_colors", false)
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
val readerPagerNav = intPref("reader_navigation_mode_pager", 0)
val readerPagerNavInverted = intPref("reader_tapping_inverted_2", 0)
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon_2", 0)
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
val navigateToPan = boolPref("navigate_pan", true)
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
// TODO: Remove this after swipe gestures are correctly handled in compose
var touchSlopFactor by intPref("touch_slop", 3)
init {
edit { pref ->
if ("CN" == Locale.getDefault().country) {
if (showTagTranslations !in pref) pref[showTagTranslations] = true
}
if (pref[downloadFilterMode] == DownloadsFilterMode.ARTIST.flag && pref[recentDownloadLabel] == null) {
pref[recentDownloadLabel] = ""
}
val orientation = pref[orientationMode]
if (OrientationType.entries.none { it.prefValue == orientation }) {
pref.remove(orientationMode)
if ("CN" == Locale.getDefault().country) {
edit {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations = true
}
}
}
interface Delegate<R> {
fun changesFlow(): Flow<Unit>
val flowGetter: () -> Flow<Unit>
operator fun getValue(thisRef: Any?, prop: KProperty<*>?): R
operator fun setValue(thisRef: Any?, prop: KProperty<*>?, value: R)
}
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
private val delegates = Array(count) { intPref("${key}_$it", 0) }
override fun changesFlow(): Flow<Unit> = delegates.asFlow().flatMapMerge { it.changesFlow() }.conflate()
override fun getValue(thisRef: Any?, prop: KProperty<*>?) = IntArray(delegates.size) { delegates[it].value }
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard: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) {
check(value.size == count)
edit { pref -> value.zip(delegates) { v, d -> pref[d] = v } }
edit { value.zip(_value) { v, d -> d.value = v } }
}
}
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
private val delegates = Array(count) { stringPref("${key}_$it", "$defMetaValue $it") }
override fun changesFlow(): Flow<Unit> = delegates.asFlow().flatMapMerge { it.changesFlow() }.conflate()
override fun getValue(thisRef: Any?, prop: KProperty<*>?) = Array(delegates.size) { delegates[it].value }
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
@Suppress("ktlint:standard: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>) {
check(value.size == count)
edit { pref -> value.zip(delegates) { v, d -> pref[d] = v } }
edit { value.zip(_value) { v, d -> d.value = v } }
}
}
}
@@ -2,73 +2,93 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import arrow.core.Either.Companion.catch
import com.ehviewer.core.preferences.PrefDelegate
import com.ehviewer.core.util.isAtLeastS
import com.ehviewer.core.util.logcat
import com.ehviewer.core.util.withIOContext
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.util.isAtLeastS
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.launch
import splitties.init.appCtx
import splitties.preferences.PrefDelegate
import splitties.systemservices.uiModeManager
private const val TAG = "SettingsCollector"
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun <T, R : PrefDelegate<T>> R.observed(func: suspend (T) -> Unit) = apply { collectScope.launch { valueFlow().drop(1).collectLatest(func) } }
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flow.emitAll(changesFlow()) } }
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flow.emitAll(changesFlow()) } }
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { flowGetter().collect(func) } }
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
suspend fun updateWhenKeepMediaStatusChanges(mediaScan: Boolean) {
withIOContext {
catch {
keepNoMediaFileStatus(mediaScan = mediaScan)
}.onLeft {
logcat(TAG, it)
fun updateWhenLocaleChanges() {
val newValue = Settings.language
if ("system" == newValue) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
} else {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue))
}
}
fun updateWhenKeepMediaStatusChanges() {
collectScope.launchIO {
runCatching {
keepNoMediaFileStatus()
}
}
}
suspend fun updateWhenThemeChanges(theme: Int) {
delay(100) // Avoid recompose being cancelled
if (isAtLeastS) {
val mode = when (theme) {
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_AUTO
fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
if (isAtLeastS) {
val mode = when (Settings.theme) {
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
else -> UiModeManager.MODE_NIGHT_AUTO
}
uiModeManager.setApplicationNightMode(mode)
}
uiModeManager.setApplicationNightMode(mode)
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
}
fun updateWhenAmoledModeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
AppCompatDelegate.setDefaultNightMode(Settings.theme)
}
AppCompatDelegate.setDefaultNightMode(theme)
}
fun updateWhenRequestNewsChanges() {
updateDailyCheckWork(appCtx)
}
suspend fun updateWhenGallerySiteChanges(gallerySite: Int) {
if (Settings.hasSignedIn.value) {
catch {
EhEngine.getUConfig(gallerySite)
}.onLeft {
logcat(TAG, it)
fun updateWhenGallerySiteChanges() {
if (!Settings.needSignIn && EhCookieStore.hasSignedIn()) {
collectScope.launchIO {
runCatching {
EhEngine.getUConfig()
}.onFailure {
it.printStackTrace()
}
}
}
}
fun updateWhenTagTranslationChanges(enabled: Boolean) {
if (enabled) {
EhTagDatabase.launchUpdate()
fun updateWhenTagTranslationChanges() {
collectScope.launchIO {
runCatching {
EhTagDatabase.update()
}.onFailure {
it.printStackTrace()
}
}
}
@@ -15,38 +15,36 @@
*/
package com.hippo.ehviewer.client
import com.ehviewer.core.model.GalleryInfo
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
// Normal Preview (v2): https://*.hath.network/c(m|1|2)/[timed token]/[gid]-[index].(jpg|webp)
// ExHentai Large Preview (v1 Cover): https://s.exhentai.org/t/***
// E-Hentai Large Preview (v1 Cover): https://ehgt.org/***
// ExHentai v2 Cover: https://s.exhentai.org/**.webp
// E-Hentai v2 Cover: https://ehgt.org/**.webp
// ExHentai Large Preview: https://s.exhentai.org/t/***
// ExHentai Normal Preview: https://s.exhentai.org/m/***
// EHentai Normal Preview: https://ehgt.org/m/***
// EHentai Large Preview: https://ehgt.org/***
const val URL_PREFIX_THUMB_E = "https://ehgt.org/"
const val URL_PREFIX_THUMB_EX = "https://s.exhentai.org/"
private const val URL_PREFIX_V1_THUMB_EX = URL_PREFIX_THUMB_EX + "t/"
private val V2PreviewKeyRegex = Regex("/c([m12]/)[^/]+/(\\d+-\\d+)")
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 = "image:$gid:$index"
fun getImageKey(gid: Long, index: Int): String {
return "image:$gid:$index"
}
fun getThumbKey(url: String): String = url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_V1_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
fun getThumbKey(url: String): String {
return url.removePrefix(URL_PREFIX_THUMB_E).removePrefix(URL_PREFIX_LARGE_THUMB_EX).removePrefix(URL_PREFIX_THUMB_EX)
}
fun getV2PreviewKey(url: String) = "$".plus(
V2PreviewKeyRegex.find(url)?.let {
it.groupValues[1] + it.groupValues[2]
} ?: url,
)
val GalleryPreview.url
get() = if (exThumb) {
if (this is NormalGalleryPreview) URL_PREFIX_THUMB_EX else URL_PREFIX_LARGE_THUMB_EX
} else {
URL_PREFIX_THUMB_E
} + imageKey
val GalleryInfo.thumbUrl
get() = keyToUrl(thumbKey!!)
get() = (if (exThumb) URL_PREFIX_LARGE_THUMB_EX else URL_PREFIX_THUMB_E) + thumbKey!!
fun keyToUrl(key: String) = if (key.startsWith("https:")) {
key
} else {
if (key.endsWith("webp")) {
if (EhUtils.isExHentai) URL_PREFIX_THUMB_EX else URL_PREFIX_THUMB_E
} else {
if (EhUtils.isExHentai) URL_PREFIX_V1_THUMB_EX else URL_PREFIX_THUMB_E
} + key
}
val exThumb get() = EhUtils.isExHentai && !Settings.forceEhThumb
@@ -0,0 +1,108 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client
import android.webkit.CookieManager
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastJoinToString
import androidx.compose.ui.util.fastMapNotNull
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
object EhCookieStore : CookieJar {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
fun get(url: HttpUrl): List<Cookie> {
val cookies = manager.getCookie(url.toString())
return if (!cookies.isNullOrEmpty()) {
cookies.split(";").fastMapNotNull { setCookie ->
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
}
} else {
emptyList()
}
}
fun hasSignedIn(): Boolean {
val url = EhUrl.HOST_E.toHttpUrl()
return contains(url, KEY_IPB_MEMBER_ID) && contains(url, KEY_IPB_PASS_HASH)
}
const val KEY_IPB_MEMBER_ID = "ipb_member_id"
const val KEY_IPB_PASS_HASH = "ipb_pass_hash"
const val KEY_IGNEOUS = "igneous"
private const val KEY_STAR = "star"
private const val KEY_CONTENT_WARNING = "nw"
private const val CONTENT_WARNING_NOT_SHOW = "1"
private const val KEY_UTMP_NAME = "__utmp"
private val sTipsCookie = Cookie.Builder().apply {
name(KEY_CONTENT_WARNING)
value(CONTENT_WARNING_NOT_SHOW)
domain(EhUrl.DOMAIN_E)
path("/")
expiresAt(Long.MAX_VALUE)
}.build()
fun copyNecessaryCookies() {
val cookies = get(EhUrl.HOST_E.toHttpUrl())
cookies.fastForEach {
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
manager.setCookie(EhUrl.HOST_EX, it.toString())
}
}
}
fun deleteCookie(url: HttpUrl, name: String) {
manager.setCookie(url.toString(), "$name=;Max-Age=0")
}
fun addCookie(cookie: Cookie) {
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
}
fun flush() = manager.flush()
fun getCookieHeader(url: HttpUrl): String {
return loadForRequest(url).fastJoinToString("; ") {
"${it.name}=${it.value}"
}
}
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
if (it.name != KEY_UTMP_NAME) {
manager.setCookie(url.toString(), it.toString())
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val checkTips = EhUrl.DOMAIN_E in url.host
return get(url).run {
if (checkTips) {
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
} else {
this
}
}
}
}
@@ -15,40 +15,26 @@
*/
package com.hippo.ehviewer.client
import arrow.core.Either
import arrow.core.left
import arrow.core.partially2
import arrow.core.right
import android.util.Log
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
import com.ehviewer.core.i18n.R
import com.ehviewer.core.model.BaseGalleryInfo
import com.ehviewer.core.model.GalleryInfo
import com.ehviewer.core.network.EhCookieStore
import com.ehviewer.core.util.logcat
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.client.data.fillInfo
import com.hippo.ehviewer.client.data.filterComments
import com.hippo.ehviewer.client.exception.CloudflareBypassException
import com.hippo.ehviewer.client.exception.EhError
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.InsufficientGpException
import com.hippo.ehviewer.client.exception.IpBannedException
import com.hippo.ehviewer.client.exception.NoHathClientException
import com.hippo.ehviewer.client.exception.NoHitsFoundException
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.EventPaneParser
import com.hippo.ehviewer.client.parser.FavParserResult
import com.hippo.ehviewer.client.parser.FavoritesParser
import com.hippo.ehviewer.client.parser.ForumsParser
import com.hippo.ehviewer.client.parser.GalleryApiParser
import com.hippo.ehviewer.client.parser.GalleryDetailParser
import com.hippo.ehviewer.client.parser.GalleryListParser
import com.hippo.ehviewer.client.parser.GalleryMultiPageViewerPTokenParser
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
import com.hippo.ehviewer.client.parser.GalleryPageParser
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
import com.hippo.ehviewer.client.parser.HomeParser
@@ -57,223 +43,224 @@ import com.hippo.ehviewer.client.parser.RateGalleryResult
import com.hippo.ehviewer.client.parser.SignInParser
import com.hippo.ehviewer.client.parser.TorrentParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.UserConfigParser
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.cronet.awaitBodyFully
import com.hippo.ehviewer.cronet.cronetRequest
import com.hippo.ehviewer.cronet.execute
import com.hippo.ehviewer.cronet.withRequestBody
import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.HttpStatement
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.request
import io.ktor.http.Url
import io.ktor.util.moveToByteArray
import com.hippo.ehviewer.util.StatusCodeException
import com.hippo.ehviewer.util.isCronetSupported
import io.ktor.utils.io.pool.DirectByteBufferPool
import io.ktor.utils.io.pool.useInstance
import io.ktor.utils.io.readAvailable
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlin.system.measureTimeMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerializationException
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonArray
import kotlinx.serialization.json.put
import moe.tarsin.coroutines.removeAllSuspend
import moe.tarsin.coroutines.runSuspendCatching
import okio.buffer
import okio.sink
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import org.jsoup.Jsoup
import splitties.init.appCtx
// https://ehwiki.org/wiki/API#Basics
private const val TAG = "EhEngine"
private const val MAX_REQUEST_SIZE = 25
private const val MAX_SEQUENTIAL_REQUESTS = 5
private const val REQUEST_INTERVAL = 5000L
private const val U_CONFIG_TEXT = "Selected Profile"
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
private val Url.isLogin get() = segments.firstOrNull() == "bounce_login.php"
private fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
val dir = AppConfig.externalParseErrorDir ?: return
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
file.sink().buffer().use { sink ->
with(sink) {
writeUtf8(e.message + "\n")
onLeft { writeUtf8(it) }
onRight { write(it) }
}
}
}
private fun rethrowExactly(response: HttpResponse, body: Either<String, ByteBuffer>, e: Throwable): Nothing {
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
// Don't translate coroutine cancellation
if (e is CancellationException) throw e
if (response.headers["cf-mitigated"] == "challenge") throw CloudflareBypassException()
// Check sad panda (without panda)
val empty = body.fold(
{ it.isEmpty() },
{ !it.hasRemaining() },
)
if (empty) {
val message = if (EhUtils.isExHentai) {
"Sad Panda\n(without panda)"
// Check sad panda(without panda)
if (body.isEmpty()) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
appCtx.getString(R.string.error_ip_banned)
throw EhException("IP banned")
}
throw IpBannedException(message)
}
// Parse error from rust
val error = body.getOrNull()?.runCatching {
val array = moveToByteArray()
rewind()
Cbor.decodeFromByteArray<EhError>(array)
}?.getOrNull()
// Check Gallery Not Available - 404
if (error is EhError.GalleryUnavailable) throw EhException(error.message)
// Check Gallery Not Available
if (body.contains("Gallery Not Available - ")) {
val error = GalleryNotAvailableParser.parse(body)
if (!error.isNullOrBlank()) {
throw EhException(error)
}
}
// Check bad response code
response.status.ensureSuccess()
when (error) {
EhError.NoHits -> throw NoHitsFoundException()
EhError.NoWatched -> throw EhException(R.string.gallery_list_empty_hit_subscription)
EhError.NeedLogin -> throw NotLoggedInException()
EhError.NoHathClient -> throw NoHathClientException()
EhError.InsufficientFunds -> throw InsufficientFundsException()
is EhError.IpBanned -> throw IpBannedException(error.message)
is EhError.Error -> throw EhException(error.message)
else -> Unit
if (code >= 400) {
throw StatusCodeException(code)
}
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw IpBannedException(it) }
if (Settings.saveParseErrorBody.value) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
if ("<" !in body) {
throw EhException(body)
} else {
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
}
// We can't translate it, rethrow it anyway
throw e
}
private val httpContentPool = DirectByteBufferPool(8, 0x80000)
val httpContentPool = DirectByteBufferPool(8, 0x80000)
private suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = executeSafely { response ->
if (response.request.url.isLogin) throw NotLoggedInException()
val body = response.bodyAsUtf8Text()
runSuspendCatching {
block(body)
}.onFailure {
rethrowExactly(response, body.left(), it)
}.getOrThrow()
suspend fun <T> fetchCompat(
url: String,
referer: String? = null,
origin: String? = null,
reqBody: RequestBody? = null,
parser: suspend (ByteBuffer) -> T,
): T {
Log.d(TAG, url)
fun commonChecks(body: ByteBuffer) {
// Check sad panda(without panda)
if (!body.hasRemaining()) {
if (EhUtils.isExHentai) {
throw EhException("Sad Panda\n(without panda)")
} else {
throw EhException("IP banned")
}
}
}
return if (isCronetSupported) {
cronetRequest(url, referer, origin) {
reqBody?.let { withRequestBody(it) }
}.execute {
@Suppress("NewApi")
val code = it.httpStatusCode
if (code >= 400) {
throw StatusCodeException(code)
}
httpContentPool.useInstance { buffer ->
awaitBodyFully(buffer)
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
} else {
ehRequest(url, referer, origin) {
reqBody?.let { post(it) }
}.execute {
httpContentPool.useInstance { buffer ->
while (true) {
if (body.source().read(buffer) == -1) break
}
buffer.flip()
commonChecks(buffer)
parser(buffer)
}
}
}
}
private suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = executeSafely { response ->
if (response.request.url.isLogin) throw NotLoggedInException()
httpContentPool.useInstance { buffer ->
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
buffer.flip()
runSuspendCatching {
block(buffer)
}.onFailure {
buffer.rewind()
rethrowExactly(response, buffer.right(), it)
}.getOrThrow()
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
private suspend inline fun <T> Request.executeAndParsingWith(block: suspend String.() -> T): T {
Log.d(TAG, url.toString())
return execute {
val body = body.string()
try {
block(body)
} catch (e: Exception) {
rethrowExactly(code, body, e)
}
}
}
object EhEngine {
suspend fun getOriginalImageUrl(url: String, referer: String?) = noRedirectEhRequest(url, referer).executeSafely { response ->
response.headers["Location"]?.takeUnless { Url(it).isLogin } ?: throw InsufficientGpException()
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
Log.d(TAG, url)
return ehRequest(url, referer).executeNoRedirect {
header("Location")?.apply {
if (contains("bounce_login")) {
throw NotLoggedInException()
}
} ?: throw InsufficientFundsException()
}
}
suspend fun getPTokenListFromMpv(gid: Long, token: String) = ehRequest(EhUrl.getGalleryMultiPageViewerUrl(gid, token), EhUrl.referer)
.fetchUsingAsText(GalleryMultiPageViewerPTokenParser::parse)
suspend fun getTorrentList(gid: Long, token: String): TorrentResult {
val url = EhUrl.getTorrentUrl(gid, token)
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
return fetchCompat(url, referer, parser = TorrentParser::parse)
}
suspend fun getTorrentKey() = EhCookieStore.getUserId()?.let { id ->
val key = ehRequest(EhUrl.URL_HOME).fetchUsingAsText(HomeParser::parseTorrentKey)
"${id}x$key"
}
suspend fun getArchiveList(gid: Long, token: String): ArchiveParser.Result {
val url = EhUrl.getArchiveUrl(gid, token)
val funds = if (EhUtils.isExHentai) getFunds() else null
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsByteBuffer(ArchiveParser::parse.partially2(funds))
}
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.executeAndParsingWith(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
suspend fun getImageLimits() = parZip(
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
{ getFunds() },
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.fetchUsingAsByteBuffer { if (parse) EventPaneParser.parse(this) else null }
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
suspend fun getProfile(): ProfileParser.Result {
val url = ehRequest(EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parseProfileUrl)
return ehRequest(url, EhUrl.URL_FORUMS).fetchUsingAsByteBuffer(ProfileParser::parse)
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
}
suspend fun getUConfig(gallerySite: Int) {
val url = EhUrl.getUConfigUrl(gallerySite)
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
}.onFailure { throwable ->
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
}.onFailure {
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
logcat(throwable)
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
it.printStackTrace()
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
} else {
throw throwable
throw it
}
}
}
suspend fun getGalleryPage(url: String, gid: Long, token: String): GalleryPageParser.Result {
suspend fun getGalleryPage(
url: String,
gid: Long,
token: String?,
): GalleryPageParser.Result {
val referer = EhUrl.getGalleryDetailUrl(gid, token)
return ehRequest(url, referer).fetchUsingAsText(GalleryPageParser::parse)
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
}
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryListParser::parse)
.apply { galleryInfoList.fillInfo(url, true) }
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
.apply { fillGalleryList(galleryInfoList, url, true) }
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parse).run {
event?.let {
Settings.lastDawnDays = today
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
EventPaneParser.parse(this)?.let {
Settings.lastDawnDay = today
showEventNotification(it)
}
detail.apply {
// Fill info for database
fillInfo()
filterComments()
}
GalleryDetailParser.parse(this)
}
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(GalleryDetailParser::parsePreviews)
suspend fun getPreviewList(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
}
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
.apply { galleryInfoList.fillInfo(url) }
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
suspend fun signIn(username: String, password: String): String {
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
@@ -281,77 +268,89 @@ object EhEngine {
val origin = "https://forums.e-hentai.org"
return ehRequest(url, referer, origin) {
formBody {
append("referer", referer)
append("b", "")
append("bt", "")
append("UserName", username)
append("PassWord", password)
append("CookieDate", "1")
add("referer", referer)
add("b", "")
add("bt", "")
add("UserName", username)
add("PassWord", password)
add("CookieDate", "1")
}
}.fetchUsingAsText(SignInParser::parse)
}.executeAndParsingWith(SignInParser::parse)
}
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
formBody {
if (id == -1L) {
append("commenttext_new", comment)
add("commenttext_new", comment)
} else {
append("commenttext_edit", comment)
append("edit_comment", id.toString())
add("commenttext_edit", comment)
add("edit_comment", id.toString())
}
}
}.executeSafely { response ->
// Ktor does not handle POST redirect, we need to do it manually
// https://youtrack.jetbrains.com/issue/KTOR-478
val location = response.headers["Location"] ?: url
ehRequest(location, url).fetchUsingAsByteBuffer(GalleryDetailParser::parseComments)
}.executeAndParsingWith {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
}
suspend fun modifyFavorites(gid: Long, token: String, dstCat: Int = -1, note: String = "") {
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
val catStr: String = when (dstCat) {
-1 -> "favdel"
in 0..9 -> dstCat.toString()
else -> throw EhException("Invalid dstCat: $dstCat")
}
val url = EhUrl.getAddFavorites(gid, token)
ehRequest(url, url, EhUrl.origin) {
formBody {
append("favcat", catStr)
append("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
append("apply", "Apply Changes")
append("update", "1")
}
}.executeSafely { }
val body = formBody {
add("favcat", catStr)
add("favnote", note)
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
add("apply", "Apply Changes")
add("update", "1")
}
fetchCompat(url, url, EhUrl.origin, body) { }
}
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, res: String, isHath: Boolean): String? {
val url = EhUrl.getArchiveUrl(gid, token)
suspend fun downloadArchive(
gid: Long,
token: String?,
or: String?,
res: String?,
isHAtH: Boolean,
): String? {
if (or.isNullOrEmpty()) {
throw EhException("Invalid form param or: $or")
}
if (res.isNullOrEmpty()) {
throw EhException("Invalid res: $res")
}
val url = EhUrl.getDownloadArchive(gid, token, or)
val referer = EhUrl.getGalleryDetailUrl(gid, token)
val request = ehRequest(url, referer, EhUrl.origin) {
formBody {
if (isHath) {
append("hathdl_xres", res)
if (isHAtH) {
add("hathdl_xres", res)
} else {
append("dltype", res)
add("dltype", res)
if (res == "org") {
append("dlcheck", "Download Original Archive")
add("dlcheck", "Download Original Archive")
} else {
append("dlcheck", "Download Resample Archive")
add("dlcheck", "Download Resample Archive")
}
}
}
}
var result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
if (!isHath) {
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
if (!isHAtH) {
if (result == null) {
// Wait for the server to prepare archives
delay(1000)
result = request.fetchUsingAsByteBuffer(ArchiveParser::parseArchiveUrl)
if (result == null) throw EhException("Archive unavailable")
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
if (result == null) {
throw EhException("Archive unavailable")
}
}
return result
}
@@ -360,28 +359,37 @@ object EhEngine {
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
formBody {
append("reset_imagelimit", "Reset Quota")
add("reset_imagelimit", "Reset Limit")
}
}.fetchUsingAsText(HomeParser::parseResetLimits)
}.executeAndParsingWith(HomeParser::parseResetLimits)
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavParserResult {
val url = ehUrl(EhUrl.FAV_PATH) {
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
}.buildString()
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
val url = ehUrl {
addPathSegments(EhUrl.FAV_PATH)
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
addQueryParameter("favcat", srcCat.toString())
}
}.toString()
val catStr: String = when (dstCat) {
-1 -> "delete"
in 0..9 -> "fav$dstCat"
else -> throw EhException("Invalid dstCat: $dstCat")
}
return ehRequest(url, url, EhUrl.origin) {
formBody {
append("ddact", catStr)
gidArray.forEach { append("modifygids[]", it.toString()) }
}
}.fetchUsingAsByteBuffer(FavoritesParser::parse).apply { galleryInfoList.fillInfo(url) }
val body = formBody {
add("ddact", catStr)
gidArray.forEach { add("modifygids[]", it.toString()) }
}
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
.apply { fillGalleryList(galleryInfoList, url) }
}
suspend fun getGalleryPageApi(gid: Long, index: Int, pToken: String, showKey: String?, previousPToken: String?): GalleryPageParser.Result {
suspend fun getGalleryPageApi(
gid: Long,
index: Int,
pToken: String,
showKey: String?,
previousPToken: String?,
): GalleryPageParser.Result {
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
@@ -391,67 +399,83 @@ object EhEngine {
put("imgkey", pToken)
put("showkey", showKey)
}
}.fetchUsingAsText { GalleryPageParser.parse(filterNot { it == '\\' }) }
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
}
suspend fun rateGallery(apiUid: Long, apiKey: String?, gid: Long, token: String, rating: Float): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
suspend fun rateGallery(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
rating: Float,
): RateGalleryResult = ehRequest(EhUrl.apiUrl, EhUrl.getGalleryDetailUrl(gid, token), EhUrl.origin) {
jsonBody {
put("method", "rategallery")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("rating", ceil((rating * 2).toDouble()).toInt())
}
}.fetchUsingAsText(String::parseAs)
}.executeAndParsingWith(String::parseAs)
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String? = null) = galleryInfoList.chunked(MAX_REQUEST_SIZE).chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap {
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
jsonBody {
put("method", "gdata")
array("gidlist") {
it.forEach { info ->
it.forEach {
addJsonArray {
add(info.gid)
add(info.token)
add(it.gid)
add(it.token)
}
}
}
put("namespace", 1)
}
}.fetchUsingAsText { GalleryApiParser.parse(this, it) }
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
}
}
suspend fun voteComment(apiUid: Long, apiKey: String?, gid: Long, token: String, commentId: Long, commentVote: Int): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
suspend fun voteComment(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
commentId: Long,
commentVote: Int,
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "votecomment")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("comment_id", commentId)
put("comment_vote", commentVote)
}
}.fetchUsingAsText(String::parseAs)
}.executeAndParsingWith(String::parseAs)
suspend fun voteTag(apiUid: Long, apiKey: String?, gid: Long, token: String, tags: String, vote: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
suspend fun voteTag(
apiUid: Long,
apiKey: String?,
gid: Long,
token: String?,
tags: String?,
vote: Int,
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "taggallery")
put("apiuid", apiUid)
put("apikey", requireNotNull(apiKey))
put("apikey", apiKey)
put("gid", gid)
put("token", token)
put("tags", tags)
put("vote", vote)
}
}.fetchUsingAsByteBuffer(VoteTagParser::parse)
}.executeAndParsingWith(VoteTagParser::parse)
suspend fun getGalleryToken(gid: Long, gtoken: String, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
jsonBody {
put("method", "gtoken")
array("pagelist") {
@@ -462,32 +486,51 @@ object EhEngine {
}
}
}
}.fetchUsingAsText(GalleryTokenApiParser::parse)
}.executeAndParsingWith(GalleryTokenApiParser::parse)
private suspend fun MutableList<BaseGalleryInfo>.fillInfo(url: String, filter: Boolean = false) = with(EhFilter) {
if (filter) removeAllSuspend { filterTitle(it) || filterUploader(it) }
val hasTags = any { !it.simpleTags.isNullOrEmpty() }
val hasPages = any { it.pages != 0 }
val hasRated = any { it.rated }
val needApi = filter && needTags() && !hasTags || Settings.showGalleryPages.value && !hasPages || hasRated
if (needApi) fillGalleryListByApi(this@fillInfo, url)
if (filter) removeAllSuspend { filterUploader(it) || filterTag(it) || filterTagNamespace(it) }
forEach {
if (it.favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = GalleryInfo.LOCAL_FAVORITED
/**
* @param image Must be jpeg
*/
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
EhUrl.imageSearchUrl,
EhUrl.referer,
EhUrl.origin,
multipartBody {
setType(MultipartBody.FORM)
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
if (uss) addFormDataPart("fs_similar", "on")
if (osc) addFormDataPart("fs_covers", "on")
addFormDataPart("f_sfile", "File Search")
},
parser = GalleryListParser::parse,
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
// Filter title and uploader
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
var hasTags = false
var hasPages = false
var hasRated = false
for (gi in list) {
if (!gi.simpleTags.isNullOrEmpty()) {
hasTags = true
}
if (gi.pages != 0) {
hasPages = true
}
if (gi.rated) {
hasRated = true
}
if (!needApi) it.generateSLang()
}
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
if (needApi) fillGalleryListByApi(list, url)
// Filter tag, thumbnail mode need filter uploader again
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.forEach { (gid, token) ->
// https://github.com/FooIbar/EhViewer/issues/1190
// Workaround for duplicate items when sorting by favorited time
val timeTaken = measureTimeMillis {
modifyFavorites(gid, token, dstCat)
}
delay(1000 - timeTaken)
}
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
}
}
@@ -1,10 +1,10 @@
package com.hippo.ehviewer.client
import arrow.core.memoize
import com.ehviewer.core.database.model.Filter
import com.ehviewer.core.database.model.FilterMode
import com.ehviewer.core.model.GalleryInfo
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -55,8 +55,8 @@ object EhFilter : CoroutineScope {
suspend fun needTags() = filters.await().any { it.enable && (it.mode == FilterMode.TAG || it.mode == FilterMode.TAG_NAMESPACE) }
suspend fun filterTitle(info: GalleryInfo) = anyActive(FilterMode.TITLE) { info.title.orEmpty().contains(it.text, true) }
suspend fun filterUploader(info: GalleryInfo) = anyActive(FilterMode.UPLOADER) { it.text == info.uploader }
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } == true
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } == true
suspend fun filterTag(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG) { matchTag(tag, it.text.lowercase()) } } ?: false
suspend fun filterTagNamespace(info: GalleryInfo) = info.simpleTags?.any { tag -> anyActive(FilterMode.TAG_NAMESPACE) { matchTagNamespace(tag, it.text.lowercase()) } } ?: false
suspend fun filterCommenter(commenter: String) = anyActive(FilterMode.COMMENTER) { it.text == commenter }
suspend fun filterComment(comment: String) = anyActive(FilterMode.COMMENT) { regex(it).containsMatchIn(comment) }
}
@@ -15,90 +15,116 @@
*/
package com.hippo.ehviewer.client
import com.ehviewer.core.util.logcat
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.header
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.HttpStatement
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.ParametersBuilder
import io.ktor.http.content.TextContent
import kotlinx.coroutines.cancel
import kotlinx.io.Source
import android.util.Log
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
import com.hippo.ehviewer.Settings
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArrayBuilder
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.io.decodeFromSource
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.Call
import okhttp3.FormBody
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.executeAsync
import okio.BufferedSource
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
addHeader("User-Agent", Settings.userAgent)
addHeader("Accept", CHROME_ACCEPT)
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
referer?.let { addHeader("Referer", it) }
origin?.let { addHeader("Origin", it) }
}.apply(builder).build()
inline fun formBody(builder: FormBody.Builder.() -> Unit) = FormBody.Builder().apply(builder).build()
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
inline fun multipartBody(builder: MultipartBody.Builder.() -> Unit) = MultipartBody.Builder().apply(builder).build()
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
suspend inline fun <reified T> HttpStatement.executeAndParseAs() = executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text().parseAs<T>()
}
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
inline fun <reified T> Source.parseAs(): T = json.decodeFromSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
const val TAG_CALL = "CancellableCallReadingScope"
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun HttpRequestBuilder.applyEhConfig(referer: String?, origin: String?) {
header("Referer", referer)
header("Origin", origin)
}
suspend inline fun ehRequest(
url: String,
referer: String? = null,
origin: String? = null,
verbose: Boolean = true,
builder: HttpRequestBuilder.() -> Unit = {},
) = ktorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { if (verbose) logcat("EhRequest") { url } }
suspend inline fun noRedirectEhRequest(
url: String,
referer: String? = null,
origin: String? = null,
builder: HttpRequestBuilder.() -> Unit = {},
) = noRedirectKtorClient.prepareRequest(url) {
applyEhConfig(referer, origin)
apply(builder)
}.also { logcat("EhRequest") { url } }
suspend inline fun <T> HttpStatement.executeSafely(
crossinline block: suspend (response: HttpResponse) -> T,
) = execute { resp ->
try {
block(resp)
} finally {
resp.coroutineContext.cancel()
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
// TODO: Remove logging after this function stable
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
val call = this
Log.d(TAG_CALL, "Got response of call$call")
coroutineScope {
suspendCancellableCoroutine<R> { cont ->
launch {
val r = runCatching {
Log.d(TAG_CALL, "Reading response of call$call")
block(it)
}
if (!isCanceled() && cont.isActive) {
r.exceptionOrNull()?.let {
Log.e(TAG_CALL, "Reading response of call$call failed!")
cont.resumeWithException(it)
}
r.getOrNull()?.let {
Log.d(TAG_CALL, "Reading response of call$call succeed!")
cont.resume(it)
}
} else if (isCanceled() && cont.isActive) {
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
} else if (!isCanceled() && !cont.isActive) {
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
} else {
Log.d(TAG_CALL, "Reading response of call$call cancelled")
}
}
cont.invokeOnCancellation {
Log.d(TAG_CALL, "Cancelling call$call")
cancel()
}
}
}.apply {
Log.d(TAG_CALL, "Reading response of call$call finished!")
}
}
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
method = HttpMethod.Post
val parameters = ParametersBuilder().apply(builder).build()
setBody(FormDataContent(parameters))
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return okHttpClient.newCall(this).executeAsync().use(block)
}
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
inline fun HttpRequestBuilder.jsonBody(builder: JsonObjectBuilder.() -> Unit) {
method = HttpMethod.Post
val json = buildJsonObject(builder).toString()
setBody(TextContent(text = json, contentType = ContentType.Application.Json))
}
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
inline fun <reified T> BufferedSource.parseAs(): T = json.decodeFromBufferedSource(this)
inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
@@ -19,39 +19,33 @@ import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import com.ehviewer.core.files.metadataOrNull
import com.ehviewer.core.files.read
import com.ehviewer.core.files.write
import com.ehviewer.core.model.TagNamespace
import com.ehviewer.core.util.logcat
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
import com.hippo.ehviewer.R
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.bodyAsUtf8Text
import com.hippo.ehviewer.util.ensureSuccess
import com.hippo.ehviewer.util.utf8
import io.ktor.client.request.prepareGet
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.copyToFile
import java.io.File
import java.nio.charset.StandardCharsets
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.Source
import kotlinx.io.writeString
import kotlinx.serialization.json.io.encodeToSink
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.tarsin.coroutines.runSuspendCatching
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.executeAsync
import okio.BufferedSource
import okio.HashingSink.Companion.sha1
import okio.blackholeSink
import okio.buffer
import okio.source
import splitties.init.appCtx
object EhTagDatabase : CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
private const val NAMESPACE_PREFIX = "n"
private val PREFIXES = arrayOf(
"-",
@@ -61,140 +55,229 @@ object EhTagDatabase : CoroutineScope {
)
private lateinit var tagGroups: Map<String, Map<String, String>>
private val updateLock = Mutex()
override val coroutineContext = Dispatchers.IO + Job()
var initialized by mutableStateOf(false)
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? = tagGroups[prefix]?.get(tag)?.trim()?.ifEmpty { null }
// Copied from https://github.com/EhTagTranslation/EhSyringe/blob/c0cf88da04081072492829673aca34380b14dae6/src/plugin/suggest.ts#L37
// TODO: Optimize
private val nsScore = mapOf(
TagNamespace.Location.prefix to 10f,
TagNamespace.Other.prefix to 10f,
TagNamespace.Female.prefix to 9f,
TagNamespace.Male.prefix to 8.5f,
TagNamespace.Mixed.prefix to 8f,
TagNamespace.Parody.prefix to 3.3f,
TagNamespace.Character.prefix to 2.8f,
TagNamespace.Artist.prefix to 2.5f,
TagNamespace.Cosplayer.prefix to 2.4f,
TagNamespace.Group.prefix to 2.2f,
TagNamespace.Language.prefix to 2f,
)
data class Tag(val tag: String, val hint: String?, val score: Float)
context(_: Context)
fun suggestion(rawKeyword: String, expectTranslate: Boolean): List<Tag> {
val keyword = PREFIXES.fold(rawKeyword) { kwd, pfx -> kwd.removePrefix(pfx) }
if (!initialized || keyword.isEmpty()) return emptyList()
val translate = expectTranslate && translatable
val prefix = rawKeyword.dropLast(keyword.length)
val ns = keyword.substringBefore(':')
val tag = keyword.drop(ns.length + 1)
val nsPrefix = TagNamespace.from(ns)?.prefix ?: ns
val tags = tagGroups[nsPrefix.takeIf { tag.isNotEmpty() && it != NAMESPACE_PREFIX }]
return if (tags != null) {
lookup(tags, tag, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}") }
} else {
tagGroups.asSequence().flatMap { (nsPrefix, tags) ->
if (nsPrefix != NAMESPACE_PREFIX) {
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix$nsPrefix:${it.tag}", score = it.score / (nsScore[nsPrefix] ?: 1f)) }
} else {
lookup(tags, keyword, translate).map { it.copy(tag = "$prefix${it.tag}:", score = 0f) }
}
}
}.toMutableList().apply { sortBy { it.score } }
fun getTranslation(prefix: String? = NAMESPACE_PREFIX, tag: String?): String? {
return tagGroups[prefix]?.get(tag)?.trim()?.takeIf { it.isNotEmpty() }
}
private fun lookup(tags: Map<String, String>, keyword: String, translate: Boolean) = if (translate) {
tags.asSequence().mapNotNull { (tag, hint) ->
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
tag.length
private fun internalSuggestFlow(
tags: Map<String, String>,
keyword: String,
translate: Boolean,
exactly: Boolean,
): Flow<Pair<String?, String>> = flow {
if (exactly) {
tags[keyword]?.let {
emit(Pair(it.takeIf { translate }, keyword))
}
} else {
if (translate) {
tags.forEach { (tag, hint) ->
if (tag != keyword &&
(tag.containsIgnoreSpace(keyword) || hint.containsIgnoreSpace(keyword))
) {
emit(Pair(hint, tag))
}
}
} else {
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
tag.length * 2
} else {
when (hint.indexOf(keyword, ignoreCase = true)) {
-1 -> 0
0 -> hint.length
else -> hint.length * 2
tags.keys.forEach { tag ->
if (tag != keyword && tag.containsIgnoreSpace(keyword)) {
emit(Pair(null, tag))
}
}
}
if (score != 0) Tag(tag, hint, score.toFloat()) else null
}
} else {
tags.keys.asSequence().mapNotNull { tag ->
val score = if (tag.startsWith(keyword, ignoreCase = true)) {
tag.length
}
// Construct a cold flow for tag database suggestions
fun suggestFlow(
keyword: String,
translate: Boolean,
exactly: Boolean = false,
): Flow<Pair<String?, String>> = flow {
var mKeyword = keyword
PREFIXES.forEach { mKeyword = mKeyword.removePrefix(it) }
val prefix = keyword.dropLast(mKeyword.length)
val namespace = mKeyword.substringBefore(':')
val tagKeyword = mKeyword.drop(namespace.length + 1)
val namespacePrefix = namespaceToPrefix(namespace) ?: namespace
val tags = tagGroups[namespacePrefix.takeIf { tagKeyword.isNotEmpty() && it != NAMESPACE_PREFIX }]
tags?.let {
internalSuggestFlow(it, tagKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
}
} ?: tagGroups.forEach { (namespacePrefix, tags) ->
if (namespacePrefix != NAMESPACE_PREFIX) {
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, tag) ->
emit(Pair(hint, "$prefix$namespacePrefix:$tag"))
}
} else {
if (tag.indexOf(" $keyword", ignoreCase = true) != -1) {
tag.length * 2
} else {
0
internalSuggestFlow(tags, mKeyword, translate, exactly).collect { (hint, namespacePrefix) ->
emit(Pair(hint, "$prefix$namespacePrefix:"))
}
}
if (score != 0) Tag(tag, null, score.toFloat()) else null
}
}
private fun metadata(context: Context): Array<String> = context.resources.getStringArray(R.array.tag_translation_metadata)
private fun String.removeSpace(): String = replace(" ", "")
context(ctx: Context)
val translatable
get() = ctx.resources.getBoolean(R.bool.tag_translatable)
private fun String.containsIgnoreSpace(other: String, ignoreCase: Boolean = true): Boolean =
removeSpace().contains(other.removeSpace(), ignoreCase)
private suspend fun fetch(url: String) = ktorClient.prepareGet(url).executeSafely {
it.status.ensureSuccess()
it.bodyAsUtf8Text()
private val NAMESPACE_TO_PREFIX = HashMap<String, String>().also {
it["artist"] = "a"
it["cosplayer"] = "cos"
it["character"] = "c"
it["female"] = "f"
it["group"] = "g"
it["language"] = "l"
it["male"] = "m"
it["mixed"] = "x"
it["other"] = "o"
it["parody"] = "p"
it["reclass"] = "r"
}
fun launchUpdate() = launch {
fun namespaceToPrefix(namespace: String): String? {
return NAMESPACE_TO_PREFIX[namespace]
}
private fun getMetadata(context: Context): Array<String>? {
return 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)
}
private fun getFileContent(file: File): String? {
return runCatching {
file.source().buffer().use { it.readString(StandardCharsets.UTF_8) }
}.getOrNull()
}
private fun getFileSha1(file: File): String? {
return runCatching {
file.source().buffer().use { source ->
sha1(blackholeSink()).use {
source.readAll(it)
it.hash.hex()
}
}
}.getOrNull()
}
private fun checkData(sha1: String?, data: File): Boolean {
return sha1 != null && sha1 == getFileSha1(data)
}
private suspend fun save(client: OkHttpClient, url: String, file: File): Boolean {
val request: Request = Request.Builder().url(url).build()
val call = client.newCall(request)
runCatching {
call.executeAsync().use { response ->
if (!response.isSuccessful) {
return false
}
response.body.use {
it.copyToFile(file)
}
return true
}
}.onFailure {
file.delete()
it.printStackTrace()
}
return false
}
suspend fun update() {
updateLock.withLock {
runSuspendCatching {
atomicallyUpdate()
}.onFailure {
logcat(it)
updateInternal()
}
}
private fun issueUpdateInMemoryData(file: File? = null) {
val dataFile = file ?: getMetadata(appCtx)?.let { metadata ->
val dataName = metadata[2]
val dir = AppConfig.getFilesDir("tag-translations")
File(dir, dataName).takeIf { it.exists() }
} ?: return
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
initialized = true
}
init {
launch {
updateLock.withLock {
runSuspendCatching {
issueUpdateInMemoryData()
}.onFailure {
it.printStackTrace()
}
updateInternal()
}
}
}
private suspend fun atomicallyUpdate() {
val (sha1Name, sha1Url, dataName, dataUrl) = metadata(appCtx)
val workdir = AppConfig.tagTranslationsDir
val sha1File = workdir / sha1Name
val dataFile = workdir / dataName
private suspend fun updateInternal() {
getMetadata(appCtx)?.let { metadata ->
val sha1Name = metadata[0]
val sha1Url = metadata[1]
val dataName = metadata[2]
val dataUrl = metadata[3]
if (!initialized) {
Either.catch {
tagGroups = dataFile.read(Source::parseAs)
initialized = true
val dir = AppConfig.getFilesDir("tag-translations")
checkNotNull(dir)
val sha1File = File(dir, sha1Name)
val dataFile = File(dir, dataName)
// Perform only one automatic update (on app startup) per day
val lastModified = Instant.fromEpochMilliseconds(dataFile.metadataOrNull()!!.lastModifiedAtMillis!!)
if (Clock.System.now() - lastModified < 1.days) return
}.onLeft {
logcat(it)
runSuspendCatching {
// Check current sha1 and current data
val sha1 = getFileContent(sha1File)
if (!checkData(sha1, dataFile)) {
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
}
// Save new sha1
val tempSha1File = File(dir, "$sha1Name.tmp")
check(save(baseOkHttpClient, sha1Url, tempSha1File))
val tempSha1 = getFileContent(tempSha1File)
// Check new sha1 and current sha1
if (tempSha1 == sha1) {
// The data is the same
FileUtils.delete(tempSha1File)
return
}
// Save new data
val tempDataFile = File(dir, "$dataName.tmp")
check(save(baseOkHttpClient, dataUrl, tempDataFile))
// Check new sha1 and new data
if (!checkData(tempSha1, tempDataFile)) {
FileUtils.delete(tempSha1File)
FileUtils.delete(tempDataFile)
return
}
// Replace current sha1 and current data with new sha1 and new data
FileUtils.delete(sha1File)
FileUtils.delete(dataFile)
tempSha1File.renameTo(sha1File)
tempDataFile.renameTo(dataFile)
// Read new EhTagDatabase
issueUpdateInMemoryData(dataFile)
}.onFailure {
it.printStackTrace()
}
}
val currentSha1 = if (initialized) {
Either.catch { sha1File.utf8() }.getOrNull()
} else {
null
}
val newSha1 = fetch(sha1Url)
if (newSha1 == currentSha1) return
val rawData = fetch(dataUrl)
tagGroups = json.parseToJsonElement(rawData).jsonObject["data"]!!.jsonArray.associate { e ->
val data = e.jsonObject
val namespace = data["namespace"]!!.jsonPrimitive.content
val prefix = if (namespace == "rows") NAMESPACE_PREFIX else TagNamespace.from(namespace)!!.prefix!!
prefix to data["data"]!!.jsonObject.mapValues { it.value.jsonObject["name"]!!.jsonPrimitive.content }
}
initialized = true
sha1File.write { writeString(newSha1) }
dataFile.write { json.encodeToSink(tagGroups, this) }
}
}
@@ -16,10 +16,7 @@
package com.hippo.ehviewer.client
import com.hippo.ehviewer.Settings
import io.ktor.http.Parameters
import io.ktor.http.ParametersBuilder
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import okhttp3.HttpUrl
object EhUrl {
const val SITE_E = 0
@@ -52,117 +49,106 @@ object EhUrl {
const val REFERER_E = "https://$DOMAIN_E"
const val ORIGIN_E = REFERER_E
val host: String
get() = when (Settings.gallerySite) {
SITE_E -> HOST_E
SITE_EX -> HOST_EX
else -> HOST_E
}
val domain: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> DOMAIN_E
SITE_EX -> DOMAIN_EX
else -> DOMAIN_E
else -> HOST_E
}
val apiUrl: String
get() = when (Settings.gallerySite.value) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
get() = if (Settings.forceEhThumb) {
API_E
} else {
when (Settings.gallerySite) {
SITE_E -> API_E
SITE_EX -> API_EX
else -> API_E
}
}
val referer: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> REFERER_E
SITE_EX -> REFERER_EX
else -> REFERER_E
}
val origin: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> ORIGIN_E
SITE_EX -> ORIGIN_EX
else -> ORIGIN_E
}
fun getUConfigUrl(gallerySite: Int = Settings.gallerySite.value) = when (gallerySite) {
SITE_E -> URL_UCONFIG_E
SITE_EX -> URL_UCONFIG_EX
else -> URL_UCONFIG_E
}
val uConfigUrl: String
get() = when (Settings.gallerySite) {
SITE_E -> URL_UCONFIG_E
SITE_EX -> URL_UCONFIG_EX
else -> URL_UCONFIG_E
}
val myTagsUrl: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> URL_MY_TAGS_E
SITE_EX -> URL_MY_TAGS_EX
else -> URL_MY_TAGS_E
}
val popularUrl: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> URL_POPULAR_E
SITE_EX -> URL_POPULAR_EX
else -> URL_POPULAR_E
}
val imageSearchUrl: String
get() = when (Settings.gallerySite.value) {
get() = when (Settings.gallerySite) {
SITE_E -> URL_IMAGE_SEARCH_E
SITE_EX -> URL_IMAGE_SEARCH_EX
else -> URL_IMAGE_SEARCH_E
}
fun getGalleryDetailUrl(gid: Long, token: String, index: Int = 0, allComment: Boolean = false) = ehUrl(listOf("g", "$gid", token)) {
fun getGalleryDetailUrl(gid: Long, token: String?): String {
return getGalleryDetailUrl(gid, token, 0, false)
}
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl {
addPathSegments("g/$gid/$token/")
if (index != 0) addQueryParameter("p", "$index")
if (allComment) addQueryParameter("hc", "1")
}.buildString()
}.toString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl(listOf("mpv", "$gid", token)).buildString()
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
addPathSegments("mpv/$gid/$token/")
}.toString()
fun getPageUrl(gid: Long, index: Int, pToken: String, nl: String? = null) = ehUrl(listOf("s", pToken, "$gid-${index + 1}")) {
addQueryParameterIfNotBlank("nl", nl)
}.buildString()
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
}
fun getAddFavorites(gid: Long, token: String) = ehUrl("gallerypopups.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("t", token)
addQueryParameter("act", "addfav")
}.buildString()
fun getAddFavorites(gid: Long, token: String?): String {
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
}
fun getArchiveUrl(gid: Long, token: String) = ehUrl("archiver.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("token", token)
}.buildString()
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
}
fun getTorrentUrl(gid: Long, token: String) = ehUrl("gallerytorrents.php") {
addQueryParameter("gid", "$gid")
addQueryParameter("t", token)
}.buildString()
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
fun getTrackerUrl(gid: Long, key: String?) = ehUrl(
path = listOfNotNull("$gid", key, "announce"),
host = "ehtracker.org",
).buildString()
fun getTagDefinitionUrl(tag: String): String {
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
}
}
inline fun ehUrl(path: String? = null, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
host = host,
parameters = Parameters.build(builder),
)
inline fun ehUrl(path: List<String>, host: String = EhUrl.domain, builder: ParametersBuilder.() -> Unit) = ehUrl(
path = path,
host = host,
parameters = Parameters.build(builder),
)
fun ehUrl(path: List<String>, host: String = EhUrl.domain, parameters: Parameters = Parameters.Empty) = URLBuilder(
protocol = URLProtocol.HTTPS,
host = host,
pathSegments = path,
parameters = parameters,
)
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) append(name, value)
inline fun httpsUrl(builder: HttpUrl.Builder.() -> Unit) = HttpUrl.Builder().apply(builder).scheme("https").build()
inline fun ehUrl(builder: HttpUrl.Builder.() -> Unit) = httpsUrl { apply(builder).host(EhUrl.domain) }
fun HttpUrl.Builder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
if (!value.isNullOrBlank()) addQueryParameter(name, value)
}
fun ParametersBuilder.addQueryParameter(name: String, value: String) = append(name, value)
@@ -15,36 +15,19 @@
*/
package com.hippo.ehviewer.client
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Environment
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.LocalContentColor
import android.annotation.SuppressLint
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.net.toUri
import arrow.core.memoize
import com.ehviewer.core.model.GalleryDetail
import com.ehviewer.core.model.GalleryInfo
import com.ehviewer.core.network.EhCookieStore
import com.ehviewer.core.util.withUIContext
import com.google.android.material.color.utilities.Hct
import com.google.android.material.color.utilities.MathUtils
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.parser.Archive
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.addTextToClipboard
import com.materialkolor.hct.Hct
import com.materialkolor.ktx.from
import com.materialkolor.ktx.toColor
import com.materialkolor.utils.ColorUtils.lstarFromArgb
import splitties.systemservices.downloadManager
import com.hippo.ehviewer.client.data.GalleryInfo
import java.util.regex.Pattern
object EhUtils {
const val NONE = -1 // Use it for homepage
@@ -76,14 +59,14 @@ object EhUtils {
private const val BG_COLOR_UNKNOWN = 0xff000000u
// Remove [XXX], (XXX), {XXX}, ~XXX~ stuff
private val PATTERN_TITLE_PREFIX = Regex(
"""^(?:\([^)]*\)|\[[^]]*]|\{[^}]*\}|~[^~]*~|\s+)*""",
private val PATTERN_TITLE_PREFIX = Pattern.compile(
"^(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*",
)
// Remove [XXX], (XXX), {XXX}, ~XXX~ stuff and something like ch. 1-23
private val PATTERN_TITLE_SUFFIX = Regex(
"""(?:\s+ch.[\s\d-]+)?(?:\([^)]*\)|\[[^]]*]|\{[^}]*\}|~[^~]*~|\s+)*$""",
RegexOption.IGNORE_CASE,
private val PATTERN_TITLE_SUFFIX = Pattern.compile(
"(?:\\s+ch.[\\s\\d-]+)?(?:\\([^)]*\\)|\\[[^]]*]|\\{[^}]*\\}|~[^~]*~|\\s+)*$",
Pattern.CASE_INSENSITIVE,
)
private val CATEGORY_VALUES = hashMapOf(
@@ -103,121 +86,106 @@ object EhUtils {
private val CATEGORY_STRINGS = CATEGORY_VALUES.entries.map { (k, v) -> v to k }
val isExHentai: Boolean
get() = Settings.gallerySite.value == EhUrl.SITE_EX
val isMpvAvailable
get() = EhCookieStore.getHathPerks()?.contains('q') == true
get() = Settings.gallerySite == EhUrl.SITE_EX
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 = CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
fun getCategory(type: Int): String {
return CATEGORY_VALUES.getOrDefault(type, CATEGORY_VALUES[UNKNOWN])!![0]
}
fun invCategory(category: Int): Int = category.inv() and ALL_CATEGORY
val mergeColor = { primaryContainer: Color, src: Color ->
val fromHct = Hct.from(src)
val toHct = Hct.from(primaryContainer)
Hct.from(fromHct.hue, toHct.chroma, toHct.tone).toColor()
@SuppressLint("RestrictedApi")
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
val fromHct = Hct.fromInt(src)
val toHct = Hct.fromInt(primaryContainer)
val differenceDegrees = MathUtils.differenceDegrees(fromHct.hue, toHct.hue)
val rotationDegrees = minOf(differenceDegrees * 0.5, 15.0)
val outputHue = MathUtils.sanitizeDegreesDouble(fromHct.hue + rotationDegrees * MathUtils.rotationDirection(fromHct.hue, toHct.hue))
Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
}.memoize()
@Stable
@ReadOnlyComposable
@Composable
fun getCategoryColor(category: Int): Color {
val primary = Color(
when (category) {
DOUJINSHI -> BG_COLOR_DOUJINSHI
MANGA -> BG_COLOR_MANGA
ARTIST_CG -> BG_COLOR_ARTIST_CG
GAME_CG -> BG_COLOR_GAME_CG
WESTERN -> BG_COLOR_WESTERN
NON_H -> BG_COLOR_NON_H
IMAGE_SET -> BG_COLOR_IMAGE_SET
COSPLAY -> BG_COLOR_COSPLAY
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
MISC -> BG_COLOR_MISC
else -> BG_COLOR_UNKNOWN
}.toInt(),
)
return if (Settings.harmonizeCategoryColor.value) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
mergeColor(primaryContainer, primary)
val primary = when (category) {
DOUJINSHI -> BG_COLOR_DOUJINSHI
MANGA -> BG_COLOR_MANGA
ARTIST_CG -> BG_COLOR_ARTIST_CG
GAME_CG -> BG_COLOR_GAME_CG
WESTERN -> BG_COLOR_WESTERN
NON_H -> BG_COLOR_NON_H
IMAGE_SET -> BG_COLOR_IMAGE_SET
COSPLAY -> BG_COLOR_COSPLAY
ASIAN_PORN -> BG_COLOR_ASIAN_PORN
MISC -> BG_COLOR_MISC
else -> BG_COLOR_UNKNOWN
}.toInt()
val color = if (Settings.harmonizeCategoryColor) {
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
harmonizeWithRole(primaryContainer, primary)
} else {
primary
}
return Color(color)
}
@ReadOnlyComposable
@Composable
fun getCategoryTextColor(color: Color) = if (isSystemInDarkTheme() == lstarFromArgb(color.toArgb()) > 70) {
MaterialTheme.colorScheme.surface
} else {
LocalContentColor.current
}
val favoriteIconColor = Color(0xffff3040)
val categoryTextColor = Color(0xffe6e0e9)
fun signOut() {
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
Settings.hasSignedIn.value = false
Settings.gallerySite.value = EhUrl.SITE_E
Settings.needSignIn.value = true
EhCookieStore.signOut()
Settings.avatar = null
Settings.displayName = null
Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = true
}
fun getSuitableTitle(gi: GalleryInfo): String = if (Settings.showJpnTitle.value) {
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 {
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 extractTitle(fullTitle: String?): String? {
var title: String = fullTitle ?: return null
title = PATTERN_TITLE_PREFIX.replaceFirst(title, "")
title = PATTERN_TITLE_SUFFIX.replaceFirst(title, "")
title = PATTERN_TITLE_PREFIX.matcher(title).replaceFirst("")
title = PATTERN_TITLE_SUFFIX.matcher(title).replaceFirst("")
// Sometimes title is combined by romaji and english translation.
// Only need romaji.
// TODO But not sure every '|' means that
return title.substringBeforeLast('|').trim().ifEmpty { null }
val index = title.indexOf('|')
if (index >= 0) {
title = title.substring(0, index)
}
return title.ifEmpty { null }
}
context(ctx: Context)
suspend fun downloadArchive(galleryDetail: GalleryDetail, archive: Archive) {
val gid = galleryDetail.gid
EhEngine.downloadArchive(gid, galleryDetail.token, archive.res, archive.isHath)?.let {
val uri = it.toUri()
val intent = Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "application/zip")
}
val name = "$gid-${getSuitableTitle(galleryDetail)}.zip"
try {
ctx.startActivity(intent)
withUIContext { addTextToClipboard(name, true) }
} catch (_: ActivityNotFoundException) {
val r = DownloadManager.Request(uri)
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
)
r.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
downloadManager.enqueue(r)
}
if (Settings.archiveMetadata.value) {
SpiderDen(galleryDetail).apply {
initDownloadDir()
writeComicInfo()
}
}
fun handleThumbUrlResolution(url: String?): String? {
if (null == url) {
return null
}
val resolution = when (Settings.thumbResolution) {
0 -> return url
1 -> "250"
2 -> "300"
else -> return url
}
val index1 = url.lastIndexOf('_')
val index2 = url.lastIndexOf('.')
return if (index1 >= 0 && index2 >= 0 && index1 < index2) {
url.substring(0, index1 + 1) + resolution + url.substring(index2)
} else {
url
}
}
}
@@ -0,0 +1,14 @@
package com.hippo.ehviewer.client.data
interface AbstractDownloadInfo {
var state: Int
var legacy: Int
var time: Long
var label: String?
var position: Int
var speed: Long
var remaining: Long
var finished: Int
var downloaded: Int
var total: Int
}
@@ -0,0 +1,89 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
@Parcelize
@Entity(tableName = "GALLERIES")
data class BaseGalleryInfo @Keep constructor(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long = 0,
@ColumnInfo(name = "TOKEN")
override var token: String? = null,
@ColumnInfo(name = "TITLE")
override var title: String? = null,
@ColumnInfo(name = "TITLE_JPN")
override var titleJpn: String? = null,
@ColumnInfo(name = "THUMB")
override var thumbKey: String? = null,
@ColumnInfo(name = "CATEGORY")
override var category: Int = 0,
@ColumnInfo(name = "POSTED")
override var posted: String? = null,
@ColumnInfo(name = "UPLOADER")
override var uploader: String? = null,
@Ignore
override var disowned: Boolean = false,
@ColumnInfo(name = "RATING")
override var rating: Float = 0f,
@Ignore
override var rated: Boolean = false,
@Ignore
override var simpleTags: ArrayList<String>? = null,
@Ignore
override var pages: Int = 0,
@Ignore
override var thumbWidth: Int = 0,
@Ignore
override var thumbHeight: Int = 0,
@ColumnInfo(name = "SIMPLE_LANGUAGE")
override var simpleLanguage: String? = null,
@ColumnInfo(name = "FAVORITE_SLOT")
override var favoriteSlot: Int = NOT_FAVORITED,
@Ignore
override var favoriteName: String? = null,
@Ignore
override var favoriteNote: String? = null,
) : GalleryInfo, Parcelable {
constructor() : this(0)
}
@@ -15,43 +15,43 @@
*/
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import kotlinx.serialization.Serializable
import kotlinx.parcelize.Parcelize
@Serializable
@Parcelize
data class FavListUrlBuilder(
val favCat: Int = FAV_CAT_ALL,
val keyword: String? = null,
private var jumpTo: String? = null,
private var prev: String? = null,
private var next: String? = null,
) {
val isLocal
get() = favCat == FAV_CAT_LOCAL
var jumpTo: String? = null,
var prev: String? = null,
var next: String? = null,
) : Parcelable {
fun setIndex(index: String?, isNext: Boolean) {
next = index.takeIf { isNext }
prev = index.takeUnless { isNext }
jumpTo = null
}
fun build() = ehUrl(EhUrl.FAV_PATH) {
when {
isValidFavCat(favCat) -> addQueryParameter("favcat", "$favCat")
favCat == FAV_CAT_ALL -> addQueryParameter("favcat", "all")
fun build() = ehUrl {
addPathSegment(EhUrl.FAV_PATH)
if (isValidFavCat(favCat)) {
addQueryParameter("favcat", "$favCat")
} else if (favCat == FAV_CAT_ALL) {
addQueryParameter("favcat", "all")
}
addQueryParameterIfNotBlank("f_search", keyword)
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
}.buildString()
}.toString()
companion object {
const val FAV_CAT_ALL = -1
const val FAV_CAT_LOCAL = -2
fun isValidFavCat(favCat: Int) = favCat in 0..9
fun isValidFavCat(favCat: Int): Boolean {
return favCat in 0..9
}
}
}
@@ -13,11 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ehviewer.core.model
package com.hippo.ehviewer.client.data
import kotlinx.serialization.Serializable
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Serializable
@Parcelize
data class GalleryComment(
val id: Long,
val score: Int,
@@ -32,4 +33,4 @@ data class GalleryComment(
val user: String?,
val comment: String,
val lastEdited: Long,
)
) : Parcelable
@@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ehviewer.core.model
package com.hippo.ehviewer.client.data
import kotlinx.serialization.Serializable
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Serializable
@Parcelize
data class GalleryCommentList(
val comments: List<GalleryComment>,
val hasMore: Boolean,
)
) : Parcelable
@@ -15,14 +15,9 @@
*/
package com.hippo.ehviewer.client.data
import androidx.compose.ui.util.fastFlatMap
import com.ehviewer.core.model.GalleryDetail
import com.ehviewer.core.model.GalleryInfo
import com.ehviewer.core.model.GalleryTagGroup
import com.ehviewer.core.model.PowerStatus
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import android.os.Parcelable
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANGS
import kotlinx.parcelize.Parcelize
private val LANGUAGES = arrayOf(
"English",
@@ -41,27 +36,28 @@ private val LANGUAGES = arrayOf(
"Dutch",
)
suspend fun GalleryDetail.fillInfo() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = GalleryInfo.S_LANGS[index]
simpleTags = tagGroups.fastFlatMap(GalleryTagGroup::tags).map { (text, power, _) ->
if (power == PowerStatus.Weak) "_$text" else text
}
if (favoriteSlot == GalleryInfo.NOT_FAVORITED && EhDB.containLocalFavorites(gid)) {
favoriteSlot = GalleryInfo.LOCAL_FAVORITED
}
}
suspend fun GalleryDetail.filterComments() {
comments = with(comments) {
val scoreThreshold = Settings.commentThreshold.value
copy(
comments = comments.filter {
it.uploader ||
it.score > scoreThreshold &&
!EhFilter.filterCommenter(it.user.orEmpty()) &&
!EhFilter.filterComment(it.comment)
},
)
@Parcelize
data class GalleryDetail(
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
var apiUid: Long = -1L,
var apiKey: String? = null,
var torrentCount: Int = 0,
var torrentUrl: String? = null,
var archiveUrl: String? = null,
var parent: String? = null,
var newerVersions: ArrayList<BaseGalleryInfo> = ArrayList(),
var visible: String? = null,
var language: String? = null,
var size: String? = null,
var favoriteCount: Int = 0,
var ratingCount: Int = 0,
val tags: List<GalleryTagGroup>,
var comments: GalleryCommentList,
val previewPages: Int,
val previewList: List<GalleryPreview>,
) : GalleryInfo by galleryInfo, Parcelable {
override fun generateSLang() {
val index = LANGUAGES.indexOf(language)
if (index != -1) simpleLanguage = S_LANGS[index]
}
}
@@ -13,11 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ehviewer.core.model
package com.hippo.ehviewer.client.data
import java.util.regex.Pattern
import moe.tarsin.kt.unreachable
interface GalleryInfo {
var gid: Long
var token: String
var token: String?
var title: String?
var titleJpn: String?
var thumbKey: String?
@@ -27,7 +30,7 @@ interface GalleryInfo {
var disowned: Boolean
var rating: Float
var rated: Boolean
var simpleTags: List<String>?
var simpleTags: ArrayList<String>?
var pages: Int
var thumbWidth: Int
var thumbHeight: Int
@@ -59,38 +62,38 @@ interface GalleryInfo {
"NL",
)
private val S_LANG_PATTERNS = arrayOf(
Regex(
Pattern.compile(
"[(\\[]eng(?:lish)?[)\\]]|英訳",
RegexOption.IGNORE_CASE,
Pattern.CASE_INSENSITIVE,
),
// [((\[]ch(?:inese)?[))\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳
Regex(
Pattern.compile(
"[(\uFF08\\[]ch(?:inese)?[)\uFF09\\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳",
RegexOption.IGNORE_CASE,
Pattern.CASE_INSENSITIVE,
),
Regex(
Pattern.compile(
"[(\\[]spanish[)\\]]|[(\\[]Español[)\\]]|スペイン翻訳",
RegexOption.IGNORE_CASE,
Pattern.CASE_INSENSITIVE,
),
Regex("[(\\[]korean?[)\\]]|韓国翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]rus(?:sian)?[)\\]]|ロシア翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]fr(?:ench)?[)\\]]|フランス翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]portuguese|ポルトガル翻訳", RegexOption.IGNORE_CASE),
Regex(
Pattern.compile("[(\\[]korean?[)\\]]|韓国翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]rus(?:sian)?[)\\]]|ロシア翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]fr(?:ench)?[)\\]]|フランス翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]portuguese|ポルトガル翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile(
"[(\\[]thai(?: ภาษาไทย)?[)\\]]|แปลไทย|タイ翻訳",
RegexOption.IGNORE_CASE,
Pattern.CASE_INSENSITIVE,
),
Regex("[(\\[]german[)\\]]|ドイツ翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]italiano?[)\\]]|イタリア翻訳", RegexOption.IGNORE_CASE),
Regex(
Pattern.compile("[(\\[]german[)\\]]|ドイツ翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]italiano?[)\\]]|イタリア翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile(
"[(\\[]vietnamese(?: Tiếng Việt)?[)\\]]|ベトナム翻訳",
RegexOption.IGNORE_CASE,
Pattern.CASE_INSENSITIVE,
),
Regex("[(\\[]polish[)\\]]|ポーランド翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", RegexOption.IGNORE_CASE),
Regex("[(\\[]dutch[)\\]]|オランダ翻訳", RegexOption.IGNORE_CASE),
Pattern.compile("[(\\[]polish[)\\]]|ポーランド翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]hun(?:garian)?[)\\]]|ハンガリー翻訳", Pattern.CASE_INSENSITIVE),
Pattern.compile("[(\\[]dutch[)\\]]|オランダ翻訳", Pattern.CASE_INSENSITIVE),
)
val S_LANG_TAGS = arrayOf(
private val S_LANG_TAGS = arrayOf(
"language:english",
"language:chinese",
"language:spanish",
@@ -120,7 +123,7 @@ interface GalleryInfo {
private fun generateSLangFromTitle(title: String): String? {
for (i in S_LANGS.indices) {
if (S_LANG_PATTERNS[i].containsMatchIn(title)) {
if (S_LANG_PATTERNS[i].matcher(title).find()) {
return S_LANGS[i]
}
}
@@ -131,3 +134,15 @@ interface GalleryInfo {
const val LOCAL_FAVORITED = -1
}
}
fun GalleryInfo.findBaseInfo(): BaseGalleryInfo {
return when (this) {
is BaseGalleryInfo -> this
is GalleryDetail -> galleryInfo
else -> unreachable()
}
}
fun GalleryInfo.asGalleryDetail(): GalleryDetail? {
return this as? GalleryDetail
}
@@ -13,41 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ehviewer.core.model
package com.hippo.ehviewer.client.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.CborArray
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Serializable
sealed interface GalleryPreview {
val url: String
interface GalleryPreview : Parcelable {
val imageKey: String
val position: Int
val pToken: String
}
@Serializable
@SerialName("V1")
data class V1GalleryPreview(
override val url: String,
@Parcelize
data class LargeGalleryPreview(
override val imageKey: String,
override val position: Int,
override val pToken: String,
) : GalleryPreview
@Serializable
@SerialName("V2")
data class V2GalleryPreview(
override val url: String,
@Parcelize
data class NormalGalleryPreview(
override val imageKey: String,
override val position: Int,
override val pToken: String,
val offsetX: Int,
val clipWidth: Int,
val clipHeight: Int,
) : GalleryPreview
@Serializable
@CborArray
class GalleryPreviewList(
val previews: List<GalleryPreview>,
val total: Int,
)
@@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ehviewer.core.model
package com.hippo.ehviewer.client.data
import kotlinx.serialization.Serializable
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Serializable
@Parcelize
data class GalleryTagGroup(
val namespace: TagNamespace,
val tags: List<GalleryTag>,
)
val groupName: String,
private val tagList: MutableList<String> = mutableListOf(),
) : Parcelable, MutableList<String> by tagList
@@ -15,58 +15,47 @@
*/
package com.hippo.ehviewer.client.data
import com.ehviewer.core.database.model.QuickSearch
import com.ehviewer.core.model.GalleryInfo
import com.hippo.ehviewer.Settings
import android.os.Parcelable
import androidx.annotation.IntDef
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.addQueryParameter
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
import com.hippo.ehviewer.client.ehUrl
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.ui.main.AdvanceTable
import io.ktor.http.Parameters
import kotlinx.serialization.Serializable
import com.hippo.ehviewer.util.encodeUTF8
import com.hippo.ehviewer.util.toIntOrDefault
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
import kotlinx.parcelize.Parcelize
@Serializable
@Parcelize
data class ListUrlBuilder(
@get:Mode @Mode
var mode: Int = MODE_NORMAL,
private var prev: String? = null,
private var next: String? = null,
private var jumpTo: String? = null,
private var range: Int = 0,
private var mPrev: String? = null,
var mNext: String? = null,
// Reset to null after initial loading
var mJumpTo: String? = null,
var category: Int = EhUtils.NONE,
private var mKeyword: String? = null,
var hash: String? = null,
var language: Int = -1,
var advanceSearch: Int = -1,
var minRating: Int = -1,
var pageFrom: Int = -1,
var pageTo: Int = -1,
) {
var imagePath: String? = null,
var isUseSimilarityScan: Boolean = false,
var isOnlySearchCovers: Boolean = false,
) : Parcelable {
fun setIndex(index: String, isNext: Boolean = true) {
next = index.takeIf { isNext }
prev = index.takeUnless { isNext }
range = 0
jumpTo = null
fun setIndex(index: String?, isNext: Boolean = true) {
mNext = index?.takeIf { isNext }
mPrev = index?.takeUnless { isNext }
}
var page: Int
get() = (jumpTo?.toInt() ?: 0) + 1
set(value) {
jumpTo = (value - 1).takeUnless { it == 0 }?.toString()
}
fun setRange(to: Int) {
range = to
prev = null
next = null
jumpTo = null
}
fun setSeek(date: String) {
setIndex("2", true)
jumpTo = date
fun setJumpTo(jumpTo: Int) {
mJumpTo = jumpTo.takeUnless { it == 0 }?.toString()
}
var keyword: String?
@@ -75,27 +64,29 @@ data class ListUrlBuilder(
mKeyword = keyword
}
constructor(q: QuickSearch) : this(
mode = q.mode,
category = q.category,
mKeyword = q.keyword,
advanceSearch = q.advanceSearch,
minRating = q.minRating,
pageFrom = q.pageFrom,
pageTo = q.pageTo,
next = q.name.substringAfterLast('@', "").ifEmpty { null },
)
constructor(q: QuickSearch) : this() {
mode = q.mode
this.category = q.category
mKeyword = q.keyword
advanceSearch = q.advanceSearch
minRating = q.minRating
pageFrom = q.pageFrom
pageTo = q.pageTo
mNext = q.name.substringAfterLast('@', "").takeIf { it.isNotEmpty() }
}
fun toQuickSearch(name: String) = QuickSearch(
name = name,
mode = mode,
category = category,
keyword = mKeyword,
advanceSearch = advanceSearch,
minRating = minRating,
pageFrom = pageFrom,
pageTo = pageTo,
)
fun toQuickSearch(name: String): QuickSearch {
return 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) {
@@ -123,115 +114,270 @@ data class ListUrlBuilder(
}
}
constructor(params: Parameters) : this(
prev = params["prev"],
next = params["next"],
jumpTo = params["seek"],
range = params["range"]?.toIntOrNull() ?: 0,
category = params["f_cats"]?.toIntOrNull()?.let(EhUtils::invCategory) ?: EhUtils.NONE,
mKeyword = params["f_search"],
) {
if (params["advsearch"] == "1") {
advanceSearch = 0
if (params["f_sh"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SH
/**
* @param query xxx=yyy&mmm=nnn
*/
constructor(query: String?) : this() {
// TODO page
if (query.isNullOrEmpty()) {
return
}
val queries = query.split('&')
var category = 0
var keyword: String? = null
var enableAdvanceSearch = false
var advanceSearch = 0
var enableMinRating = false
var minRating = -1
var enablePage = false
var pageFrom = -1
var pageTo = -1
for (str in queries) {
val index = str.indexOf('=')
if (index < 0) {
continue
}
if (params["f_sto"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.STO
val key = str.substring(0, index)
val value = str.substring(index + 1)
when (key) {
"f_cats" -> {
val cats = value.toIntOrDefault(EhUtils.ALL_CATEGORY)
category = category or (cats.inv() and EhUtils.ALL_CATEGORY)
}
"f_doujinshi" -> if ("1" == value) {
category = category or EhUtils.DOUJINSHI
}
"f_manga" -> if ("1" == value) {
category = category or EhUtils.MANGA
}
"f_artistcg" -> if ("1" == value) {
category = category or EhUtils.ARTIST_CG
}
"f_gamecg" -> if ("1" == value) {
category = category or EhUtils.GAME_CG
}
"f_western" -> if ("1" == value) {
category = category or EhUtils.WESTERN
}
"f_non-h" -> if ("1" == value) {
category = category or EhUtils.NON_H
}
"f_imageset" -> if ("1" == value) {
category = category or EhUtils.IMAGE_SET
}
"f_cosplay" -> if ("1" == value) {
category = category or EhUtils.COSPLAY
}
"f_asianporn" -> if ("1" == value) {
category = category or EhUtils.ASIAN_PORN
}
"f_misc" -> if ("1" == value) {
category = category or EhUtils.MISC
}
"f_search" -> try {
keyword = URLDecoder.decode(value, "utf-8")
} catch (e: UnsupportedEncodingException) {
// Ignore
} catch (_: IllegalArgumentException) {
}
"advsearch" -> if ("1" == value) {
enableAdvanceSearch = true
}
"f_sh" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SH
}
"f_sto" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.STO
}
"f_sfl" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFL
}
"f_sfu" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFU
}
"f_sft" -> if ("on" == value) {
advanceSearch = advanceSearch or AdvanceTable.SFT
}
"f_sr" -> if ("on" == value) {
enableMinRating = true
}
"f_srdd" -> minRating = value.toIntOrDefault(-1)
"f_sp" -> if ("on" == value) {
enablePage = true
}
"f_spf" -> pageFrom = value.toIntOrDefault(-1)
"f_spt" -> pageTo = value.toIntOrDefault(-1)
"f_shash" -> hash = value
}
if (params["f_sfl"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFL
}
this.category = category
mKeyword = keyword
if (enableAdvanceSearch) {
this.advanceSearch = advanceSearch
if (enableMinRating) {
this.minRating = minRating
} else {
this.minRating = -1
}
if (params["f_sfu"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFU
if (enablePage) {
this.pageFrom = pageFrom
this.pageTo = pageTo
} else {
this.pageFrom = -1
this.pageTo = -1
}
if (params["f_sft"] == "on") {
advanceSearch = advanceSearch or AdvanceTable.SFT
}
minRating = params["f_srdd"]?.toIntOrNull() ?: -1
pageFrom = params["f_spf"]?.toIntOrNull() ?: -1
pageTo = params["f_spt"]?.toIntOrNull() ?: -1
} else {
this.advanceSearch = -1
}
}
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", "${EhUtils.invCategory(category)}")
}
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
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl {
if (mode == MODE_SUBSCRIPTION) addPathSegment(EhUrl.WATCHED_PATH)
if (category != EhUtils.NONE) {
addEncodedQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
addQueryParameterIfNotBlank("f_shash", hash)
addQueryParameterIfNotBlank("prev", mPrev)
addQueryParameterIfNotBlank("next", mNext)
addQueryParameterIfNotBlank("seek", mJumpTo)
// Search key
// the settings of ub:UrlBuilder may be overwritten by following Advance search
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
val keyword = kwd.trim { it <= ' ' }
when (idx) {
0 -> addQueryParameterIfNotBlank("f_search", keyword)
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
val key = keyword.substring(0, this).trim { it <= ' ' }
val value = keyword.substring(this + 1).trim { it <= ' ' }
addQueryParameterIfNotBlank(key, value)
}
}
}
// Advance search
if (advanceSearch != -1) {
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 != -1) {
addQueryParameter("f_sr", "on")
addQueryParameter("f_srdd", "$minRating")
}
// Pages
if (pageFrom != -1 || pageTo != -1) {
addQueryParameter("f_sp", "on")
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeUnless { it == -1 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeUnless { it == -1 }?.toString(),
)
}
}
}.toString()
MODE_UPLOADER -> {
buildString {
append(EhUrl.host)
mKeyword?.let {
append("uploader/").append(encodeUTF8(it))
}
mPrev?.let {
append("?prev=").append(it)
}
mNext?.let {
append("?next=").append(it)
}
mJumpTo?.let {
append("&seek=").append(it)
}
}
}
addQueryParameterIfNotBlank("f_search", query)
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
// Advance search
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
addQueryParameter("advsearch", "1")
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_srdd", "$minRating")
}
// Pages
if (pageFrom > 0 || pageTo > 0) {
addQueryParameterIfNotBlank(
"f_spf",
pageFrom.takeIf { it > 0 }?.toString(),
)
addQueryParameterIfNotBlank(
"f_spt",
pageTo.takeIf { it > 0 }?.toString(),
)
MODE_TAG -> {
buildString {
append(EhUrl.host)
mKeyword?.let {
append("tag/").append(encodeUTF8(it))
}
mPrev?.let {
append("?prev=").append(it)
}
mNext?.let {
append("?next=").append(it)
}
mJumpTo?.let {
append("&seek=").append(it)
}
}
}
}.buildString()
MODE_UPLOADER, MODE_TAG -> {
val path = if (mode == MODE_UPLOADER) "uploader" else "tag"
ehUrl(listOf(path, requireNotNull(mKeyword))) {
addQueryParameterIfNotBlank("prev", prev)
addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
addQueryParameterIfNotBlank("range", range.takeIf { it > 0 }?.toString())
}.buildString()
MODE_WHATS_HOT -> EhUrl.popularUrl
MODE_IMAGE_SEARCH -> EhUrl.imageSearchUrl
MODE_TOPLIST -> {
buildString {
append(EhUrl.HOST_E)
append("toplist.php?tl=")
append(mKeyword!!)
mJumpTo?.let {
append("&p=").append(it)
}
}
}
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", jumpTo)
}.buildString()
else -> throw IllegalStateException("Unexpected value: $mode")
}
@IntDef(
MODE_NORMAL,
MODE_UPLOADER,
MODE_TAG,
MODE_WHATS_HOT,
MODE_IMAGE_SEARCH,
MODE_SUBSCRIPTION,
MODE_TOPLIST,
)
@Retention(AnnotationRetention.SOURCE)
private annotation class Mode
companion object {
const val MODE_NORMAL = 0x0
const val MODE_UPLOADER = 0x1
@@ -1,5 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.ehviewer.core.i18n.R
import com.hippo.ehviewer.R
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
@@ -1,48 +0,0 @@
package com.hippo.ehviewer.client.exception
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.CborArray
@Serializable
sealed interface EhError {
@CborArray
@Serializable
@SerialName("NoHits")
data object NoHits : EhError
@CborArray
@Serializable
@SerialName("NoWatched")
data object NoWatched : EhError
@CborArray
@Serializable
@SerialName("NeedLogin")
data object NeedLogin : EhError
@CborArray
@Serializable
@SerialName("NoHathClient")
data object NoHathClient : EhError
@CborArray
@Serializable
@SerialName("InsufficientFunds")
data object InsufficientFunds : EhError
@CborArray
@Serializable
@SerialName("GalleryUnavailable")
data class GalleryUnavailable(val message: String) : EhError
@CborArray
@Serializable
@SerialName("IpBanned")
data class IpBanned(val message: String) : EhError
@CborArray
@Serializable
@SerialName("Error")
data class Error(val message: String) : EhError
}
@@ -1,13 +0,0 @@
package com.hippo.ehviewer.client.exception
import androidx.annotation.StringRes
import com.ehviewer.core.i18n.R
sealed class FatalException : EhException {
constructor(message: String) : super(message)
constructor(@StringRes messageId: Int) : super(messageId)
}
class IpBannedException(message: String) : FatalException(message)
class InsufficientGpException : FatalException(R.string.insufficient_gp)
class QuotaExceededException : FatalException(R.string.error_509)
@@ -1,5 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.ehviewer.core.i18n.R
import com.hippo.ehviewer.R
class InsufficientFundsException : EhException(R.string.insufficient_funds)
@@ -15,6 +15,4 @@
*/
package com.hippo.ehviewer.client.exception
import com.ehviewer.core.i18n.R
class NoHathClientException() : EhException(R.string.download_archive_failure_no_hath)
class NoHAtHClientException(detailMessage: String?) : EhException(detailMessage)
@@ -1,5 +0,0 @@
package com.hippo.ehviewer.client.exception
import com.ehviewer.core.i18n.R
class NoHitsFoundException : EhException(R.string.gallery_list_empty_hit)
@@ -1,5 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.ehviewer.core.i18n.R
import com.hippo.ehviewer.R
class NotLoggedInException : EhException(R.string.sign_in_first)
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.client.exception
import com.hippo.ehviewer.R
class QuotaExceededException : EhException(R.string.error_509)
@@ -15,43 +15,77 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either.Companion.catch
import arrow.core.getOrElse
import arrow.core.partially3
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import org.jsoup.Jsoup
object ArchiveParser {
fun parse(body: ByteBuffer, maybeFunds: Funds?) = catch {
if (maybeFunds != null) {
val archives = unmarshalParsingAs<List<Archive>>(body, ::parseArchives)
Result(archives, maybeFunds)
} else {
unmarshalParsingAs<Result>(body, ::parseArchives.partially3(true))
private val PATTERN_ARCHIVE_URL =
Regex("<strong>(.*)</strong>.*<a href=\"([^\"]*)\">Click Here To Start Downloading</a>")
private val PATTERN_CURRENT_FUNDS =
Regex("<p>([\\d,]+) GP \\[[^]]*] &nbsp; ([\\d,]+) Credits \\[[^]]*]</p>")
private val PATTERN_HATH_FORM =
Regex("<form id=\"hathdl_form\" action=\"[^\"]*?or=([^=\"]*?)\" method=\"post\">")
private val PATTERN_HATH_ARCHIVE =
Regex("<p><a href=\"[^\"]*\" onclick=\"return do_hathdl\\('([0-9]+|org)'\\)\">([^<]+)</a></p>\\s*<p>([\\w. ]+)</p>\\s*<p>([\\w. ]+)</p>")
private const val ERROR_NEED_HATH_CLIENT =
"You must have a H@H client assigned to your account to use this feature."
private const val ERROR_INSUFFICIENT_FUNDS =
"You do not have enough funds to download this archive."
fun parse(body: String): Result {
val m = PATTERN_HATH_FORM.find(body)!!
val paramOr = m.groupValues[1]
val archiveList = ArrayList<Archive>()
Jsoup.parse(body).select("#db>div>div").forEach { element ->
if (element.childrenSize() > 0 && !element.attr("style").contains("color:#CCCCCC")) {
runCatching {
val res = element.selectFirst("form>input")!!.attr("value")
val name = element.selectFirst("form>div>input")!!.attr("value")
val size = element.selectFirst("p>strong")!!.text()
val cost = element.selectFirst("div>strong")!!.text().replace(",", "")
Archive(res, name, size, cost, false)
}.onSuccess {
archiveList.add(it)
}.onFailure {
it.printStackTrace()
}
}
}
}.getOrElse {
throw ParseException("Can't parse archive list", it)
PATTERN_HATH_ARCHIVE.findAll(body).forEach { matchResult ->
val (res, name, size, cost) = matchResult.groupValues.slice(1..4)
.map { ParserUtils.trim(it) }
val item = Archive(res, name, size, cost, true)
archiveList.add(item)
}
val result = Result(paramOr, archiveList, null)
PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run {
val fundsGP = ParserUtils.parseInt(get(1), 0)
val fundsC = ParserUtils.parseInt(get(2), 0)
val funds = HomeParser.Funds(fundsGP, fundsC)
result.funds = funds
}
return result
}
fun parseArchiveUrl(body: ByteBuffer) = catch {
unmarshalParsingAs<String?>(body, ::parseArchiveUrl)
}.getOrElse {
throw ParseException("Can't parse archive url", it)
fun parseArchiveUrl(body: String): String? {
if (body.contains(ERROR_NEED_HATH_CLIENT)) {
throw NoHAtHClientException("No H@H client")
} else if (body.contains(ERROR_INSUFFICIENT_FUNDS)) {
throw InsufficientFundsException()
}
return Jsoup.parse(body).selectFirst("#continue>a[href]")
?.let { it.attr("href") + "?start=1" }
// TODO: Check more errors
}
@Serializable
data class Result(val archiveList: List<Archive>, val funds: Funds)
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
)
class Result(val paramOr: String?, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
}
@Serializable
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHath: Boolean,
)
private external fun parseArchives(body: ByteBuffer, size: Int = body.limit(), parseFunds: Boolean = false): Int
private external fun parseArchiveUrl(body: ByteBuffer, size: Int = body.limit()): Int
@@ -1,13 +0,0 @@
package com.hippo.ehviewer.client.parser
import io.ktor.util.moveToByteArray
import java.nio.ByteBuffer
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
inline fun <reified T> unmarshalParsingAs(body: ByteBuffer, parser: (ByteBuffer, Int) -> Int): T {
val cborBytes = parser(body, body.limit())
body.limit(cborBytes)
val array = body.moveToByteArray()
return Cbor.decodeFromByteArray<T>(array)
}
@@ -3,7 +3,4 @@ package com.hippo.ehviewer.client.parser
import kotlinx.serialization.Serializable
@Serializable
data class VoteTagResult(
val error: String?,
val tagpane: String?,
)
data class Error(val error: String)
@@ -17,10 +17,10 @@
*/
package com.hippo.ehviewer.client.parser
import java.nio.ByteBuffer
import org.jsoup.Jsoup
object EventPaneParser {
private external fun parseEventPane(body: ByteBuffer, size: Int = body.limit()): Int
fun parse(body: ByteBuffer): String? = unmarshalParsingAs(body, ::parseEventPane)
fun parse(body: String): String? {
return Jsoup.parse(body).getElementById("eventpane")?.html()
}
}
@@ -1,33 +1,32 @@
package com.hippo.ehviewer.client.parser
import com.ehviewer.core.util.unescapeXml
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@Serializable
data class FavParserResult(
val catArray: List<String>,
val countArray: List<Int>,
val galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
object FavoritesParser {
private val NoteRegex = Regex("<textarea[^>]*>([^<]*)</textarea>")
fun parse(body: ByteBuffer): FavParserResult = runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
throw ParseException("Parse favorites error", it)
suspend fun parse(body: ByteBuffer): Result {
val catArray = arrayOfNulls<String>(10)
val countArray = runSuspendCatching {
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
val result = GalleryListParser.parse(body)
return Result(catArray.requireNoNulls(), countArray, result)
}
fun parseNote(body: String) = NoteRegex.find(body)?.run { groupValues[1].unescapeXml() }
?: throw ParseException("Can't parse favorite note")
class Result(
val catArray: Array<String>,
val countArray: IntArray,
galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
@@ -0,0 +1,35 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.util.ExceptionUtils
import org.jsoup.Jsoup
object ForumsParser {
fun parse(body: String): String {
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()
}
}
}
@@ -15,11 +15,12 @@
*/
package com.hippo.ehviewer.client.parser
import com.ehviewer.core.model.GalleryInfo
import com.ehviewer.core.util.unescapeXml
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.client.parseAs
import com.hippo.ehviewer.util.unescapeXml
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -31,7 +32,7 @@ object GalleryApiParser {
title = item.title.unescapeXml()
titleJpn = item.titleJpn.unescapeXml()
category = getCategory(item.category)
thumbKey = getThumbKey(item.thumb)
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
uploader = item.uploader?.unescapeXml()
posted = ParserUtils.formatDate(item.posted * 1000)
rating = item.rating
@@ -59,6 +60,6 @@ object GalleryApiParser {
@SerialName("filecount")
val pages: Int,
val rating: Float,
val tags: List<String>,
val tags: ArrayList<String>,
)
}
@@ -15,40 +15,473 @@
*/
package com.hippo.ehviewer.client.parser
import arrow.core.Either
import arrow.core.getOrElse
import com.ehviewer.core.model.GalleryCommentList
import com.ehviewer.core.model.GalleryDetail
import com.ehviewer.core.model.GalleryPreviewList
import arrow.core.unzip
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhUtils.getCategory
import com.hippo.ehviewer.client.EhUtils.handleThumbUrlResolution
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryCommentList
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.data.GalleryPreview
import com.hippo.ehviewer.client.data.GalleryTagGroup
import com.hippo.ehviewer.client.data.LargeGalleryPreview
import com.hippo.ehviewer.client.data.NormalGalleryPreview
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.OffensiveException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.CborArray
import com.hippo.ehviewer.client.exception.PiningException
import com.hippo.ehviewer.client.getThumbKey
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.toFloatOrDefault
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.trimAnd
import com.hippo.ehviewer.util.unescapeXml
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.select.Elements
import org.jsoup.select.NodeTraversor
import org.jsoup.select.NodeVisitor
object GalleryDetailParser {
fun parse(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<Result>(body, ::nativeParse)
}.getOrElse {
throw ParseException("Failed to parse gallery detail", it)
private val PATTERN_ERROR = Regex("<div class=\"d\">\n<p>([^<]+)</p>")
private val PATTERN_DETAIL = Regex(
"var gid = (\\d+);.+?var token = \"([a-f0-9]+)\";.+?var apiuid = ([\\-\\d]+);.+?var apikey = \"([a-f0-9]+)\";",
RegexOption.DOT_MATCHES_ALL,
)
private val PATTERN_TORRENT =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Torrent Download[^<]+(\\d+)[^<]+</a")
private val PATTERN_ARCHIVE =
Regex("<a[^<>]*onclick=\"return popUp\\('([^']+)'[^)]+\\)\">Archive Download</a>")
private val PATTERN_COVER = Regex("width:(\\d+)px; height:(\\d+)px.+?url\\((.+?)\\)")
private val PATTERN_PAGES =
Regex("<tr><td[^<>]*>Length:</td><td[^<>]*>([\\d,]+) pages</td></tr>")
private val PATTERN_PREVIEW_PAGES =
Regex("<td[^>]+><a[^>]+>([\\d,]+)</a></td><td[^>]+>(?:<a[^>]+>)?&gt;(?:</a>)?</td>")
private val PATTERN_NORMAL_PREVIEW =
Regex("<div class=\"gdtm\"[^<>]*><div[^<>]*width:(\\d+)[^<>]*height:(\\d+)[^<>]*\\((.+?)\\)[^<>]*-(\\d+)px[^<>]*><a[^<>]*href=\"(.+?)\"[^<>]*><img alt=\"([\\d,]+)\"")
private val PATTERN_LARGE_PREVIEW =
Regex("<div class=\"gdtl\".+?<a href=\"(.+?)\"><img alt=\"([\\d,]+)\".+?src=\"(.+?)\"")
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
private val PATTERN_FAVORITE_SLOT =
Regex("/fav.png\\); background-position:0px -(\\d+)px")
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
private val WEB_COMMENT_DATE_FORMAT = DateTimeFormatter
.ofPattern("dd MMMM yyyy, HH:mm", Locale.US).withZone(ZoneOffset.UTC)
private const val OFFENSIVE_STRING =
"<p>(And if you choose to ignore this warning, you lose all rights to complain about it in the future.)</p>"
private const val PINING_STRING = "<p>This gallery is pining for the fjords.</p>"
suspend fun parse(body: String): GalleryDetail {
if (body.contains(OFFENSIVE_STRING)) {
throw OffensiveException()
}
if (body.contains(PINING_STRING)) {
throw PiningException()
}
// Error info
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
// Temporary workaround, see https://github.com/jhy/jsoup/issues/1850
val document = Jsoup.parse(body.replace("del>", "s>"))
val galleryDetail = GalleryDetail(
tags = parseTagGroups(document),
comments = parseComments(document),
previewPages = parsePreviewPages(body),
previewList = parsePreviewList(body).first,
)
parseDetail(galleryDetail, document, body)
// Generate simpleLanguage for local favorites
galleryDetail.generateSLang()
return galleryDetail
}
fun parseComments(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<GalleryCommentList>(body, ::nativeParseComments)
}.getOrElse {
throw ParseException("Failed to parse comments", it)
private suspend fun parseDetail(gd: GalleryDetail, d: Document, body: String) {
PATTERN_DETAIL.find(body)?.apply {
gd.gid = groupValues[1].toLongOrNull() ?: -1L
gd.token = groupValues[2]
gd.apiUid = groupValues[3].toLongOrNull() ?: -1L
gd.apiKey = groupValues[4]
} ?: throw ParseException("Can't parse gallery detail")
if (gd.gid == -1L) {
throw ParseException("Can't parse gallery detail")
}
PATTERN_TORRENT.find(body)?.run {
gd.torrentUrl = groupValues[1].trim().unescapeXml()
gd.torrentCount = groupValues[2].toIntOrNull() ?: 0
}
PATTERN_ARCHIVE.find(body)?.run {
gd.archiveUrl = groupValues[1].trim().unescapeXml()
}
try {
val gm = d.getElementsByClass("gm")[0]
// Thumb url
gm.getElementById("gd1")?.child(0)?.attr("style")?.trim()?.let {
gd.thumbKey = getThumbKey(
PATTERN_COVER.find(it)?.run {
handleThumbUrlResolution(groupValues[3])
}!!,
)
}
gd.title = gm.getElementById("gn")?.text()?.trim()
gd.titleJpn = gm.getElementById("gj")?.text()?.trim()
// Category
try {
val gdc = gm.getElementById("gdc")!!
var ce = gdc.getElementsByClass("cn").first()
if (ce == null) {
ce = gdc.getElementsByClass("cs").first()
}
gd.category = getCategory(ce!!.text())
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
gd.category = EhUtils.UNKNOWN
}
// Uploader
val gdn = gm.getElementById("gdn")
if (null != gdn) {
gd.disowned = gdn.attr("style").contains("opacity:0.5")
gd.uploader = gdn.text().trim()
} else {
gd.uploader = ""
}
val gdd = gm.getElementById("gdd")
gd.posted = ""
gd.parent = ""
gd.visible = ""
gd.size = ""
gd.pages = 0
gd.favoriteCount = 0
val es = gdd!!.child(0).child(0).children()
es.forEach {
parseDetailInfo(gd, it)
}
// Rating count
val ratingCount = gm.getElementById("rating_count")
if (null != ratingCount) {
gd.ratingCount = ratingCount.text().trim().toIntOrDefault(0)
} else {
gd.ratingCount = 0
}
// Rating
val ratingLabel = gm.getElementById("rating_label")
if (null != ratingLabel) {
val ratingStr = ratingLabel.text().trim()
if ("Not Yet Rated" == ratingStr) {
gd.rating = -1.0f
} else {
val index = ratingStr.indexOf(' ')
if (index == -1 || index >= ratingStr.length) {
gd.rating = 0f
} else {
gd.rating = ratingStr.substring(index + 1).toFloatOrDefault(0f)
}
}
} else {
gd.rating = -1.0f
}
// isFavorited
val gdf = gm.getElementById("gdf")
if (gdf != null) {
val favoriteName = gdf.text().trim()
if (favoriteName != "Add to Favorites") {
gd.favoriteName = gdf.text().trim()
PATTERN_FAVORITE_SLOT.find(body)?.run {
gd.favoriteSlot = ((groupValues[1].toIntOrNull() ?: 2) - 2) / 19
}
}
}
if (gd.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(gd.gid)) {
gd.favoriteSlot = LOCAL_FAVORITED
}
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
throw ParseException("Can't parse gallery detail")
}
// newer version
d.getElementById("gnd")?.run {
val dates = PATTERN_NEWER_DATE.findAll(body).map { it.groupValues[1] }.toList()
select("a").forEachIndexed { index, element ->
val gi = BaseGalleryInfo()
val result = GalleryDetailUrlParser.parse(element.attr("href"))
if (result != null) {
gi.gid = result.gid
gi.token = result.token
gi.title = element.text().trim()
gi.posted = dates[index]
gd.newerVersions.add(gi)
}
}
}
}
fun parsePreviews(body: ByteBuffer) = Either.catch {
unmarshalParsingAs<GalleryPreviewList>(body, ::nativeParsePreviews)
}.getOrElse {
throw ParseException("Failed to parse previews", it)
private fun parseDetailInfo(gd: GalleryDetail, e: Element) {
val es = e.children()
if (es.size < 2) {
return
}
val key = es[0].text().trim()
val value = es[1].ownText().trim()
if (key.startsWith("Posted")) {
gd.posted = value
} else if (key.startsWith("Parent")) {
val a = es[1].children().first()
if (a != null) {
gd.parent = a.attr("href")
}
} else if (key.startsWith("Visible")) {
gd.visible = value
} else if (key.startsWith("Language")) {
gd.language = value
} else if (key.startsWith("File Size")) {
gd.size = value
} else if (key.startsWith("Length")) {
val index = value.indexOf(' ')
if (index >= 0) {
gd.pages = value.substring(0, index).toIntOrDefault(1)
} else {
gd.pages = 1
}
} else if (key.startsWith("Favorited")) {
when (value) {
"Never" -> gd.favoriteCount = 0
"Once" -> gd.favoriteCount = 1
else -> {
val index = value.indexOf(' ')
if (index == -1) {
gd.favoriteCount = 0
} else {
gd.favoriteCount = value.substring(0, index).toIntOrDefault(0)
}
}
}
}
}
@Serializable
@CborArray
class Result(val detail: GalleryDetail, val event: String?)
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)
}
if (group.size > 0) group else null
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
null
}
}
private external fun nativeParse(body: ByteBuffer, size: Int = body.limit()): Int
private external fun nativeParseComments(body: ByteBuffer, size: Int = body.limit()): Int
private external fun nativeParsePreviews(body: ByteBuffer, size: Int = body.limit()): Int
/**
* 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)
e.printStackTrace()
emptyList()
}
}
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
return try {
trs.mapNotNull { parseTagGroup(it) }
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
emptyList()
}
}
private suspend fun parseComment(element: Element): GalleryComment? {
return try {
// Id
val a = element.previousElementSibling()
val name = a!!.attr("name")
val id = name trimAnd { substring(1).toInt().toLong() }
// Editable, vote up and vote down
val c4 = element.getElementsByClass("c4").first()
var voteUpAble = false
var voteUpEd = false
var voteDownAble = false
var voteDownEd = false
var editable = false
var uploader = false
if (null != c4) {
if ("Uploader Comment" == c4.text()) {
uploader = true
}
for (e in c4.children()) {
when (e.text()) {
"Vote+" -> {
voteUpAble = true
voteUpEd = e.attr("style").trim().isNotEmpty()
}
"Vote-" -> {
voteDownAble = true
voteDownEd = e.attr("style").trim().isNotEmpty()
}
"Edit" -> editable = true
}
}
}
// Vote state
val c7 = element.getElementsByClass("c7").first()
val voteState = c7?.text()?.trim()
// Score
val c5 = element.getElementsByClass("c5").first()
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
// time
val c3 = element.getElementsByClass("c3").first()
val temp = c3!!.ownText()
val (timeStr, user) = if (temp.endsWith(':')) {
temp.substring("Posted on ".length, temp.length - " by:".length) to c3.child(0).text()
} else {
temp.substring("Posted on ".length) to null
}
val time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(timeStr)).toEpochMilli()
// comment
val c6 = element.getElementsByClass("c6").first()
// fix underline support
for (e in c6!!.children()) {
if ("span" == e.tagName() && "text-decoration:underline;" == e.attr("style")) {
e.tagName("u")
}
}
val commentHtml = c6.html()
// filter comment
if (!uploader) {
val sEhFilter = EhFilter
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
return null
}
}
// last edited
val c8 = element.getElementsByClass("c8").first()
val lastEdited = c8?.children()?.first()?.run { Instant.from(WEB_COMMENT_DATE_FORMAT.parse(text())).toEpochMilli() } ?: 0
GalleryComment(
id, score, editable, voteUpAble, voteUpEd, voteDownAble,
voteDownEd, uploader, voteState, time, user, commentHtml, lastEdited,
)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
null
}
}
/**
* Parse comments with html parser
*/
suspend fun parseComments(document: Document): GalleryCommentList {
return try {
val cdiv = document.getElementById("cdiv")!!
val c1s = cdiv.getElementsByClass("c1")
val list = c1s.mapNotNull { parseComment(it) }
val chd = cdiv.getElementById("chd")
var hasMore = false
NodeTraversor.traverse(
object : NodeVisitor {
override fun head(node: Node, depth: Int) {
if (node is Element && node.text() == "click to show all") {
hasMore = true
}
}
override fun tail(node: Node, depth: Int) {}
},
chd!!,
)
GalleryCommentList(list, hasMore)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
e.printStackTrace()
EMPTY_GALLERY_COMMENT_LIST
}
}
/**
* 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")
}
/**
* 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 parsePreviewList(body: String): Pair<List<GalleryPreview>, List<String>> {
return runCatching { parseNormalPreview(body) }.getOrElse { parseLargePreview(body) }
}
private fun parseLargePreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_LARGE_PREVIEW.containsMatchIn(body))
return PATTERN_LARGE_PREVIEW.findAll(body).unzip {
val position = it.groupValues[2].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val pageUrl = it.groupValues[1].trim()
LargeGalleryPreview(imageKey, position) to pageUrl
}.run {
first.toList() to second.toList()
}
}
private fun parseNormalPreview(body: String): Pair<List<GalleryPreview>, List<String>> {
check(PATTERN_NORMAL_PREVIEW.containsMatchIn(body))
return PATTERN_NORMAL_PREVIEW.findAll(body).unzip {
val position = it.groupValues[6].toInt() - 1
val imageKey = getThumbKey(it.groupValues[3].trim())
val xOffset = it.groupValues[4].toIntOrNull() ?: 0
val width = it.groupValues[1].toInt()
val height = it.groupValues[2].toInt()
val pageUrl = it.groupValues[5].trim()
NormalGalleryPreview(imageKey, position, xOffset, width, height) to pageUrl
}.run {
first.toList() to second.toList()
}
}
}
@@ -16,23 +16,29 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.util.toLongOrDefault
import java.util.regex.Pattern
/**
* Like http://exhentai.org/g/1234567/a1b2c3d4e5<br></br>
*/
object GalleryDetailUrlParser {
private val URL_STRICT_PATTERN = Regex(
private val URL_STRICT_PATTERN = Pattern.compile(
"https?://(?:${EhUrl.DOMAIN_EX}|${EhUrl.DOMAIN_E}(?:/lofi)?)/(?:g|mpv)/(\\d+)/([0-9a-f]{10})",
)
private val URL_PATTERN = Regex("(\\d+)/([0-9a-f]{10})(?:[^0-9a-f]|$)")
private val URL_PATTERN = Pattern.compile("(\\d+)/([0-9a-f]{10})(?:[^0-9a-f]|$)")
fun parse(url: String?, strict: Boolean = true): Result? {
url ?: return null
val pattern = if (strict) URL_STRICT_PATTERN else URL_PATTERN
return pattern.find(url)?.destructured?.let { (gid, token) ->
Result(gid.toLong(), token)
val m = pattern.matcher(url)
return if (m.find()) {
val gid = m.group(1)!!.toLongOrDefault(0).takeIf { it > 0 }
gid?.let { Result(it, m.group(2)!!) }
} else {
null
}
}
data class Result(val gid: Long, val token: String)
class Result(val gid: Long, val token: String)
}
Loaded 100 of 702 files, more files were not shown because too many files have changed in this diff. Show more