Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019b961461 | ||
|
|
97f988991e | ||
|
|
0dbc3b7ea7 | ||
|
|
d974cb56df | ||
|
|
13f255529b | ||
|
|
16e912c9ae | ||
|
|
d58e08e24c | ||
|
|
e111ec74be | ||
|
|
01742ac6b9 | ||
|
|
e94424a83e | ||
|
|
56d30aa413 | ||
|
|
2c01acb4a6 | ||
|
|
c0f6d0ab04 | ||
|
|
a6d215a0bf | ||
|
|
cf9441adb5 | ||
|
|
9746f20b60 | ||
|
|
501148179b | ||
|
|
36b16d7be8 | ||
|
|
fab5532407 | ||
|
|
8ad4e8640f | ||
|
|
d08491bf7e | ||
|
|
12f6f08d81 | ||
|
|
5b5a2a9a33 | ||
|
|
7719312355 | ||
|
|
e2345cc756 | ||
|
|
8d0b8534b2 | ||
|
|
48f1b0a958 | ||
|
|
9abeba6b15 | ||
|
|
63f9c8de79 | ||
|
|
f792f2c968 | ||
|
|
75bec4314e | ||
|
|
b767c041b9 | ||
|
|
4e916731b3 | ||
|
|
6c24407fb7 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
[*.{kt,kts}]
|
||||
ij_kotlin_imports_layout = *
|
||||
ktlint_code_style = intellij_idea
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
@@ -64,7 +64,7 @@ body:
|
||||
description: 您可以通过设置-高级-导出日志来获得日志文件 / You can get logs file in Settings - Advanced - Dump logcat
|
||||
placeholder: 06-15 17:44:53.704 23382 23382 E ...
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: more
|
||||
@@ -79,15 +79,6 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: site
|
||||
attributes:
|
||||
label: 浏览站点 / Browsing site
|
||||
description: E-Hentai / ExHentai
|
||||
placeholder: E-Hentai
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
+22
-143
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'renovate/**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -13,12 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -53,161 +54,39 @@ jobs:
|
||||
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') }}
|
||||
key: ${{ runner.os }}-cmake-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-cmake-
|
||||
|
||||
- name: Build OSS
|
||||
run: ./gradlew assembleDefaultOssRelease
|
||||
- name: Gradle Build
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload OSS ARM64
|
||||
- name: Update ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk
|
||||
name: arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload OSS x86_64
|
||||
- name: Update x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk
|
||||
name: x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-x86_64-release.apk
|
||||
|
||||
- name: Upload OSS ARM32
|
||||
- name: Update ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk
|
||||
name: armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload OSS x86
|
||||
- name: Update x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk
|
||||
name: x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-x86-release.apk
|
||||
|
||||
- name: Upload OSS mapping
|
||||
- name: Update mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultOssRelease/mapping.txt
|
||||
|
||||
- name: Upload OSS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip
|
||||
|
||||
- name: Build GMS
|
||||
run: ./gradlew assembleDefaultGmsRelease
|
||||
|
||||
- name: Upload GMS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload GMS x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk
|
||||
|
||||
- name: Upload GMS ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload GMS x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk
|
||||
|
||||
- name: Upload GMS mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultGmsRelease/mapping.txt
|
||||
|
||||
- name: Upload GMS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip
|
||||
|
||||
- name: Build Marshmallow OSS
|
||||
run: ./gradlew assembleMarshmallowOssRelease
|
||||
|
||||
- name: Upload Marshmallow OSS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
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
|
||||
name: mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/release/mapping.txt
|
||||
retention-days: 5
|
||||
@@ -1,21 +0,0 @@
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: '1'
|
||||
process-only: 'issues'
|
||||
@@ -11,12 +11,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
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') }}
|
||||
key: ${{ runner.os }}-cmake-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-cmake-
|
||||
|
||||
- name: Gradle Build
|
||||
@@ -45,71 +45,20 @@ jobs:
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
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/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
|
||||
cp app/build/outputs/apk/release/app-universal-release.apk app/build/outputs/apk/release/EhViewer-universal.apk
|
||||
cp app/build/outputs/apk/release/app-arm64-v8a-release.apk app/build/outputs/apk/release/EhViewer-arm64-v8a.apk
|
||||
cp app/build/outputs/apk/release/app-x86_64-release.apk app/build/outputs/apk/release/EhViewer-x86_64.apk
|
||||
cp app/build/outputs/apk/release/app-x86-release.apk app/build/outputs/apk/release/EhViewer-x86.apk
|
||||
cp app/build/outputs/apk/release/app-armeabi-v7a-release.apk app/build/outputs/apk/release/EhViewer-armeabi-v7a.apk
|
||||
|
||||
- name: Releases
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: Bump Version
|
||||
files: |
|
||||
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 }}-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
|
||||
app/build/outputs/apk/release/EhViewer-universal.apk
|
||||
app/build/outputs/apk/release/EhViewer-arm64-v8a.apk
|
||||
app/build/outputs/apk/release/EhViewer-x86_64.apk
|
||||
app/build/outputs/apk/release/EhViewer-x86.apk
|
||||
app/build/outputs/apk/release/EhViewer-armeabi-v7a.apk
|
||||
app/build/outputs/mapping/release/mapping.txt
|
||||
@@ -6,4 +6,5 @@
|
||||
*.iml
|
||||
.idea
|
||||
/captures
|
||||
release
|
||||
google-services.json
|
||||
@@ -1,99 +0,0 @@
|
||||
# 变更日志
|
||||
|
||||
## [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
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 除阅读器外全部迁移至 Compose
|
||||
|
||||
### 新功能
|
||||
|
||||
* 设置-高级 中增加实验性滑动手势敏感度设置
|
||||
* 支持通过滑动手势打开右侧抽屉
|
||||
* 支持预见式返回动画,相关设置位于 设置-高级
|
||||
|
||||
### 改进
|
||||
|
||||
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
|
||||
* 优化原图下载重试策略
|
||||
* 优化小屏设备上的搜索类别布局
|
||||
* 优化过渡动画
|
||||
* 优化历史记录删除动画
|
||||
* 设置-EH 中的缩略图大小设置改为缩略图列数
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复点击通知栏的下载通知闪退 [#175](https://github.com/FooIbar/EhViewer/issues/175)
|
||||
* 修复收藏状态显示错误 [#189](https://github.com/FooIbar/EhViewer/issues/189)
|
||||
* 修复双击导航按钮后无法打开导航抽屉
|
||||
* 修复某些情况下闪退 [#142](https://github.com/FooIbar/EhViewer/issues/142) [#208](https://github.com/FooIbar/EhViewer/issues/208)
|
||||
* 修复收藏界面点击重试崩溃 [#193](https://github.com/FooIbar/EhViewer/issues/193)
|
||||
* 修复本地收藏滚动位置丢失 [#192](https://github.com/FooIbar/EhViewer/issues/192)
|
||||
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
|
||||
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
|
||||
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
|
||||
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
|
||||
* 修复已舍弃画廊上传者解析
|
||||
|
||||
### 已知问题
|
||||
|
||||
* 画廊信息对话框无法通过返回关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
|
||||
@@ -1,11 +1,15 @@
|
||||
# Maintenance Mode
|
||||
|
||||
The project is in maintenance mode, will only receive bugfixes.
|
||||
|
||||
<p align="right">
|
||||
<strong>English</strong>
|
||||
<span> | </span>
|
||||
<a href="/docs/README/zh-cn.md">
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-cn.md">
|
||||
简体中文
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="/docs/README/zh-tw.md">
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-tw.md">
|
||||
正體中文
|
||||
</a>
|
||||
</p>
|
||||
@@ -19,12 +23,15 @@
|
||||
<a href="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml/badge.svg" alt="Github Actions">
|
||||
</a>
|
||||
<a href="/LICENSE">
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/FooIbar/EhViewer" alt="LICENSE">
|
||||
</a>
|
||||
<a href="https://www.codefactor.io/repository/github/FooIbar/EhViewer">
|
||||
<img src="https://www.codefactor.io/repository/github/FooIbar/EhViewer/badge" alt="CodeFactor">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/downloads/FooIbar/EhViewer/total.svg" alt="Downloads">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/v/release/FooIbar/EhViewer" alt="Release">
|
||||
</a>
|
||||
@@ -35,23 +42,23 @@
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="#description">
|
||||
<a href="https://github.com/FooIbar/EhViewer#description">
|
||||
Description
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#download">
|
||||
<a href="https://github.com/FooIbar/EhViewer#download">
|
||||
Download
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#screenshot">
|
||||
<a href="https://github.com/FooIbar/EhViewer#screenshot">
|
||||
Screenshot
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#thanks">
|
||||
<a href="https://github.com/FooIbar/EhViewer#thanks">
|
||||
Thanks
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="#license">
|
||||
<a href="https://github.com/FooIbar/EhViewer#license">
|
||||
License
|
||||
</a>
|
||||
</h3>
|
||||
@@ -66,17 +73,6 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
|
||||
# Download
|
||||
|
||||
| Flavor | Feature |
|
||||
|-------------|-----------------------------------|
|
||||
| Default | Android 9+, full support |
|
||||
| Marshmallow | Android 6.0-8.1, limited support* |
|
||||
| OSS | FOSS dependencies** |
|
||||
| GMS | Proprietary GMS component |
|
||||
|
||||
\* Archives and GIFs are not supported
|
||||
|
||||
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
</a>
|
||||
@@ -93,14 +89,14 @@ Here is the libraries
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [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)
|
||||
- [OkHttp](https://square.github.io/okhttp/)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [FullDraggableDrawer](https://github.com/PureWriter/FullDraggableDrawer)
|
||||
- [MCA](https://github.com/material-components/material-components-android)
|
||||
- [material-design-icons](https://github.com/google/material-design-icons)
|
||||
- [Okhttp](https://square.github.io/okhttp/)
|
||||
- [RikkaX](https://github.com/RikkaApps/RikkaX)
|
||||
- [Libarchive](http://www.libarchive.org/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
- [Jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
|
||||
+122
-116
@@ -8,21 +8,21 @@ val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
|
||||
plugins {
|
||||
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.spotless)
|
||||
alias(libs.plugins.aboutlibrariesPlugin)
|
||||
alias(libs.plugins.rustAndroidPlugin)
|
||||
alias(libs.plugins.composeCompilerReportGenerator)
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.diffplug.spotless")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
id("org.mozilla.rust-android-gradle.rust-android")
|
||||
id("dev.shreyaspatil.compose-compiler-report-generator")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
buildToolsVersion = "34.0.0"
|
||||
ndkVersion = "26.1.10909125"
|
||||
ndkVersion = "26.0.10792818"
|
||||
|
||||
splits {
|
||||
abi {
|
||||
@@ -32,7 +32,7 @@ android {
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
isUniversalApk = true
|
||||
} else {
|
||||
include("arm64-v8a", "x86_64")
|
||||
include("arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ android {
|
||||
enableV4Signing = true
|
||||
}
|
||||
|
||||
val branch = providers.exec {
|
||||
commandLine = "git branch --show-current".split(' ')
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
val commitSha = providers.exec {
|
||||
commandLine = "git rev-parse --short=7 HEAD".split(' ')
|
||||
}.standardOutput.asText.get().trim()
|
||||
@@ -64,8 +68,9 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 180049
|
||||
versionName = "1.10.2"
|
||||
versionCode = 180043
|
||||
versionName = "1.8.9.7"
|
||||
versionNameSuffix = "-gms"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -82,40 +87,10 @@ android {
|
||||
"nb-rNO",
|
||||
),
|
||||
)
|
||||
buildConfigField("String", "BRANCH", "\"$branch\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += listOf("api", "oss")
|
||||
|
||||
productFlavors {
|
||||
create("default") {
|
||||
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"
|
||||
}
|
||||
buildConfigField("String", "VERSION_NAME_SUFFIX", "\"$versionNameSuffix\"")
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
@@ -124,14 +99,21 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs = listOf(
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
"-XXLanguage:+BreakContinueInInlineLambdas",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-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",
|
||||
@@ -145,20 +127,18 @@ android {
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
fatal += setOf("NewApi", "InlineApi")
|
||||
abortOnError = true
|
||||
checkReleaseBuilds = false
|
||||
disable.add("MissingTranslation")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/**"
|
||||
excludes += "/okhttp3/**"
|
||||
excludes += "/kotlin/**"
|
||||
excludes += "**.txt"
|
||||
excludes += "**.bin"
|
||||
@@ -188,7 +168,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
kotlinCompilerExtensionVersion = "1.5.3"
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
@@ -196,89 +176,105 @@ android {
|
||||
|
||||
dependencies {
|
||||
// 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("androidx.activity:activity-compose:1.8.0-rc01")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha05")
|
||||
implementation("androidx.browser:browser:1.6.0")
|
||||
implementation("androidx.collection:collection-ktx:1.3.0-rc01")
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
api(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
// api(platform("androidx.compose:compose-bom:2023.05.00"))
|
||||
api(platform("dev.chrisbanes.compose:compose-bom:2023.07.00-alpha02"))
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material3:material3-window-size-class")
|
||||
implementation("androidx.compose.ui:ui-util")
|
||||
|
||||
implementation(libs.compose.destinations.core)
|
||||
ksp(libs.compose.destinations.compiler)
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha12")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.7.0-alpha05")
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.6.2")
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/navigation
|
||||
implementation(libs.bundles.androidx.navigation)
|
||||
val navigation = "2.7.3"
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navigation")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:$navigation")
|
||||
implementation("androidx.navigation:navigation-compose:$navigation")
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.androidx.recyclerview)
|
||||
val paging = "3.2.1"
|
||||
implementation("androidx.paging:paging-compose:$paging")
|
||||
implementation("androidx.paging:paging-runtime-ktx:$paging")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.1")
|
||||
implementation("androidx.recyclerview:recyclerview-selection:1.2.0-alpha01")
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
val room = "2.6.0-rc01"
|
||||
ksp("androidx.room:room-compiler:$room")
|
||||
implementation("androidx.room:room-paging:$room")
|
||||
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
implementation(libs.material)
|
||||
implementation(libs.material.motion.core)
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("com.drakeet.drawer:drawer:1.0.3")
|
||||
implementation("com.github.chrisbanes:PhotoView:2.3.0") // Dead Dependency
|
||||
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0") // Dead Dependency
|
||||
// https://github.com/google/accompanist/releases
|
||||
val accompanist = "0.32.0"
|
||||
implementation("com.google.accompanist:accompanist-themeadapter-material3:$accompanist")
|
||||
implementation("com.google.accompanist:accompanist-webview:$accompanist")
|
||||
implementation("com.google.android.material:material:1.11.0-alpha03")
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
val splitties = "3.0.0"
|
||||
implementation("com.louiscad.splitties:splitties-appctx:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-systemservices:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-preferences:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-arch-room:$splitties")
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.bundles.okhttp)
|
||||
implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.11"))
|
||||
implementation("com.squareup.okhttp3:okhttp-android")
|
||||
implementation("com.squareup.okhttp3:okhttp-coroutines")
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
implementation("com.squareup.okio:okio-jvm:3.5.0")
|
||||
|
||||
implementation(libs.diff)
|
||||
implementation("com.mikepenz:aboutlibraries-core:10.9.1")
|
||||
|
||||
implementation(libs.aboutlibraries.core)
|
||||
implementation("dev.chrisbanes.insetter:insetter:0.6.1") // Dead Dependency
|
||||
implementation("dev.rikka.rikkax.core:core-ktx:1.4.1")
|
||||
implementation("dev.rikka.rikkax.insets:insets:1.3.0")
|
||||
implementation("dev.rikka.rikkax.layoutinflater:layoutinflater:1.3.0")
|
||||
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
implementation(platform("io.arrow-kt:arrow-stack:1.2.1"))
|
||||
implementation("io.arrow-kt:arrow-fx-coroutines")
|
||||
|
||||
// https://coil-kt.github.io/coil/changelog/
|
||||
implementation(platform(libs.coil.bom))
|
||||
implementation(libs.bundles.coil)
|
||||
implementation(platform("io.coil-kt:coil-bom:2.4.0"))
|
||||
implementation("io.coil-kt:coil-compose")
|
||||
implementation("io.coil-kt:coil-gif")
|
||||
|
||||
implementation(libs.bundles.ktor)
|
||||
val ktor = "2.3.4"
|
||||
implementation("io.ktor:ktor-io-jvm:$ktor")
|
||||
implementation("io.ktor:ktor-utils-jvm:$ktor")
|
||||
|
||||
implementation(libs.bundles.kotlinx.serialization)
|
||||
implementation("org.chromium.net:cronet-api:113.5672.61")
|
||||
implementation("com.google.android.gms:play-services-cronet:18.0.1")
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.jsoup)
|
||||
val serialization = "1.6.0"
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:$serialization")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:$serialization")
|
||||
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jsoup:jsoup:1.16.1")
|
||||
|
||||
"gmsImplementation"(libs.bundles.cronet)
|
||||
}
|
||||
val chunker = "4.0.0"
|
||||
debugImplementation("com.github.chuckerteam.chucker:library:$chunker")
|
||||
releaseImplementation("com.github.chuckerteam.chucker:library-no-op:$chunker")
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
exclude("androidx.navigation", "navigation-compose")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
|
||||
}
|
||||
|
||||
ksp {
|
||||
@@ -289,30 +285,40 @@ ksp {
|
||||
aboutLibraries {
|
||||
duplicationMode = MERGE
|
||||
duplicationRule = GROUP
|
||||
exclusionPatterns = listOf(
|
||||
"androidx\\..*".toPattern(),
|
||||
".*annotations".toPattern(),
|
||||
"com\\.google\\.code\\..*".toPattern(),
|
||||
"com\\.google\\.guava:listenablefuture".toPattern(),
|
||||
"com\\.google\\.protobuf:protobuf-javalite".toPattern(),
|
||||
"org\\.checkerframework:checker-qual".toPattern(),
|
||||
"org\\.chromium\\.net:cronet-embedded".toPattern(),
|
||||
"org\\.slf4j:slf4j-api".toPattern(),
|
||||
)
|
||||
}
|
||||
|
||||
cargo {
|
||||
module = "src/main/rust"
|
||||
libname = "ehviewer_rust"
|
||||
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64", "x86_64")
|
||||
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64")
|
||||
if (isRelease) profile = "release"
|
||||
}
|
||||
|
||||
val ktlintVersion = libs.versions.ktlint.get()
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
// https://github.com/diffplug/spotless/issues/111
|
||||
target("src/**/*.kt")
|
||||
ktlint(ktlintVersion)
|
||||
ktlint()
|
||||
}
|
||||
kotlinGradle {
|
||||
ktlint(ktlintVersion)
|
||||
ktlint().editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_multiline-expression-wrapping" to "disabled",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.configureEach {
|
||||
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
|
||||
if ((name == "mergeDebugJniLibFolders" || name == "mergeReleaseJniLibFolders")) {
|
||||
dependsOn("cargoBuild")
|
||||
// fix mergeDebugJniLibFolders UP-TO-DATE
|
||||
inputs.dir(layout.buildDirectory.dir("rustJniLibs/android"))
|
||||
|
||||
Vendored
+21
-11
@@ -1,20 +1,30 @@
|
||||
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/issues/190382641
|
||||
-keepclassmembers class kotlin.SafePublicationLazyImpl {
|
||||
java.lang.Object _value;
|
||||
}
|
||||
|
||||
-keep class androidx.viewpager.widget.ViewPager$LayoutParams { int position; }
|
||||
|
||||
-keep class com.hippo.ehviewer.client.parser.Torrent { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.GalleryListResult { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.Limits { *; }
|
||||
-keep class com.hippo.ehviewer.client.data.BaseGalleryInfo { *; }
|
||||
-keep class com.hippo.ehviewer.ui.scene.SearchBarScene { getBinding(); }
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-keepattributes LineNumberTable,SourceFile
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
-allowaccessmodification
|
||||
-repackageclasses
|
||||
-allowaccessmodification
|
||||
-overloadaggressively
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.chromium.net.CronetEngine
|
||||
import splitties.init.appCtx
|
||||
|
||||
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
|
||||
enableBrotli(true)
|
||||
setUserAgent(Settings.userAgent)
|
||||
|
||||
// Cache Quic hint only since the real cache mechanism should on Ktor layer
|
||||
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
|
||||
setStoragePath(cache.absolutePath)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 4096)
|
||||
|
||||
addQuicHint("e-hentai.org", 443, 443)
|
||||
addQuicHint("forums.e-hentai.org", 443, 443)
|
||||
addQuicHint("exhentai.org", 443, 443)
|
||||
}.build()
|
||||
@@ -1,176 +0,0 @@
|
||||
package com.hippo.ehviewer.ktor
|
||||
|
||||
import com.hippo.ehviewer.cronet.cronetDispatcher
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClient
|
||||
import com.hippo.ehviewer.cronet.cronetHttpClientExecutor
|
||||
import com.hippo.ehviewer.cronet.pool
|
||||
import io.ktor.client.engine.HttpClientEngineBase
|
||||
import io.ktor.client.engine.HttpClientEngineConfig
|
||||
import io.ktor.client.engine.callContext
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpProtocolVersion
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.content.OutgoingContent
|
||||
import io.ktor.http.headers
|
||||
import io.ktor.util.date.GMTDate
|
||||
import io.ktor.util.flattenForEach
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import io.ktor.utils.io.writer
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UploadDataProvider
|
||||
import org.chromium.net.UploadDataSink
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
|
||||
object CronetEngine : HttpClientEngineBase("Cronet") {
|
||||
override val config = HttpClientEngineConfig()
|
||||
override val dispatcher = cronetDispatcher
|
||||
|
||||
@InternalAPI
|
||||
override suspend fun execute(data: HttpRequestData): HttpResponseData {
|
||||
val callContext = callContext()
|
||||
|
||||
return executeHttpRequest(callContext, data)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private suspend fun executeHttpRequest(
|
||||
callContext: CoroutineContext,
|
||||
data: HttpRequestData,
|
||||
): HttpResponseData = suspendCancellableCoroutine { continuation ->
|
||||
val requestTime = GMTDate()
|
||||
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
lateinit var chunkChan: Channel<ByteBuffer>
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) {
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||
val channel = GlobalScope.writer(callContext) {
|
||||
pool.useInstance {
|
||||
chunkChan = Channel(1)
|
||||
request.read(it)
|
||||
chunkChan.consumeEach { buffer ->
|
||||
buffer.flip()
|
||||
channel.writeFully(buffer)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
}
|
||||
}.channel
|
||||
continuation.resume(
|
||||
info.toHttpResponseData(
|
||||
requestTime = requestTime,
|
||||
callContext = callContext,
|
||||
responseBody = channel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(request: UrlRequest, info: UrlResponseInfo, byteBuffer: ByteBuffer) {
|
||||
chunkChan.trySend(byteBuffer).getOrThrow()
|
||||
}
|
||||
|
||||
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
|
||||
chunkChan.close()
|
||||
}
|
||||
|
||||
override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) {
|
||||
if (::chunkChan.isInitialized) {
|
||||
chunkChan.close(error)
|
||||
} else {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val request = cronetHttpClient.newUrlRequestBuilder(data.url.toString(), callback, cronetHttpClientExecutor).apply {
|
||||
setHttpMethod(data.method.value)
|
||||
data.headers.flattenForEach { key, value -> addHeader(key, value) }
|
||||
data.body.contentType?.let { addHeader(HttpHeaders.ContentType, it.toString()) }
|
||||
data.body.contentLength?.let { addHeader(HttpHeaders.ContentLength, it.toString()) }
|
||||
data.body.toUploadDataProvider(callContext)?.let { setUploadDataProvider(it, cronetHttpClientExecutor) }
|
||||
}.build()
|
||||
request.start()
|
||||
callContext[Job]!!.invokeOnCompletion { request.cancel() }
|
||||
continuation.invokeOnCancellation { request.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UrlResponseInfo.toHttpResponseData(
|
||||
requestTime: GMTDate,
|
||||
callContext: CoroutineContext,
|
||||
responseBody: ByteReadChannel = ByteReadChannel.Empty,
|
||||
): HttpResponseData {
|
||||
return HttpResponseData(
|
||||
statusCode = HttpStatusCode.fromValue(httpStatusCode),
|
||||
requestTime = requestTime,
|
||||
headers = headers {
|
||||
allHeaders.forEach { (key, value) ->
|
||||
appendAll(key, value)
|
||||
}
|
||||
},
|
||||
version = when (negotiatedProtocol) {
|
||||
"h2" -> HttpProtocolVersion.HTTP_2_0
|
||||
"h3" -> HttpProtocolVersion.QUIC
|
||||
"quic/1+spdy/3" -> HttpProtocolVersion.SPDY_3
|
||||
else -> HttpProtocolVersion.HTTP_1_1
|
||||
},
|
||||
body = responseBody,
|
||||
callContext = callContext,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun OutgoingContent.toUploadDataProvider(callContext: CoroutineContext): UploadDataProvider? = when (this) {
|
||||
is OutgoingContent.NoContent -> null
|
||||
is OutgoingContent.ByteArrayContent -> object : UploadDataProvider() {
|
||||
val buffer = ByteBuffer.wrap(bytes()).slice()
|
||||
override fun getLength() = buffer.limit().toLong()
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
byteBuffer.put(buffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
buffer.position(0)
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
is OutgoingContent.WriteChannelContent -> object : UploadDataProvider() {
|
||||
fun launch() = GlobalScope.writer(callContext) { writeTo(channel) }.channel
|
||||
var chan = launch()
|
||||
override fun getLength() = contentLength!!
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
GlobalScope.launch(callContext) {
|
||||
chan.readAvailable(byteBuffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
chan = launch()
|
||||
uploadDataSink.onRewindSucceeded()
|
||||
}
|
||||
}
|
||||
else -> error("UnsupportedContentType $this")
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import com.google.android.gms.net.CronetProviderInstaller
|
||||
import org.chromium.net.CronetProvider
|
||||
import splitties.init.appCtx
|
||||
|
||||
val isCronetAvailable: Boolean
|
||||
get() = CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
if (!it) {
|
||||
CronetProviderInstaller.installProvider(appCtx)
|
||||
}
|
||||
}
|
||||
@@ -23,27 +23,27 @@
|
||||
android:allowBackup="true"
|
||||
android:appCategory="image"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_scheme"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="@bool/large_heap"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConfigureActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:banner="@mipmap/ic_leanback_banner"
|
||||
android:configChanges="screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.Splash"
|
||||
android:windowSoftInputMode="adjustResize|stateAlwaysHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -134,6 +134,8 @@
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<activity
|
||||
android:name=".ui.WebViewActivity" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
||||
@@ -24,7 +24,7 @@ set(BUILD_TESTING 0)
|
||||
|
||||
FetchContent_Declare(liblzma
|
||||
GIT_REPOSITORY https://github.com/FooIbar/xz.git
|
||||
GIT_TAG v5.4.5
|
||||
GIT_TAG v5.4.4
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
@@ -76,7 +76,5 @@ FetchContent_MakeAvailable(libarchive)
|
||||
include_directories(${libarchive_SOURCE_DIR}/libarchive)
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics)
|
||||
|
||||
add_custom_target("noop")
|
||||
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} archive_static log)
|
||||
@@ -279,7 +279,7 @@ 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,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_openArchive(JNIEnv *env, jclass thiz, jint fd,
|
||||
jlong size) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -359,7 +359,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint
|
||||
#pragma ide diagnostic ignored "UnreachableCode"
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
|
||||
jint index) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -389,7 +389,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass th
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
@@ -408,14 +408,14 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) {
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_needPassword(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
return need_encrypt;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
jstring str) {
|
||||
EH_UNUSED(thiz);
|
||||
struct archive_entry *entry;
|
||||
@@ -446,7 +446,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_getFilename(JNIEnv *env, jclass thiz,
|
||||
jint index) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -462,7 +462,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToFd(JNIEnv *env, jclass thiz,
|
||||
jint index, jint fd) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -477,7 +477,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
|
||||
jobject buffer) {
|
||||
EH_UNUSED(thiz);
|
||||
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <android/bitmap.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/** A line will be considered as having content if 0.25% of it is filled. */
|
||||
const float filledRatioLimit = 0.0025;
|
||||
|
||||
/** When the threshold is closer to 1, less content will be cropped. **/
|
||||
#define THRESHOLD 0.75
|
||||
|
||||
const uint8_t thresholdForBlack = (uint8_t) (255.0 * THRESHOLD);
|
||||
|
||||
const uint8_t thresholdForWhite = (uint8_t) (255.0 - 255.0 * THRESHOLD);
|
||||
|
||||
static uint8_t inline grey(uint32_t pixel) {
|
||||
const uint8_t r = ((uint8_t *) &pixel)[0];
|
||||
const uint8_t g = ((uint8_t *) &pixel)[1];
|
||||
const uint8_t b = ((uint8_t *) &pixel)[2];
|
||||
return (r * 77 + g * 151 + b * 28) >> 8;
|
||||
}
|
||||
|
||||
static bool inline isBlackPixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) < thresholdForBlack;
|
||||
}
|
||||
|
||||
static bool inline isWhitePixel(const uint32_t *pixels, uint32_t width, uint32_t x, uint32_t y) {
|
||||
const uint32_t pixel = *((uint32_t *) pixels + (y * width + x));
|
||||
return grey(pixel) > thresholdForWhite;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the left. */
|
||||
uint32_t findBorderLeft(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, 0, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, 0, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = 1; x < width; x++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first x position where there is a substantial amount of fill,
|
||||
* starting the search from the right. */
|
||||
uint32_t findBorderRight(uint32_t *pixels, uint32_t width, uint32_t height,
|
||||
uint32_t top, uint32_t bottom) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(height * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
uint32_t lastX = width - 1;
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (isBlackPixel(pixels, width, lastX, y)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, lastX, y)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return width;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan vertical lines in search of filled lines
|
||||
for (x = width - 2; x > 0; x--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (y = top; y < bottom; y += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return x + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the top. */
|
||||
uint32_t findBorderTop(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, 0)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, 0)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return 0;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = 1; y < height; y++) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Return the first y position where there is a substantial amount of fill,
|
||||
* starting the search from the bottom. */
|
||||
uint32_t findBorderBottom(uint32_t *pixels, uint32_t width, uint32_t height) {
|
||||
int x, y;
|
||||
const uint32_t filledLimit = round(width * filledRatioLimit / 2);
|
||||
|
||||
// Scan first line to detect dominant color
|
||||
uint32_t whitePixels = 0;
|
||||
uint32_t blackPixels = 0;
|
||||
uint32_t lastY = height - 1;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (isBlackPixel(pixels, width, x, lastY)) {
|
||||
blackPixels++;
|
||||
} else if (isWhitePixel(pixels, width, x, lastY)) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
bool (*detectFunc)(const uint32_t *, uint32_t, uint32_t, uint32_t) = isBlackPixel;
|
||||
if (whitePixels > filledLimit && blackPixels > filledLimit) {
|
||||
// Mixed fill found... don't crop anything
|
||||
return height;
|
||||
} else if (blackPixels > filledLimit) {
|
||||
detectFunc = isWhitePixel;
|
||||
}
|
||||
|
||||
// Scan horizontal lines in search of filled lines
|
||||
for (y = height - 2; y > 0; y--) {
|
||||
uint32_t filledCount = 0;
|
||||
|
||||
for (x = 0; x < width; x += 2) {
|
||||
if (detectFunc(pixels, width, x, y)) {
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (filledCount > filledLimit) {
|
||||
// This line contains enough fill
|
||||
return y + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// No fill found... don't crop anything
|
||||
return height;
|
||||
}
|
||||
|
||||
JNIEXPORT jintArray JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_detectBorder(JNIEnv *env, jclass clazz, jobject bitmap) {
|
||||
void *pixels = NULL;
|
||||
AndroidBitmapInfo info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
AndroidBitmap_getInfo(env, bitmap, &info);
|
||||
AndroidBitmap_lockPixels(env, bitmap, &pixels);
|
||||
uint32_t width = info.width;
|
||||
uint32_t height = info.height;
|
||||
uint32_t top = findBorderTop(pixels, width, height);
|
||||
uint32_t bottom = findBorderBottom(pixels, width, height);
|
||||
uint32_t left = findBorderLeft(pixels, width, height, top, bottom);
|
||||
uint32_t right = findBorderRight(pixels, width, height, top, bottom);
|
||||
int array[4] = {left, top, right, bottom};
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
jintArray ret = (*env)->NewIntArray(env, 4);
|
||||
(*env)->SetIntArrayRegion(env, ret, 0, 4, array);
|
||||
return ret;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
#include <jni.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <android/log.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/mman.h>
|
||||
@@ -12,7 +11,6 @@
|
||||
|
||||
#define GIF_HEADER_87A "GIF87a"
|
||||
#define GIF_HEADER_89A "GIF89a"
|
||||
#define GIF_HEADER_LENGTH 6
|
||||
|
||||
static int FRAME_DELAY_START_MARKER = 0x0021F904;
|
||||
|
||||
@@ -23,8 +21,7 @@ typedef signed char byte;
|
||||
#define DEFAULT_FRAME_DELAY 10
|
||||
|
||||
static inline bool isGif(void *addr) {
|
||||
return !memcmp(addr, GIF_HEADER_87A, GIF_HEADER_LENGTH) ||
|
||||
!memcmp(addr, GIF_HEADER_89A, GIF_HEADER_LENGTH);
|
||||
return !memcmp(addr, GIF_HEADER_87A, 6) || !memcmp(addr, GIF_HEADER_89A, 6);
|
||||
}
|
||||
|
||||
static void doRewrite(byte *addr, size_t size) {
|
||||
@@ -45,33 +42,20 @@ static void doRewrite(byte *addr, size_t size) {
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_isGif(JNIEnv *env, jclass clazz, jint fd) {
|
||||
byte buffer[GIF_HEADER_LENGTH];
|
||||
return read(fd, buffer, GIF_HEADER_LENGTH) == GIF_HEADER_LENGTH &&
|
||||
isGif(buffer);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
Java_com_hippo_ehviewer_image_ImageKt_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
byte *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
|
||||
doRewrite(addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_mmap(JNIEnv *env, jclass clazz, jint fd) {
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_rewriteGifSource2(JNIEnv *env, jclass clazz, jint fd) {
|
||||
struct stat64 st;
|
||||
fstat64(fd, &st);
|
||||
size_t size = st.st_size;
|
||||
byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
|
||||
if (addr == MAP_FAILED) return NULL;
|
||||
return (*env)->NewDirectByteBuffer(env, addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_munmap(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
byte *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
|
||||
byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (addr == MAP_FAILED) return;
|
||||
doRewrite(addr, size);
|
||||
munmap(addr, size);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package androidx.navigation.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavOptions
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
fun onNavDestinationSelected2(item: MenuItem, navController: NavController): Boolean {
|
||||
val builder = NavOptions.Builder().setLaunchSingleTop(true)
|
||||
if (
|
||||
navController.currentDestination!!.parent!!.findNode(item.itemId)
|
||||
is ActivityNavigator.Destination
|
||||
) {
|
||||
builder.setEnterAnim(R.anim.nav_default_enter_anim)
|
||||
.setExitAnim(R.anim.nav_default_exit_anim)
|
||||
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
|
||||
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
|
||||
} else {
|
||||
builder.setEnterAnim(androidx.fragment.R.animator.fragment_open_enter)
|
||||
.setExitAnim(androidx.fragment.R.animator.fragment_open_exit)
|
||||
.setPopEnterAnim(androidx.fragment.R.animator.fragment_close_enter)
|
||||
.setPopExitAnim(androidx.fragment.R.animator.fragment_close_exit)
|
||||
}
|
||||
val options = builder.build()
|
||||
return try {
|
||||
// TODO provide proper API instead of using Exceptions as Control-Flow.
|
||||
navController.navigate(item.itemId, null, options)
|
||||
// Return true only if the destination we've navigated to matches the MenuItem
|
||||
navController.currentDestination?.matchDestination(item.itemId) == true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
|
||||
hierarchy.any { it.id == destId }
|
||||
@@ -0,0 +1,25 @@
|
||||
package androidx.swiperefreshlayout.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import rikka.core.res.resolveColor
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
class ThemedSwipeRefreshLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : SwipeRefreshLayout(context, attrs) {
|
||||
|
||||
init {
|
||||
val surfaceColor =
|
||||
context.theme.resolveColor(com.google.android.material.R.attr.colorSurface)
|
||||
val overlayColor =
|
||||
context.getColor(com.google.android.material.R.color.m3_popupmenu_overlay_color)
|
||||
val backgroundColor = ColorUtils.compositeColors(overlayColor, surfaceColor)
|
||||
(mCircleView.background as ShapeDrawable).paint.color = backgroundColor
|
||||
setColorSchemeColors(context.theme.resolveColor(com.google.android.material.R.attr.colorAccent))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Helper class used to set the source and additional attributes from a variety of sources. Supports
|
||||
* use of a bitmap, asset, resource, external file or any other URI.
|
||||
*
|
||||
*
|
||||
* When you are using a preview image, you must set the dimensions of the full size image on the
|
||||
* ImageSource object for the full size image using the [.dimensions] method.
|
||||
*/
|
||||
class ImageSource {
|
||||
val bitmap: Bitmap?
|
||||
val provider: InputProvider?
|
||||
|
||||
private var sWidth = 0
|
||||
private var sHeight = 0
|
||||
|
||||
var sRegion = Rect(0, 0, 0, 0)
|
||||
private set
|
||||
var isCached = false
|
||||
private set
|
||||
|
||||
private constructor(bitmap: Bitmap, cached: Boolean) {
|
||||
this.bitmap = bitmap
|
||||
provider = null
|
||||
sWidth = bitmap.width
|
||||
sHeight = bitmap.height
|
||||
isCached = cached
|
||||
}
|
||||
|
||||
private constructor(provider: InputProvider) {
|
||||
bitmap = null
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a region of the source image. Region must be set independently for the full size image and the preview if
|
||||
* you are using one.
|
||||
*
|
||||
* @param sRegion the region of the source image to be displayed.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun region(sRegion: Rect): ImageSource {
|
||||
this.sRegion = sRegion
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the dimensions of the image. This is only required for a full size image, when you are specifying a URI
|
||||
* and also a preview image. When displaying a bitmap object, or not using a preview, you do not need to declare
|
||||
* the image dimensions. Note if the declared dimensions are found to be incorrect, the view will reset.
|
||||
*
|
||||
* @param sWidth width of the source image.
|
||||
* @param sHeight height of the source image.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun dimensions(sWidth: Int, sHeight: Int): ImageSource {
|
||||
if (bitmap == null) {
|
||||
this.sWidth = sWidth
|
||||
this.sHeight = sHeight
|
||||
}
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
private fun setInvariants() {
|
||||
if (!sRegion.isEmpty) {
|
||||
sWidth = sRegion.width()
|
||||
sHeight = sRegion.height()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create an instance from an input provider.
|
||||
*
|
||||
* @param provider input stream provider.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun provider(provider: InputProvider): ImageSource {
|
||||
return ImageSource(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded bitmap for display.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun bitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded and cached bitmap for display. This bitmap will not be recycled when it is no
|
||||
* longer needed. Use this method if you loaded the bitmap with an image loader such as Picasso
|
||||
* or Volley.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun cachedBitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.PointF
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate.
|
||||
*/
|
||||
class ImageViewState(val scale: Float, center: PointF) : Serializable {
|
||||
private val centerX: Float = center.x
|
||||
private val centerY: Float = center.y
|
||||
|
||||
val center: PointF
|
||||
get() = PointF(centerX, centerY)
|
||||
}
|
||||
+1086
-40
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Interface for image decoding classes.
|
||||
*/
|
||||
interface Decoder {
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
fun init(context: Context, provider: InputProvider): Point
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
val isReady: Boolean
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
fun recycle()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
class ImageDecoder(private val cropBorders: Boolean, bitmapConfig: Bitmap.Config? = null) : Decoder {
|
||||
init {
|
||||
val globalBitmapConfig = SubsamplingScaleImageView.preferredBitmapConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
override fun init(context: Context, provider: InputProvider): Point {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
override fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
override val isReady: Boolean = TODO()
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
override fun recycle() {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.davemorrissey.labs.subscaleview.provider
|
||||
|
||||
interface InputProvider
|
||||
+54
-95
@@ -18,33 +18,29 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.ImageDecoderDecoder
|
||||
import coil3.fetch.NetworkFetcher
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.util.DebugLogger
|
||||
import com.google.android.gms.net.CronetProviderInstaller
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
|
||||
import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
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.ktbuilder.cache
|
||||
import com.hippo.ehviewer.ktbuilder.chunker
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.httpClient
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.CronetEngine
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.legacy.migrateCookies
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
@@ -52,23 +48,13 @@ import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isCronetAvailable
|
||||
import com.hippo.ehviewer.util.resettableLazy
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import moe.tarsin.kt.unreachable
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
import okio.Path.Companion.toOkioPath
|
||||
@@ -78,17 +64,8 @@ import splitties.init.appCtx
|
||||
private val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
class EhApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
val mode = Settings.theme
|
||||
if (!isAtLeastS) {
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lockObserver)
|
||||
val handler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { t, e ->
|
||||
@@ -101,14 +78,22 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
handler?.uncaughtException(t, e)
|
||||
}
|
||||
super.onCreate()
|
||||
if (isAtLeastP) {
|
||||
System.loadLibrary("ehviewer")
|
||||
}
|
||||
CronetProviderInstaller.installProvider(appCtx)
|
||||
System.loadLibrary("ehviewer")
|
||||
System.loadLibrary("ehviewer_rust")
|
||||
ReadableTime.initialize(this)
|
||||
lifecycleScope.launchIO {
|
||||
val theme = Settings.theme
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(theme)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launchIO {
|
||||
launchIO {
|
||||
EhTagDatabase
|
||||
migrateCookies()
|
||||
}
|
||||
launchIO {
|
||||
EhTagDatabase.update()
|
||||
}
|
||||
launchIO {
|
||||
EhDB
|
||||
@@ -116,22 +101,6 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
launchIO {
|
||||
DownloadManager.isIdle
|
||||
}
|
||||
launchIO {
|
||||
runSuspendCatching {
|
||||
val files = mutableListOf<File>()
|
||||
AppConfig.externalCrashDir?.listFiles()?.let { files.addAll(it) }
|
||||
AppConfig.externalParseErrorDir?.listFiles()?.let { files.addAll(it) }
|
||||
files.sortByDescending { it.lastModified() }
|
||||
files.forEachIndexed { index, file ->
|
||||
ensureActive()
|
||||
if (index > 9) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
launchIO {
|
||||
cleanupDownload()
|
||||
}
|
||||
@@ -142,12 +111,6 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
}
|
||||
cleanObsoleteCache(this)
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.enableDefaults()
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat(Log.VERBOSE) { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupDownload() {
|
||||
@@ -181,53 +144,49 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context) = context.imageLoader {
|
||||
override fun newImageLoader() = imageLoader {
|
||||
components {
|
||||
add(NetworkFetcher.Factory(unsafeLazy { ktorClient }))
|
||||
if (isAtLeastP) {
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
if (isCronetSupported) {
|
||||
callFactory { unreachable() }
|
||||
installCronetHttpUriFetcher()
|
||||
} else {
|
||||
callFactory(baseOkHttpClient)
|
||||
}
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
add(MergeInterceptor)
|
||||
add(DownloadThumbInterceptor)
|
||||
}
|
||||
diskCache(imageCache)
|
||||
crossfade(300)
|
||||
val drawable = AppCompatResources.getDrawable(appCtx, R.drawable.image_failed)
|
||||
if (drawable != null) error(drawable.asCoilImage(true))
|
||||
error(R.drawable.image_failed)
|
||||
if (BuildConfig.DEBUG) logger(DebugLogger())
|
||||
respectCacheHeaders(false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var ktorClient by resettableLazy {
|
||||
if (Settings.enableCronet && isCronetAvailable) {
|
||||
HttpClient(CronetEngine) {
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
}
|
||||
addInterceptor(UncaughtExceptionInterceptor())
|
||||
}
|
||||
}
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
val baseOkHttpClient by lazy {
|
||||
httpClient {
|
||||
cookieJar(EhCookieStore)
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
}
|
||||
chunker { alwaysReadResponseBody(false) }
|
||||
addInterceptor(CloudflareInterceptor(appCtx))
|
||||
}
|
||||
}
|
||||
|
||||
var noRedirectKtorClient by resettableLazy {
|
||||
HttpClient(ktorClient.engine) {
|
||||
followRedirects = false
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -17,6 +17,8 @@ package com.hippo.ehviewer
|
||||
|
||||
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 com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
@@ -30,7 +32,6 @@ 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 com.hippo.unifile.asUniFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import splitties.arch.room.roomDb
|
||||
|
||||
@@ -129,7 +130,7 @@ object EhDB {
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
removeLocalFavorites(it)
|
||||
}
|
||||
@@ -145,7 +146,7 @@ object EhDB {
|
||||
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
putLocalFavorites(it)
|
||||
}
|
||||
@@ -194,7 +195,7 @@ object EhDB {
|
||||
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun updateFavoriteSlot(gid: Long, slot: Int) {
|
||||
suspend fun modifyHistoryInfoFavslotNonRefresh(gid: Long, slot: Int) {
|
||||
val dao = db.galleryDao()
|
||||
dao.load(gid)?.let {
|
||||
it.favoriteSlot = slot
|
||||
@@ -260,7 +261,11 @@ object EhDB {
|
||||
db.filterDao().list().let { newDb.filterDao().insert(it) }
|
||||
newDb.close()
|
||||
val dbFile = context.getDatabasePath(ehExportName)
|
||||
dbFile.asUniFile() sendTo uri.asUniFile()
|
||||
context.contentResolver.openFileDescriptor(uri, "rw")!!.use { toFd ->
|
||||
ParcelFileDescriptor.open(dbFile, MODE_READ_ONLY).use { fromFd ->
|
||||
fromFd sendTo toFd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +292,7 @@ object EhDB {
|
||||
}
|
||||
runCatching {
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
DownloadManager.addDownload(downloadInfoList, false)
|
||||
}
|
||||
runCatching {
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
Regular → Executable
+13
-118
@@ -2,97 +2,18 @@
|
||||
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import splitties.preferences.BoolPref
|
||||
import splitties.preferences.DataStorePreferences
|
||||
import splitties.preferences.FloatPref
|
||||
import splitties.preferences.IntPref
|
||||
import splitties.preferences.LongPref
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.preferences.StringOrNullPref
|
||||
import splitties.preferences.StringPref
|
||||
import splitties.preferences.StringSetOrNullPref
|
||||
import splitties.preferences.StringSetPref
|
||||
import splitties.preferences.edit
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
|
||||
is BoolPref -> value
|
||||
is IntPref -> value
|
||||
is FloatPref -> value
|
||||
is LongPref -> value
|
||||
is StringPref -> value
|
||||
is StringOrNullPref -> value
|
||||
is StringSetPref -> value
|
||||
is StringSetOrNullPref -> value
|
||||
} as T
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
|
||||
is BoolPref -> value = newValue as Boolean
|
||||
is IntPref -> value = newValue as Int
|
||||
is FloatPref -> value = newValue as Float
|
||||
is LongPref -> value = newValue as Long
|
||||
is StringPref -> value = newValue as String
|
||||
is StringOrNullPref -> value = newValue as? String
|
||||
is StringSetPref -> value = newValue as Set<String?>
|
||||
is StringSetOrNullPref -> value = newValue as? Set<String?>
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
|
||||
val flow = remember { valueFlow().map { transform(it) } }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(transform(init))
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(init)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
val readOnly = flow.collectAsState(init)
|
||||
return remember {
|
||||
object : MutableState<T> {
|
||||
override var value: T
|
||||
get() = readOnly.value
|
||||
set(value) {
|
||||
setValue(value)
|
||||
}
|
||||
override fun component1() = TODO()
|
||||
override fun component2() = TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
import java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
object Settings : DataStorePreferences(null) {
|
||||
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
|
||||
@@ -103,37 +24,30 @@ object Settings : DataStorePreferences(null) {
|
||||
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
|
||||
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
|
||||
|
||||
val listMode = intPref("list_mode_2", 0)
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
|
||||
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 downloadDelay by intPref("download_delay_2", 0)
|
||||
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 listMode by intPref("list_mode_2", 0)
|
||||
var detailSize by intPref("detail_size_2", 0)
|
||||
var thumbResolution by intPref("thumb_resolution_2", 0)
|
||||
var readCacheSize by intPref("read_cache_size_2", 640)
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
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 showGalleryPages by boolPref("show_gallery_pages", false)
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var meteredNetworkWarning by boolPref("cellular_network_warning", false)
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
@@ -145,42 +59,27 @@ object Settings : DataStorePreferences(null) {
|
||||
var security by boolPref("require_unlock", false)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var needSignIn by boolPref("need_sign_in", true)
|
||||
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
|
||||
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
|
||||
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
var enableCronet by boolPref("enable_cronet", true)
|
||||
var thumbSizeDp by intPref("thumb_size_", 120)
|
||||
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
|
||||
var listThumbSize by intPref("list_tile_size", 40)
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
|
||||
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var updateIntervalDays by intPref("update_interval_days", 0)
|
||||
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 lastDawnTime by longPref("last_dawn_time", 0)
|
||||
var lastUpdateTime by longPref("last_update_time", 0)
|
||||
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
|
||||
|
||||
// TODO: Remove this after swipe gestures are correctly handled in compose
|
||||
var touchSlopFactor by intPref("touch_slop", 3)
|
||||
|
||||
// Tachiyomi Reader
|
||||
val 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.LEFT_TO_RIGHT.flagValue)
|
||||
val orientationMode = intPref("pref_default_orientation_type_key", OrientationType.FREE.flagValue)
|
||||
val showReaderSeekbar = boolPref("pref_show_reader_seekbar", true)
|
||||
var lastDawnDay by longPref("last_dawn_day", 0)
|
||||
var lastUpdateDay by longPref("last_update_day", 0)
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
@@ -198,8 +97,6 @@ object Settings : DataStorePreferences(null) {
|
||||
|
||||
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
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) {
|
||||
@@ -210,8 +107,6 @@ object Settings : DataStorePreferences(null) {
|
||||
|
||||
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
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>) {
|
||||
+9
-11
@@ -1,14 +1,13 @@
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,7 +17,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.systemservices.uiModeManager
|
||||
|
||||
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
|
||||
@@ -46,14 +44,14 @@ fun updateWhenKeepMediaStatusChanges() {
|
||||
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)
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenAmoledModeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY)
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 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).any { it.name == name }
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(url, it) }.filterNot { 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 cookie = get(EhUrl.HOST_E.toHttpUrl()).filter { it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH || it.name == KEY_IGNEOUS }
|
||||
cookie.forEach { manager.setCookie(EhUrl.HOST_EX, it.toString()) }
|
||||
flush()
|
||||
}
|
||||
|
||||
fun deleteCookie(url: HttpUrl, name: String) {
|
||||
manager.setCookie(url.toString(), "$name=;Max-Age=0")
|
||||
}
|
||||
|
||||
fun addCookie(cookie: Cookie) {
|
||||
if (EhUrl.DOMAIN_E in cookie.domain) manager.setCookie(EhUrl.HOST_E, cookie.toString()) else manager.setCookie(EhUrl.DOMAIN_EX, cookie.toString())
|
||||
}
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
fun getCookieHeader(url: HttpUrl): String {
|
||||
val cookies = loadForRequest(url)
|
||||
val cookieHeader = StringBuilder()
|
||||
var i = 0
|
||||
val size = cookies.size
|
||||
while (i < size) {
|
||||
if (i > 0) {
|
||||
cookieHeader.append("; ")
|
||||
}
|
||||
val cookie = cookies[i]
|
||||
cookieHeader.append(cookie.name).append('=').append(cookie.value)
|
||||
i++
|
||||
}
|
||||
return cookieHeader.toString()
|
||||
}
|
||||
|
||||
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.filterNot { it.name == KEY_UTMP_NAME }.forEach { manager.setCookie(url.toString(), it.toString()) }.also { flush() }
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val checkTips = EhUrl.DOMAIN_E in url.host
|
||||
return get(url).run {
|
||||
if (checkTips) {
|
||||
filterNot { it.name == KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
/*
|
||||
* 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.util.Log
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
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.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.GalleryNotAvailableParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.client.parser.ProfileParser
|
||||
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.VoteCommentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.cronet.awaitBodyFully
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
|
||||
private const val TAG = "EhEngine"
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val SAD_PANDA_DISPOSITION = "inline; filename=\"sadpanda.jpg\""
|
||||
private const val SAD_PANDA_TYPE = "image/gif"
|
||||
private const val SAD_PANDA_LENGTH = "9615"
|
||||
private const val KOKOMADE_URL = "https://exhentai.org/img/kokomade.jpg"
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
|
||||
|
||||
private fun rethrowExactly(code: Int, headers: Headers, body: String, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Check sad panda
|
||||
if (SAD_PANDA_DISPOSITION == headers["Content-Disposition"] && SAD_PANDA_TYPE == headers["Content-Type"] && SAD_PANDA_LENGTH == headers["Content-Length"]) {
|
||||
throw EhException("Sad Panda")
|
||||
}
|
||||
|
||||
// Check sad panda(without panda)
|
||||
if ("text/html; charset=UTF-8" == headers["Content-Type"] && "0" == headers["Content-Length"] && EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
}
|
||||
|
||||
// Check kokomade
|
||||
if (body.contains(KOKOMADE_URL)) {
|
||||
throw EhException("今回はここまで ${appCtx.getString(R.string.kokomade_tip)}".trimIndent())
|
||||
}
|
||||
|
||||
// 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
|
||||
if (code >= 400) {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
|
||||
if (e is ParseException) {
|
||||
if (body.isEmpty()) {
|
||||
throw EhException(appCtx.getString(R.string.error_empty_html))
|
||||
} else 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
|
||||
}
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(16, 16384)
|
||||
|
||||
suspend inline fun <T> fetchCompat(url: String, referer: String? = null, crossinline parser: (ByteBuffer) -> T): T {
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer).execute {
|
||||
val buffer = httpContentPool.borrow()
|
||||
try {
|
||||
awaitBodyFully {
|
||||
buffer.put(it)
|
||||
}
|
||||
buffer.flip()
|
||||
parser(buffer)
|
||||
} finally {
|
||||
httpContentPool.recycle(buffer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ehRequest(url, referer).execute {
|
||||
val buffer = httpContentPool.borrow()
|
||||
try {
|
||||
while (true) {
|
||||
if (body.source().read(buffer) == -1) break
|
||||
}
|
||||
buffer.flip()
|
||||
parser(buffer)
|
||||
} finally {
|
||||
httpContentPool.recycle(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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, headers, body, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EhEngine {
|
||||
suspend fun getOriginalImageUrl(url: String, referer: String?): String {
|
||||
Log.d(TAG, url)
|
||||
return ehRequest(url, referer).executeNoRedirect {
|
||||
header("Location")?.apply {
|
||||
if (contains("bounce_login")) {
|
||||
throw NotLoggedInException()
|
||||
}
|
||||
} ?: throw InsufficientFundsException()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return fetchCompat(url, referer, TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.executeAndParsingWith(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) }
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
{ fetchCompat(EhUrl.URL_HOME, parser = HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).executeAndParsingWith(HomeParser::parseFunds) },
|
||||
{ limits, funds -> HomeParser.Result(limits, funds) },
|
||||
)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.executeAndParsingWith { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
suspend fun getProfile(): ProfileParser.Result {
|
||||
val url = ehRequest(EhUrl.URL_FORUMS).executeAndParsingWith(ForumsParser::parse)
|
||||
return ehRequest(url, EhUrl.URL_FORUMS).executeAndParsingWith(ProfileParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
runSuspendCatching {
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
}.onFailure {
|
||||
// It may get redirected when accessing ex for the first time
|
||||
if (url == EhUrl.URL_UCONFIG_EX) {
|
||||
it.printStackTrace()
|
||||
ehRequest(url).executeAndParsingWith { check(contains(U_CONFIG_TEXT)) { "Unable to load config from $url!" } }
|
||||
} else {
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGalleryPage(
|
||||
url: String,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
): GalleryPageParser.Result {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer)
|
||||
.executeAndParsingWith(GalleryListParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, true) }
|
||||
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
EventPaneParser.parse(this)?.let {
|
||||
Settings.lastDawnDay = today
|
||||
showEventNotification(it)
|
||||
}
|
||||
GalleryDetailParser.parse(this)
|
||||
}
|
||||
|
||||
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)
|
||||
.executeAndParsingWith(FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, false) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
val url = EhUrl.API_SIGN_IN
|
||||
val origin = "https://forums.e-hentai.org"
|
||||
return ehRequest(url, referer, origin) {
|
||||
formBody {
|
||||
add("referer", referer)
|
||||
add("b", "")
|
||||
add("bt", "")
|
||||
add("UserName", username)
|
||||
add("PassWord", password)
|
||||
add("CookieDate", "1")
|
||||
}
|
||||
}.executeAndParsingWith(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: String?) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == null) {
|
||||
add("commenttext_new", comment)
|
||||
} else {
|
||||
add("commenttext_edit", comment)
|
||||
add("edit_comment", id)
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith {
|
||||
val document = Jsoup.parse(this)
|
||||
val elements = document.select("#chd + p")
|
||||
if (elements.size > 0) {
|
||||
throw EhException(elements[0].text())
|
||||
}
|
||||
GalleryDetailParser.parseComments(document)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dstCat -1 for delete, 0 - 9 for cloud favorite, others throw Exception
|
||||
* @param note max 250 characters
|
||||
*/
|
||||
suspend fun modifyFavorites(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
dstCat: Int = -1,
|
||||
note: String = "",
|
||||
) {
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "favdel"
|
||||
in 0..9 -> dstCat.toString()
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
val url = EhUrl.getAddFavorites(gid, token)
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
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")
|
||||
}
|
||||
}.executeAndParsingWith { }
|
||||
}
|
||||
|
||||
suspend fun downloadArchive(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
or: String?,
|
||||
res: String?,
|
||||
isHAtH: Boolean,
|
||||
): String? {
|
||||
if (or.isNullOrEmpty()) {
|
||||
throw EhException("Invalid form param or: $or")
|
||||
}
|
||||
if (res.isNullOrEmpty()) {
|
||||
throw EhException("Invalid res: $res")
|
||||
}
|
||||
val url = EhUrl.getDownloadArchive(gid, token, or)
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
val request = ehRequest(url, referer, EhUrl.origin) {
|
||||
formBody {
|
||||
if (isHAtH) {
|
||||
add("hathdl_xres", res)
|
||||
} else {
|
||||
add("dltype", res)
|
||||
if (res == "org") {
|
||||
add("dlcheck", "Download Original Archive")
|
||||
} else {
|
||||
add("dlcheck", "Download Resample Archive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
if (!isHAtH) {
|
||||
if (result == null) {
|
||||
// Wait for the server to prepare archives
|
||||
delay(1000)
|
||||
result = request.executeAndParsingWith(ArchiveParser::parseArchiveUrl)
|
||||
if (result == null) {
|
||||
throw EhException("Archive unavailable")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun resetImageLimits() = ehRequest(EhUrl.URL_HOME) {
|
||||
formBody {
|
||||
add("reset_imagelimit", "Reset Limit")
|
||||
}
|
||||
}.executeAndParsingWith(HomeParser::parseResetLimits)
|
||||
|
||||
suspend fun modifyFavorites(url: String, gidArray: LongArray, dstCat: Int): FavoritesParser.Result {
|
||||
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 {
|
||||
add("ddact", catStr)
|
||||
gidArray.forEach { add("modifygids[]", it.toString()) }
|
||||
}
|
||||
}.executeAndParsingWith(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url, false) }
|
||||
}
|
||||
|
||||
suspend fun getGalleryPageApi(
|
||||
gid: Long,
|
||||
index: Int,
|
||||
pToken: String,
|
||||
showKey: String?,
|
||||
previousPToken: String?,
|
||||
): GalleryPageApiParser.Result {
|
||||
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
|
||||
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "showpage")
|
||||
put("gid", gid)
|
||||
put("page", index + 1)
|
||||
put("imgkey", pToken)
|
||||
put("showkey", showKey)
|
||||
}
|
||||
}.executeAndParsingWith(GalleryPageApiParser::parse)
|
||||
}
|
||||
|
||||
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", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("rating", ceil((rating * 2).toDouble()).toInt())
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) = galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
|
||||
suspend fun voteComment(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
commentId: Long,
|
||||
commentVote: Int,
|
||||
): VoteCommentResult = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "votecomment")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("comment_id", commentId)
|
||||
put("comment_vote", commentVote)
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
|
||||
suspend fun voteTag(
|
||||
apiUid: Long,
|
||||
apiKey: String?,
|
||||
gid: Long,
|
||||
token: String?,
|
||||
tags: String?,
|
||||
vote: Int,
|
||||
) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "taggallery")
|
||||
put("apiuid", apiUid)
|
||||
put("apikey", apiKey)
|
||||
put("gid", gid)
|
||||
put("token", token)
|
||||
put("tags", tags)
|
||||
put("vote", vote)
|
||||
}
|
||||
}.executeAndParsingWith(VoteTagParser::parse)
|
||||
|
||||
suspend fun getGalleryToken(gid: Long, gtoken: String?, page: Int) = ehRequest(EhUrl.apiUrl, EhUrl.referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gtoken")
|
||||
array("pagelist") {
|
||||
addJsonArray {
|
||||
add(gid)
|
||||
add(gtoken)
|
||||
add(page + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith(GalleryTokenApiParser::parse)
|
||||
|
||||
/**
|
||||
* @param image Must be jpeg
|
||||
*/
|
||||
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = ehRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
|
||||
multipartBody {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
|
||||
if (uss) addFormDataPart("fs_similar", "on")
|
||||
if (osc) addFormDataPart("fs_covers", "on")
|
||||
addFormDataPart("f_sfile", "File Search")
|
||||
}
|
||||
}.executeAndParsingWith(GalleryListParser::parse).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl, true) }
|
||||
|
||||
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean) {
|
||||
// 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 != null) {
|
||||
hasTags = true
|
||||
}
|
||||
if (gi.pages != 0) {
|
||||
hasPages = true
|
||||
}
|
||||
if (gi.rated) {
|
||||
hasRated = true
|
||||
}
|
||||
}
|
||||
val needApi = filter && EhFilter.needTags() && !hasTags || Settings.showGalleryPages && !hasPages || hasRated
|
||||
if (needApi) fillGalleryListByApi(list, url)
|
||||
|
||||
// Filter tag, thumbnail mode need filter uploader again
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
|
||||
}
|
||||
|
||||
suspend fun modifyFavoritesRange(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.forEach { (gid, token) -> modifyFavorites(gid, token, dstCat) }
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -17,17 +17,17 @@ object EhFilter : CoroutineScope {
|
||||
val filters = async { EhDB.getAllFilter() as MutableList }
|
||||
private suspend inline fun anyActive(mode: FilterMode, predicate: (Filter) -> Boolean) = filters.await().any { it.mode == mode && it.enable && predicate(it) }
|
||||
private fun <R> Filter.launchOps(
|
||||
callback: (suspend (R) -> Unit)? = null,
|
||||
callback: ((R) -> Unit)? = null,
|
||||
ops: suspend Filter.() -> R,
|
||||
) = launch { ops().let { callback?.invoke(it) } }
|
||||
fun Filter.remember(callback: (suspend (Boolean) -> Unit)? = null) = launchOps(callback) {
|
||||
fun Filter.remember(callback: ((Boolean) -> Unit)? = null) = launchOps(callback) {
|
||||
EhDB.addFilter(this).also { if (it) filters.await().add(this) }
|
||||
}
|
||||
fun Filter.trigger(callback: (suspend (Unit) -> Unit)? = null) = launchOps(callback) {
|
||||
fun Filter.trigger(callback: ((Unit) -> Unit)? = null) = launchOps(callback) {
|
||||
enable = !enable
|
||||
EhDB.updateFilter(this)
|
||||
}
|
||||
fun Filter.forget(callback: (suspend (Unit) -> Unit)? = null) = launchOps(callback) {
|
||||
fun Filter.forget(callback: ((Unit) -> Unit)? = null) = launchOps(callback) {
|
||||
EhDB.deleteFilter(this)
|
||||
filters.await().remove(this)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.util.Log
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectOkHttpClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.okHttpClient
|
||||
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.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
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
inline fun ehRequest(url: String, referer: String? = null, origin: String? = null, builder: Request.Builder.() -> Unit = {}) = Request.Builder().url(url).apply {
|
||||
addHeader("User-Agent", CHROME_USER_AGENT)
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}.apply(builder).build()
|
||||
|
||||
inline fun Request.Builder.formBody(builder: FormBody.Builder.() -> Unit) = post(FormBody.Builder().apply(builder).build())
|
||||
|
||||
inline fun Request.Builder.multipartBody(builder: MultipartBody.Builder.() -> Unit) = post(MultipartBody.Builder().apply(builder).build())
|
||||
|
||||
val MEDIA_TYPE_JSON: MediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
inline fun JsonObjectBuilder.array(name: String, builder: JsonArrayBuilder.() -> Unit) = put(name, buildJsonArray(builder))
|
||||
|
||||
inline fun Request.Builder.jsonBody(builder: JsonObjectBuilder.() -> Unit) = post(buildJsonObject(builder).toString().toRequestBody(MEDIA_TYPE_JSON))
|
||||
|
||||
const val TAG_CALL = "CancellableCallReadingScope"
|
||||
|
||||
// This scoped response reading scope suspend function aimed at cancelling the call immediately once coroutine is cancelled.
|
||||
// i.e., bind a call with a suspended coroutine, and launch another coroutine to execute reading
|
||||
// Once origin coroutine is cancelled, then call cancelled, socket is closed, the reading coroutine will encounter [IOException]
|
||||
// Since Okio is not suspendable/cancellable, also okhttp, [executeAsync] only make [Request -> Response] cancellable, reading a Response is still not cancellable
|
||||
// Considering interrupting is not safe and performant, See https://github.com/Kotlin/kotlinx.coroutines/issues/3551#issuecomment-1353245978, we have such [usingCancellable]
|
||||
// TODO: Remove logging after this function stable
|
||||
suspend inline fun <R> Call.usingCancellable(crossinline block: suspend Response.() -> R): R = executeAsync().use {
|
||||
val call = this
|
||||
Log.d(TAG_CALL, "Got response of call$call")
|
||||
coroutineScope {
|
||||
suspendCancellableCoroutine<R> { cont ->
|
||||
launch {
|
||||
val r = runCatching {
|
||||
Log.d(TAG_CALL, "Reading response of call$call")
|
||||
block(it)
|
||||
}
|
||||
if (!isCanceled() && cont.isActive) {
|
||||
r.exceptionOrNull()?.let {
|
||||
Log.e(TAG_CALL, "Reading response of call$call failed!")
|
||||
cont.resumeWithException(it)
|
||||
}
|
||||
r.getOrNull()?.let {
|
||||
Log.d(TAG_CALL, "Reading response of call$call succeed!")
|
||||
cont.resume(it)
|
||||
}
|
||||
} else if (isCanceled() && cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call cancelled but coroutine is Active!")
|
||||
} else if (!isCanceled() && !cont.isActive) {
|
||||
Log.e(TAG_CALL, "call$call not cancelled but coroutine is Dead!")
|
||||
} else {
|
||||
Log.d(TAG_CALL, "Reading response of call$call cancelled")
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
Log.d(TAG_CALL, "Cancelling call$call")
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
Log.d(TAG_CALL, "Reading response of call$call finished!")
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> Request.execute(block: Response.() -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return okHttpClient.newCall(this).executeAsync().use(block)
|
||||
}
|
||||
suspend inline fun <R> Request.executeNonCache(crossinline block: suspend Response.() -> R) = baseOkHttpClient.newCall(this).usingCancellable(block)
|
||||
suspend inline fun <R> Request.executeNoRedirect(block: Response.() -> R) = noRedirectOkHttpClient.newCall(this).executeAsync().use(block)
|
||||
|
||||
suspend inline fun <reified T> Request.executeAndParseAs() = execute { parseAs<T>() }
|
||||
|
||||
inline fun <reified T> Response.parseAs(): T = body.source().parseAs()
|
||||
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"
|
||||
+47
-42
@@ -19,18 +19,11 @@ import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.baseOkHttpClient
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.copyTo
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import com.hippo.ehviewer.util.copyToFile
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -40,12 +33,17 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.executeAsync
|
||||
import okio.BufferedSource
|
||||
import okio.HashingSink.Companion.sha1
|
||||
import okio.blackholeSink
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object EhTagDatabase : CoroutineScope {
|
||||
private const val NAMESPACE_PREFIX = "n"
|
||||
@@ -57,6 +55,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
)
|
||||
private lateinit var tagGroups: Map<String, Map<String, String>>
|
||||
private val updateLock = Mutex()
|
||||
private val dbLock = Mutex()
|
||||
override val coroutineContext = Dispatchers.IO + Job()
|
||||
|
||||
var initialized by mutableStateOf(false)
|
||||
@@ -177,18 +176,24 @@ object EhTagDatabase : CoroutineScope {
|
||||
return sha1 != null && sha1 == getFileSha1(data)
|
||||
}
|
||||
|
||||
private suspend fun save(client: HttpClient, url: String, file: File) {
|
||||
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 {
|
||||
client.prepareGet(url).execute {
|
||||
if (it.status.isSuccess()) {
|
||||
it.bodyAsChannel().copyTo(file)
|
||||
} else {
|
||||
throw StatusCodeException(it.status.value)
|
||||
call.executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return false
|
||||
}
|
||||
response.body.use {
|
||||
it.copyToFile(file)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}.onFailure {
|
||||
file.delete()
|
||||
}.getOrThrow()
|
||||
it.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun update() {
|
||||
@@ -197,42 +202,42 @@ object EhTagDatabase : CoroutineScope {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
private suspend fun issueUpdateInMemoryData() {
|
||||
dbLock.withLock {
|
||||
getMetadata(appCtx)?.let { urls ->
|
||||
val dataName = urls[2]
|
||||
val dir = AppConfig.getFilesDir("tag-translations")
|
||||
val dataFile = File(dir, dataName).takeIf { it.exists() } ?: return
|
||||
runSuspendCatching {
|
||||
issueUpdateInMemoryData()
|
||||
tagGroups = dataFile.source().buffer().use(BufferedSource::parseAs)
|
||||
initialized = true
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}
|
||||
updateInternal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
launch {
|
||||
issueUpdateInMemoryData()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateInternal() {
|
||||
getMetadata(appCtx)?.let { metadata ->
|
||||
val sha1Name = metadata[0]
|
||||
val sha1Url = metadata[1]
|
||||
val dataName = metadata[2]
|
||||
val dataUrl = metadata[3]
|
||||
val urls = getMetadata(appCtx)
|
||||
urls?.let {
|
||||
val sha1Name = urls[0]
|
||||
val sha1Url = urls[1]
|
||||
val dataName = urls[2]
|
||||
val dataUrl = urls[3]
|
||||
|
||||
val dir = AppConfig.getFilesDir("tag-translations")
|
||||
checkNotNull(dir)
|
||||
val sha1File = File(dir, sha1Name)
|
||||
val dataFile = File(dir, dataName)
|
||||
|
||||
runSuspendCatching {
|
||||
runCatching {
|
||||
// Check current sha1 and current data
|
||||
val sha1 = getFileContent(sha1File)
|
||||
if (!checkData(sha1, dataFile)) {
|
||||
@@ -242,25 +247,25 @@ object EhTagDatabase : CoroutineScope {
|
||||
|
||||
// Save new sha1
|
||||
val tempSha1File = File(dir, "$sha1Name.tmp")
|
||||
save(ktorClient, sha1Url, tempSha1File)
|
||||
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
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
// Save new data
|
||||
val tempDataFile = File(dir, "$dataName.tmp")
|
||||
save(ktorClient, dataUrl, tempDataFile)
|
||||
check(save(baseOkHttpClient, dataUrl, tempDataFile))
|
||||
|
||||
// Check new sha1 and new data
|
||||
if (!checkData(tempSha1, tempDataFile)) {
|
||||
FileUtils.delete(tempSha1File)
|
||||
FileUtils.delete(tempDataFile)
|
||||
return
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
// Replace current sha1 and current data with new sha1 and new data
|
||||
@@ -270,7 +275,7 @@ object EhTagDatabase : CoroutineScope {
|
||||
tempDataFile.renameTo(dataFile)
|
||||
|
||||
// Read new EhTagDatabase
|
||||
issueUpdateInMemoryData(dataFile)
|
||||
issueUpdateInMemoryData()
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}
|
||||
+29
-42
@@ -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
|
||||
@@ -27,13 +24,13 @@ object EhUrl {
|
||||
const val DOMAIN_EX = "exhentai.org"
|
||||
const val DOMAIN_E = "e-hentai.org"
|
||||
const val HOST_EX = "https://$DOMAIN_EX/"
|
||||
const val API_EX = "https://s.exhentai.org/api.php"
|
||||
const val API_EX = HOST_EX + "api.php"
|
||||
const val FAV_PATH = "favorites.php"
|
||||
const val WATCHED_PATH = "watched"
|
||||
const val URL_UCONFIG_EX = HOST_EX + "uconfig.php"
|
||||
const val URL_MY_TAGS_EX = HOST_EX + "mytags"
|
||||
const val HOST_E = "https://$DOMAIN_E/"
|
||||
const val API_E = "https://api.e-hentai.org/api.php"
|
||||
const val API_E = HOST_E + "api.php"
|
||||
const val URL_UCONFIG_E = HOST_E + "uconfig.php"
|
||||
const val URL_MY_TAGS_E = HOST_E + "mytags"
|
||||
const val API_SIGN_IN = "https://forums.e-hentai.org/index.php?act=Login&CODE=01"
|
||||
@@ -67,14 +64,10 @@ object EhUrl {
|
||||
}
|
||||
|
||||
val apiUrl: String
|
||||
get() = if (Settings.forceEhThumb) {
|
||||
API_E
|
||||
} else {
|
||||
when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
get() = when (Settings.gallerySite) {
|
||||
SITE_E -> API_E
|
||||
SITE_EX -> API_EX
|
||||
else -> API_E
|
||||
}
|
||||
|
||||
val referer: String
|
||||
@@ -123,41 +116,35 @@ object EhUrl {
|
||||
return getGalleryDetailUrl(gid, token, 0, false)
|
||||
}
|
||||
|
||||
fun getGalleryDetailUrl(gid: Long, token: String?, index: Int, allComment: Boolean) = ehUrl(listOf("g", gid.toString(), token.orEmpty())) {
|
||||
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.toString(), token)).buildString()
|
||||
fun getGalleryMultiPageViewerUrl(gid: Long, token: String) = ehUrl {
|
||||
addPathSegments("mpv/$gid/$token/")
|
||||
}.toString()
|
||||
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String) = host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
fun getPageUrl(gid: Long, index: Int, pToken: String): String {
|
||||
return host + "s/" + pToken + '/' + gid + '-' + (index + 1)
|
||||
}
|
||||
|
||||
fun getAddFavorites(gid: Long, token: String?) = host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
fun getAddFavorites(gid: Long, token: String?): String {
|
||||
return host + "gallerypopups.php?gid=" + gid + "&t=" + token + "&act=addfav"
|
||||
}
|
||||
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String) = host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
fun getDownloadArchive(gid: Long, token: String?, or: String): String {
|
||||
return host + "archiver.php?gid=" + gid + "&token=" + token + "&or=" + or
|
||||
}
|
||||
|
||||
fun getTagDefinitionUrl(tag: String) = "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
fun getTagDefinitionUrl(tag: String): String {
|
||||
return "https://ehwiki.org/wiki/" + tag.replace(' ', '_')
|
||||
}
|
||||
}
|
||||
|
||||
inline fun ehUrl(path: String?, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = if (path.isNullOrBlank()) emptyList() else listOf(path),
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
inline fun ehUrl(path: List<String>, builder: ParametersBuilder.() -> Unit) = ehUrl(
|
||||
path = path,
|
||||
parameters = Parameters.build(builder),
|
||||
)
|
||||
|
||||
fun ehUrl(path: List<String>, parameters: Parameters = Parameters.Empty) = URLBuilder(
|
||||
protocol = URLProtocol.HTTPS,
|
||||
host = EhUrl.domain,
|
||||
pathSegments = path,
|
||||
parameters = parameters,
|
||||
)
|
||||
|
||||
fun ParametersBuilder.addQueryParameterIfNotBlank(name: String, value: String?) = apply {
|
||||
if (!value.isNullOrBlank()) append(name, value)
|
||||
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)
|
||||
+19
-17
@@ -16,13 +16,12 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import android.content.Context
|
||||
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 arrow.core.memoize
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.color.utilities.Hct
|
||||
import com.google.android.material.color.utilities.MathUtils
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -103,19 +102,17 @@ object EhUtils {
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
val harmonizeWithRole = { primaryContainer: Int, src: Int ->
|
||||
fun harmonizeWithRole(context: Context, src: Int): Int {
|
||||
val primary = MaterialColors.getColor(context, R.attr.colorPrimaryContainer, "EhUtils")
|
||||
val fromHct = Hct.fromInt(src)
|
||||
val toHct = Hct.fromInt(primaryContainer)
|
||||
val toHct = Hct.fromInt(primary)
|
||||
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()
|
||||
return Hct.from(outputHue, toHct.chroma, toHct.tone).toInt()
|
||||
}
|
||||
|
||||
@Stable
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun getCategoryColor(category: Int): Color {
|
||||
private fun getCategoryColor(context: Context, category: Int): Int {
|
||||
val primary = when (category) {
|
||||
DOUJINSHI -> BG_COLOR_DOUJINSHI
|
||||
MANGA -> BG_COLOR_MANGA
|
||||
@@ -129,15 +126,16 @@ object EhUtils {
|
||||
MISC -> BG_COLOR_MISC
|
||||
else -> BG_COLOR_UNKNOWN
|
||||
}.toInt()
|
||||
val color = if (Settings.harmonizeCategoryColor) {
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer.toArgb()
|
||||
harmonizeWithRole(primaryContainer, primary)
|
||||
return if (Settings.harmonizeCategoryColor) {
|
||||
harmonizeWithRole(context, primary)
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
return Color(color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getCategoryColor(category: Int) = Color(getCategoryColor(LocalContext.current, category))
|
||||
|
||||
val categoryTextColor = Color(0xffe6e0e9)
|
||||
|
||||
fun signOut() {
|
||||
@@ -148,6 +146,10 @@ object EhUtils {
|
||||
Settings.needSignIn = true
|
||||
}
|
||||
|
||||
fun needSignedIn(): Boolean {
|
||||
return Settings.needSignIn
|
||||
}
|
||||
|
||||
fun getSuitableTitle(gi: GalleryInfo): String {
|
||||
return if (Settings.showJpnTitle) {
|
||||
if (gi.titleJpn.isNullOrEmpty()) gi.title else gi.titleJpn
|
||||
File renamed without changes.
+1
-3
@@ -22,12 +22,10 @@ import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@Entity(tableName = "GALLERIES")
|
||||
data class BaseGalleryInfo(
|
||||
open class BaseGalleryInfo(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "GID")
|
||||
override var gid: Long = 0,
|
||||
+19
-16
@@ -17,38 +17,41 @@ package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class FavListUrlBuilder(
|
||||
val favCat: Int = FAV_CAT_ALL,
|
||||
val keyword: String? = null,
|
||||
class FavListUrlBuilder(
|
||||
var mPrev: String? = null,
|
||||
var mNext: String? = null,
|
||||
var jumpTo: String? = null,
|
||||
var prev: String? = null,
|
||||
var next: String? = null,
|
||||
var keyword: String? = null,
|
||||
var favCat: Int = FAV_CAT_ALL,
|
||||
) : Parcelable {
|
||||
fun setIndex(index: String?, isNext: Boolean) {
|
||||
next = index.takeIf { isNext }
|
||||
prev = index.takeUnless { isNext }
|
||||
mNext = index.takeIf { isNext }
|
||||
mPrev = index.takeUnless { isNext }
|
||||
}
|
||||
|
||||
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("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -19,18 +19,20 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryComment(
|
||||
val id: Long,
|
||||
val score: Int,
|
||||
val editable: Boolean,
|
||||
val voteUpAble: Boolean,
|
||||
val voteUpEd: Boolean,
|
||||
val voteDownAble: Boolean,
|
||||
val voteDownEd: Boolean,
|
||||
val uploader: Boolean,
|
||||
val voteState: String?,
|
||||
val time: Long,
|
||||
val user: String?,
|
||||
val comment: String,
|
||||
val lastEdited: Long,
|
||||
class GalleryApiInfo(
|
||||
var gid: Long = 0,
|
||||
var token: String? = null,
|
||||
var archiverKey: String? = null,
|
||||
var title: String? = null,
|
||||
var titleJpn: String? = null,
|
||||
var category: Int = 0,
|
||||
var thumb: String? = null,
|
||||
var uploader: String? = null,
|
||||
var posted: Long = 0,
|
||||
var filecount: Int = 0,
|
||||
var filesize: Long = 0,
|
||||
var expunged: Boolean = false,
|
||||
var rating: Float = 0f,
|
||||
var torrentcount: Int = 0,
|
||||
var tags: Array<String>?,
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryComment(
|
||||
// 0 for uploader comment. can't vote
|
||||
var id: Long = 0,
|
||||
var score: Int = 0,
|
||||
var editable: Boolean = false,
|
||||
var voteUpAble: Boolean = false,
|
||||
var voteUpEd: Boolean = false,
|
||||
var voteDownAble: Boolean = false,
|
||||
var voteDownEd: Boolean = false,
|
||||
var uploader: Boolean = false,
|
||||
var voteState: String? = null,
|
||||
var time: Long = 0,
|
||||
var user: String? = null,
|
||||
var comment: String? = null,
|
||||
var lastEdited: Long = 0,
|
||||
) : Parcelable
|
||||
+2
-2
@@ -19,7 +19,7 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryCommentList(
|
||||
val comments: List<GalleryComment>,
|
||||
class GalleryCommentList(
|
||||
var comments: Array<GalleryComment>,
|
||||
val hasMore: Boolean,
|
||||
) : Parcelable
|
||||
+2
-2
@@ -37,7 +37,7 @@ private val LANGUAGES = arrayOf(
|
||||
)
|
||||
|
||||
@Parcelize
|
||||
data class GalleryDetail(
|
||||
class GalleryDetail(
|
||||
val galleryInfo: BaseGalleryInfo = BaseGalleryInfo(),
|
||||
var apiUid: Long = -1L,
|
||||
var apiKey: String? = null,
|
||||
@@ -51,7 +51,7 @@ data class GalleryDetail(
|
||||
var size: String? = null,
|
||||
var favoriteCount: Int = 0,
|
||||
var ratingCount: Int = 0,
|
||||
val tags: List<GalleryTagGroup>,
|
||||
val tags: Array<GalleryTagGroup>,
|
||||
var comments: GalleryCommentList,
|
||||
val previewPages: Int,
|
||||
val previewList: List<GalleryPreview>,
|
||||
+55
-50
@@ -16,7 +16,6 @@
|
||||
package com.hippo.ehviewer.client.data
|
||||
|
||||
import java.util.regex.Pattern
|
||||
import moe.tarsin.kt.unreachable
|
||||
|
||||
interface GalleryInfo {
|
||||
var gid: Long
|
||||
@@ -44,29 +43,67 @@ interface GalleryInfo {
|
||||
?: title?.let { generateSLangFromTitle(it) }
|
||||
}
|
||||
|
||||
private fun generateSLangFromTags(simpleTags: List<String>): String? {
|
||||
for (tag in simpleTags) {
|
||||
for (i in S_LANGS.indices) {
|
||||
if (S_LANG_TAGS[i] == tag) {
|
||||
return S_LANGS[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun generateSLangFromTitle(title: String): String? {
|
||||
for (i in S_LANGS.indices) {
|
||||
if (S_LANG_PATTERNS[i].matcher(title).find()) {
|
||||
return S_LANGS[i]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* ISO 639-1
|
||||
*/
|
||||
const val S_LANG_JA = "JA"
|
||||
const val S_LANG_EN = "EN"
|
||||
const val S_LANG_ZH = "ZH"
|
||||
const val S_LANG_NL = "NL"
|
||||
const val S_LANG_FR = "FR"
|
||||
const val S_LANG_DE = "DE"
|
||||
const val S_LANG_HU = "HU"
|
||||
const val S_LANG_IT = "IT"
|
||||
const val S_LANG_KO = "KO"
|
||||
const val S_LANG_PL = "PL"
|
||||
const val S_LANG_PT = "PT"
|
||||
const val S_LANG_RU = "RU"
|
||||
const val S_LANG_ES = "ES"
|
||||
const val S_LANG_TH = "TH"
|
||||
const val S_LANG_VI = "VI"
|
||||
|
||||
val S_LANGS = arrayOf(
|
||||
"EN",
|
||||
"ZH",
|
||||
"ES",
|
||||
"KO",
|
||||
"RU",
|
||||
"FR",
|
||||
"PT",
|
||||
"TH",
|
||||
"DE",
|
||||
"IT",
|
||||
"VI",
|
||||
"PL",
|
||||
"HU",
|
||||
"NL",
|
||||
S_LANG_EN,
|
||||
S_LANG_ZH,
|
||||
S_LANG_ES,
|
||||
S_LANG_KO,
|
||||
S_LANG_RU,
|
||||
S_LANG_FR,
|
||||
S_LANG_PT,
|
||||
S_LANG_TH,
|
||||
S_LANG_DE,
|
||||
S_LANG_IT,
|
||||
S_LANG_VI,
|
||||
S_LANG_PL,
|
||||
S_LANG_HU,
|
||||
S_LANG_NL,
|
||||
)
|
||||
private val S_LANG_PATTERNS = arrayOf(
|
||||
val S_LANG_PATTERNS = arrayOf(
|
||||
Pattern.compile(
|
||||
"[(\\[]eng(?:lish)?[)\\]]|英訳",
|
||||
Pattern.CASE_INSENSITIVE,
|
||||
),
|
||||
// [((\[]ch(?:inese)?[))\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳
|
||||
), // [((\[]ch(?:inese)?[))\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳
|
||||
Pattern.compile(
|
||||
"[(\uFF08\\[]ch(?:inese)?[)\uFF09\\]]|[汉漢]化|中[国國][语語]|中文|中国翻訳",
|
||||
Pattern.CASE_INSENSITIVE,
|
||||
@@ -110,39 +147,7 @@ interface GalleryInfo {
|
||||
"language:dutch",
|
||||
)
|
||||
|
||||
private fun generateSLangFromTags(simpleTags: List<String>): String? {
|
||||
for (tag in simpleTags) {
|
||||
for (i in S_LANGS.indices) {
|
||||
if (S_LANG_TAGS[i] == tag) {
|
||||
return S_LANGS[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun generateSLangFromTitle(title: String): String? {
|
||||
for (i in S_LANGS.indices) {
|
||||
if (S_LANG_PATTERNS[i].matcher(title).find()) {
|
||||
return S_LANGS[i]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const val NOT_FAVORITED = -2
|
||||
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
|
||||
}
|
||||
File renamed without changes.
+3
-3
@@ -19,7 +19,7 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class GalleryTagGroup(
|
||||
class GalleryTagGroup(
|
||||
val groupName: String,
|
||||
private val tagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable, MutableList<String> by tagList
|
||||
private val mTagList: MutableList<String> = mutableListOf(),
|
||||
) : Parcelable, MutableList<String> by mTagList
|
||||
+84
-59
@@ -17,19 +17,17 @@ package com.hippo.ehviewer.client.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.IntDef
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.addQueryParameter
|
||||
import com.hippo.ehviewer.client.addQueryParameterIfNotBlank
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.ui.main.AdvanceTable
|
||||
import com.hippo.ehviewer.util.encodeUTF8
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.net.URLDecoder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class ListUrlBuilder(
|
||||
@@ -37,7 +35,6 @@ data class ListUrlBuilder(
|
||||
var mode: Int = MODE_NORMAL,
|
||||
private var mPrev: String? = null,
|
||||
var mNext: String? = null,
|
||||
// Reset to null after initial loading
|
||||
var mJumpTo: String? = null,
|
||||
var category: Int = EhUtils.NONE,
|
||||
private var mKeyword: String? = null,
|
||||
@@ -51,6 +48,23 @@ data class ListUrlBuilder(
|
||||
var isOnlySearchCovers: Boolean = false,
|
||||
) : Parcelable {
|
||||
|
||||
fun reset() {
|
||||
mode = MODE_NORMAL
|
||||
mPrev = null
|
||||
mNext = null
|
||||
mJumpTo = null
|
||||
this.category = EhUtils.NONE
|
||||
mKeyword = null
|
||||
advanceSearch = -1
|
||||
minRating = -1
|
||||
pageFrom = -1
|
||||
pageTo = -1
|
||||
imagePath = null
|
||||
isUseSimilarityScan = false
|
||||
isOnlySearchCovers = false
|
||||
hash = null
|
||||
}
|
||||
|
||||
fun setIndex(index: String?, isNext: Boolean = true) {
|
||||
mNext = index?.takeIf { isNext }
|
||||
mPrev = index?.takeUnless { isNext }
|
||||
@@ -66,7 +80,8 @@ data class ListUrlBuilder(
|
||||
mKeyword = keyword
|
||||
}
|
||||
|
||||
constructor(q: QuickSearch) : this() {
|
||||
fun set(q: QuickSearch) {
|
||||
reset()
|
||||
mode = q.mode
|
||||
this.category = q.category
|
||||
mKeyword = q.keyword
|
||||
@@ -119,8 +134,9 @@ data class ListUrlBuilder(
|
||||
/**
|
||||
* @param query xxx=yyy&mmm=nnn
|
||||
*/
|
||||
constructor(query: String?) : this() {
|
||||
// TODO page
|
||||
// TODO page
|
||||
fun setQuery(query: String?) {
|
||||
reset()
|
||||
if (query.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
@@ -255,23 +271,31 @@ data class ListUrlBuilder(
|
||||
|
||||
fun build(): String {
|
||||
return when (mode) {
|
||||
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
|
||||
if (category > 0) {
|
||||
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
|
||||
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())
|
||||
}
|
||||
val query = mKeyword?.let {
|
||||
val index = Settings.languageFilter.value
|
||||
GalleryInfo.S_LANG_TAGS.getOrNull(index)?.let { lang ->
|
||||
"$it $lang"
|
||||
} ?: it
|
||||
}
|
||||
addQueryParameterIfNotBlank("f_search", query)
|
||||
addQueryParameterIfNotBlank("f_shash", hash)
|
||||
addQueryParameterIfNotBlank("prev", mPrev)
|
||||
addQueryParameterIfNotBlank("next", mNext)
|
||||
addQueryParameterIfNotBlank("seek", mJumpTo)
|
||||
// Search key
|
||||
// the settings of ub:UrlBuilder may be overwritten by following Advance search
|
||||
mKeyword?.split('|')?.forEachIndexed { idx, kwd ->
|
||||
val keyword = kwd.trim { it <= ' ' }
|
||||
when (idx) {
|
||||
0 -> addQueryParameterIfNotBlank("f_search", keyword)
|
||||
|
||||
else -> keyword.indexOf(':').takeIf { it >= 0 }?.run {
|
||||
val key = keyword.substring(0, this).trim { it <= ' ' }
|
||||
val value = keyword.substring(this + 1).trim { it <= ' ' }
|
||||
addQueryParameterIfNotBlank(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Advance search
|
||||
if (advanceSearch > 0 || minRating > 0 || pageFrom > 0 || pageTo > 0) {
|
||||
if (advanceSearch != -1) {
|
||||
addQueryParameter("advsearch", "1")
|
||||
if (advanceSearch and AdvanceTable.SH != 0) {
|
||||
addQueryParameter("f_sh", "on")
|
||||
@@ -289,72 +313,73 @@ data class ListUrlBuilder(
|
||||
addQueryParameter("f_sft", "on")
|
||||
}
|
||||
// Set min star
|
||||
if (minRating > 0) {
|
||||
if (minRating != -1) {
|
||||
addQueryParameter("f_sr", "on")
|
||||
addQueryParameter("f_srdd", "$minRating")
|
||||
}
|
||||
// Pages
|
||||
if (pageFrom > 0 || pageTo > 0) {
|
||||
if (pageFrom != -1 || pageTo != -1) {
|
||||
addQueryParameter("f_sp", "on")
|
||||
addQueryParameterIfNotBlank(
|
||||
addQueryParameter(
|
||||
"f_spf",
|
||||
pageFrom.takeIf { it > 0 }?.toString(),
|
||||
(if (pageFrom != -1) pageFrom.toString() else "").toString(),
|
||||
)
|
||||
addQueryParameterIfNotBlank(
|
||||
addQueryParameter(
|
||||
"f_spt",
|
||||
pageTo.takeIf { it > 0 }?.toString(),
|
||||
(if (pageTo != -1) pageTo.toString() else "").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)
|
||||
}
|
||||
val sb = StringBuilder(EhUrl.host)
|
||||
mKeyword?.let {
|
||||
sb.append("uploader/")
|
||||
sb.append(encodeUTF8(it))
|
||||
}
|
||||
mPrev?.let {
|
||||
sb.append("?prev=").append(it)
|
||||
}
|
||||
mNext?.let {
|
||||
sb.append("?next=").append(it)
|
||||
}
|
||||
mJumpTo?.let {
|
||||
sb.append("&seek=").append(it)
|
||||
}
|
||||
sb.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)
|
||||
}
|
||||
val sb = StringBuilder(EhUrl.host)
|
||||
mKeyword?.let {
|
||||
sb.append("tag/")
|
||||
sb.append(encodeUTF8(it))
|
||||
}
|
||||
mPrev?.let {
|
||||
sb.append("?prev=").append(it)
|
||||
}
|
||||
mNext?.let {
|
||||
sb.append("?next=").append(it)
|
||||
}
|
||||
mJumpTo?.let {
|
||||
sb.append("&seek=").append(it)
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
val sb = StringBuilder(EhUrl.HOST_E)
|
||||
sb.append("toplist.php?tl=")
|
||||
mKeyword.orEmpty().let {
|
||||
sb.append(encodeUTF8(it))
|
||||
}
|
||||
mJumpTo?.let {
|
||||
sb.append("&p=").append(it)
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
else -> throw IllegalStateException("Unexpected value: $mode")
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,31 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import splitties.init.appCtx
|
||||
|
||||
object FavoritesParser {
|
||||
suspend fun parse(body: String): Result {
|
||||
if (body.contains("This page requires you to log on.</p>")) {
|
||||
throw EhException(appCtx.getString(R.string.need_sign_in))
|
||||
}
|
||||
val catArray = arrayOfNulls<String>(10)
|
||||
val countArray = parseFav(body, catArray)
|
||||
if (countArray.isEmpty()) throw ParseException("Parse favorites error")
|
||||
val result = GalleryListParser.parse(body)
|
||||
return Result(catArray.requireNoNulls(), countArray, result)
|
||||
}
|
||||
|
||||
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: String, favCat: Array<String?>): IntArray
|
||||
File renamed without changes.
+2
-3
@@ -33,7 +33,7 @@ object GalleryApiParser {
|
||||
titleJpn = item.titleJpn.unescapeXml()
|
||||
category = getCategory(item.category)
|
||||
thumbKey = getThumbKey(handleThumbUrlResolution(item.thumb)!!)
|
||||
uploader = item.uploader?.unescapeXml()
|
||||
uploader = item.uploader.unescapeXml()
|
||||
posted = ParserUtils.formatDate(item.posted * 1000)
|
||||
rating = item.rating
|
||||
simpleTags = item.tags
|
||||
@@ -54,8 +54,7 @@ object GalleryApiParser {
|
||||
val titleJpn: String,
|
||||
val category: String,
|
||||
val thumb: String,
|
||||
// Null in some old galleries
|
||||
val uploader: String?,
|
||||
val uploader: String,
|
||||
val posted: Long,
|
||||
@SerialName("filecount")
|
||||
val pages: Int,
|
||||
+49
-43
@@ -17,7 +17,6 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
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
|
||||
@@ -42,10 +41,6 @@ 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
|
||||
@@ -53,6 +48,10 @@ import org.jsoup.nodes.Node
|
||||
import org.jsoup.select.Elements
|
||||
import org.jsoup.select.NodeTraversor
|
||||
import org.jsoup.select.NodeVisitor
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
object GalleryDetailParser {
|
||||
private val PATTERN_ERROR = Regex("<div class=\"d\">\n<p>([^<]+)</p>")
|
||||
@@ -76,7 +75,8 @@ object GalleryDetailParser {
|
||||
private val PATTERN_NEWER_DATE = Regex(", added (.+?)<br />")
|
||||
private val PATTERN_FAVORITE_SLOT =
|
||||
Regex("/fav.png\\); background-position:0px -(\\d+)px")
|
||||
private val EMPTY_GALLERY_COMMENT_LIST = GalleryCommentList(emptyList(), false)
|
||||
private val EMPTY_GALLERY_TAG_GROUP_ARRAY = arrayOf<GalleryTagGroup>()
|
||||
private val EMPTY_GALLERY_COMMENT_ARRAY = GalleryCommentList(arrayOf(), false)
|
||||
private val WEB_COMMENT_DATE_FORMAT = DateTimeFormatter
|
||||
.ofPattern("dd MMMM yyyy, HH:mm", Locale.US).withZone(ZoneOffset.UTC)
|
||||
private const val OFFENSIVE_STRING =
|
||||
@@ -93,7 +93,8 @@ object GalleryDetailParser {
|
||||
|
||||
// Error info
|
||||
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
|
||||
val document = Jsoup.parse(body)
|
||||
// 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),
|
||||
@@ -306,7 +307,7 @@ object GalleryDetailParser {
|
||||
/**
|
||||
* Parse tag groups with html parser
|
||||
*/
|
||||
private fun parseTagGroups(document: Document): List<GalleryTagGroup> {
|
||||
private fun parseTagGroups(document: Document): Array<GalleryTagGroup> {
|
||||
return try {
|
||||
val taglist = document.getElementById("taglist")!!
|
||||
val tagGroups = taglist.child(0).child(0).children()
|
||||
@@ -314,69 +315,73 @@ object GalleryDetailParser {
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
EMPTY_GALLERY_TAG_GROUP_ARRAY
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTagGroups(trs: Elements): List<GalleryTagGroup> {
|
||||
private fun parseTagGroups(trs: Elements): Array<GalleryTagGroup> {
|
||||
return try {
|
||||
trs.mapNotNull { parseTagGroup(it) }
|
||||
trs.mapNotNull { parseTagGroup(it) }.toTypedArray()
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
emptyList()
|
||||
EMPTY_GALLERY_TAG_GROUP_ARRAY
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseComment(element: Element): GalleryComment? {
|
||||
return try {
|
||||
val comment = GalleryComment()
|
||||
// Id
|
||||
val a = element.previousElementSibling()
|
||||
val name = a!!.attr("name")
|
||||
val id = name trimAnd { substring(1).toInt().toLong() }
|
||||
comment.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
|
||||
comment.uploader = true
|
||||
}
|
||||
for (e in c4.children()) {
|
||||
when (e.text()) {
|
||||
"Vote+" -> {
|
||||
voteUpAble = true
|
||||
voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
comment.voteUpAble = true
|
||||
comment.voteUpEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Vote-" -> {
|
||||
voteDownAble = true
|
||||
voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
comment.voteDownAble = true
|
||||
comment.voteDownEd = e.attr("style").trim().isNotEmpty()
|
||||
}
|
||||
|
||||
"Edit" -> editable = true
|
||||
"Edit" -> comment.editable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Vote state
|
||||
val c7 = element.getElementsByClass("c7").first()
|
||||
val voteState = c7?.text()?.trim()
|
||||
if (null != c7) {
|
||||
comment.voteState = c7.text().trim()
|
||||
}
|
||||
// Score
|
||||
val c5 = element.getElementsByClass("c5").first()
|
||||
val score = c5?.children()?.first()?.text()?.trim()?.toIntOrDefault(0) ?: 0
|
||||
if (null != c5) {
|
||||
val es = c5.children()
|
||||
if (!es.isEmpty()) {
|
||||
comment.score = es[0].text().trim().toIntOrDefault(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()
|
||||
val time = if (temp.endsWith(':')) {
|
||||
// user
|
||||
comment.user = c3.child(0).text()
|
||||
temp.substring("Posted on ".length, temp.length - " by:".length)
|
||||
} else {
|
||||
temp.substring("Posted on ".length) to null
|
||||
temp.substring("Posted on ".length)
|
||||
}
|
||||
val time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(timeStr)).toEpochMilli()
|
||||
comment.time = Instant.from(WEB_COMMENT_DATE_FORMAT.parse(time)).toEpochMilli()
|
||||
// comment
|
||||
val c6 = element.getElementsByClass("c6").first()
|
||||
// fix underline support
|
||||
@@ -385,21 +390,24 @@ object GalleryDetailParser {
|
||||
e.tagName("u")
|
||||
}
|
||||
}
|
||||
val commentHtml = c6.html()
|
||||
comment.comment = c6.html()
|
||||
// filter comment
|
||||
if (!uploader) {
|
||||
if (!comment.uploader) {
|
||||
val sEhFilter = EhFilter
|
||||
if (score <= Settings.commentThreshold || sEhFilter.filterCommenter(user.orEmpty()) || sEhFilter.filterComment(commentHtml)) {
|
||||
if (sEhFilter.filterCommenter(comment.user!!) || sEhFilter.filterComment(comment.comment!!)) {
|
||||
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,
|
||||
)
|
||||
if (c8 != null) {
|
||||
val e = c8.children().first()
|
||||
if (e != null) {
|
||||
comment.lastEdited =
|
||||
Instant.from(WEB_COMMENT_DATE_FORMAT.parse(e.text())).toEpochMilli()
|
||||
}
|
||||
}
|
||||
comment
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
@@ -411,8 +419,6 @@ object GalleryDetailParser {
|
||||
* Parse comments with html parser
|
||||
*/
|
||||
suspend fun parseComments(document: Document): GalleryCommentList {
|
||||
// Disable pretty print to get comments in raw html
|
||||
document.outputSettings().prettyPrint(false)
|
||||
return try {
|
||||
val cdiv = document.getElementById("cdiv")!!
|
||||
val c1s = cdiv.getElementsByClass("c1")
|
||||
@@ -431,11 +437,11 @@ object GalleryDetailParser {
|
||||
},
|
||||
chd!!,
|
||||
)
|
||||
GalleryCommentList(list, hasMore)
|
||||
GalleryCommentList(list.toTypedArray(), hasMore)
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
e.printStackTrace()
|
||||
EMPTY_GALLERY_COMMENT_LIST
|
||||
EMPTY_GALLERY_COMMENT_ARRAY
|
||||
}
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,38 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
|
||||
object GalleryListParser {
|
||||
suspend fun parse(body: String) = runCatching {
|
||||
parseGalleryInfoList(body).apply {
|
||||
galleryInfoList.onEach {
|
||||
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
|
||||
it.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
it.generateSLang()
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
if (body.contains("<p>You do not have any watched tags")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
}
|
||||
if (body.contains("No hits found</p>")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit)
|
||||
}
|
||||
throw ParseException("Can't parse gallery list", it)
|
||||
}
|
||||
}
|
||||
|
||||
class GalleryListResult(
|
||||
val prev: String?,
|
||||
val next: String?,
|
||||
val galleryInfoList: ArrayList<BaseGalleryInfo>,
|
||||
)
|
||||
|
||||
private external fun parseGalleryInfoList(e: String): GalleryListResult
|
||||
+7
-2
@@ -42,7 +42,9 @@ object GalleryListUrlParser {
|
||||
}
|
||||
val path = url.path ?: return null
|
||||
return if (PATH_NORMAL == path || path.isEmpty()) {
|
||||
ListUrlBuilder(url.query)
|
||||
val builder = ListUrlBuilder()
|
||||
builder.setQuery(url.query)
|
||||
builder
|
||||
} else if (path.startsWith(PATH_UPLOADER)) {
|
||||
parseUploader(path)
|
||||
} else if (path.startsWith(PATH_TAG)) {
|
||||
@@ -55,7 +57,10 @@ object GalleryListUrlParser {
|
||||
} catch (e: NumberFormatException) {
|
||||
return null
|
||||
}
|
||||
ListUrlBuilder(url.query).also { it.category = category }
|
||||
val builder = ListUrlBuilder()
|
||||
builder.setQuery(url.query)
|
||||
builder.category = category
|
||||
builder
|
||||
} else {
|
||||
null
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2019 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object GalleryPageApiParser {
|
||||
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
|
||||
fun parse(body: String): Result {
|
||||
runCatching { body.parseAs<Error>() }.onSuccess { throw ParseException(it.error) }
|
||||
val res = body.parseAs<JsonResult>()
|
||||
val imageUrl = PATTERN_IMAGE_URL.find(res.imageUrl)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(res.skipHathKey)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(res.originImageUrl)?.run {
|
||||
groupValues[1].unescapeXml() + "fullimg.php" + groupValues[2].unescapeXml()
|
||||
}
|
||||
if (!imageUrl.isNullOrEmpty()) {
|
||||
return Result(imageUrl, skipHathKey, originImageUrl)
|
||||
} else {
|
||||
throw ParseException("Parse image url and skip hath key error")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class JsonResult(
|
||||
@SerialName("i3")
|
||||
val imageUrl: String,
|
||||
@SerialName("i6")
|
||||
val skipHathKey: String,
|
||||
@SerialName("i7")
|
||||
val originImageUrl: String,
|
||||
)
|
||||
|
||||
class Result(val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.exception.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object GalleryPageParser {
|
||||
private val PATTERN_IMAGE_URL = Pattern.compile("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL =
|
||||
Pattern.compile("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
|
||||
// TODO Not sure about the size of show keys
|
||||
private val PATTERN_SHOW_KEY = Pattern.compile("var showkey=\"([0-9a-z]+)\";")
|
||||
|
||||
fun parse(body: String): Result {
|
||||
var m = PATTERN_IMAGE_URL.matcher(body)
|
||||
val imageUrl = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_SKIP_HATH_KEY.matcher(body)
|
||||
val skipHathKey = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_ORIGIN_IMAGE_URL.matcher(body)
|
||||
val originImageUrl = if (m.find()) {
|
||||
m.group(1)!!.unescapeXml() + "fullimg.php" + m.group(2)!!.unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_SHOW_KEY.matcher(body)
|
||||
val showKey = if (m.find()) {
|
||||
m.group(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return if (!imageUrl.isNullOrEmpty() && !showKey.isNullOrEmpty()) {
|
||||
Result(imageUrl, skipHathKey, originImageUrl, showKey)
|
||||
} else {
|
||||
throw ParseException("Parse image url and show error")
|
||||
}
|
||||
}
|
||||
|
||||
class Result(
|
||||
val imageUrl: String,
|
||||
val skipHathKey: String?,
|
||||
val originImageUrl: String?,
|
||||
val showKey: String,
|
||||
)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+3
-7
@@ -3,17 +3,14 @@ package com.hippo.ehviewer.client.parser
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object HomeParser {
|
||||
private val PATTERN_FUNDS = Regex("Available: ([\\d,]+) Credits.*Available: ([\\d,]+) kGP", RegexOption.DOT_MATCHES_ALL)
|
||||
private const val INSUFFICIENT_FUNDS = "Insufficient funds."
|
||||
|
||||
fun parse(body: ByteBuffer) = runCatching {
|
||||
unmarshalParsingAs<Limits>(body, ::parseLimit)
|
||||
}.getOrElse { throw ParseException("Parse image limits error", it) }
|
||||
fun parse(body: ByteBuffer) = parseLimit(body)
|
||||
|
||||
fun parseResetLimits(body: String) {
|
||||
if (body.contains(INSUFFICIENT_FUNDS)) {
|
||||
@@ -36,7 +33,6 @@ object HomeParser {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
|
||||
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
|
||||
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
|
||||
File renamed without changes.
+1
-1
@@ -31,7 +31,7 @@ object ProfileParser {
|
||||
val avatar = runCatching {
|
||||
val avatar =
|
||||
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
|
||||
if (avatar.isEmpty()) {
|
||||
if (avatar.isNullOrEmpty()) {
|
||||
null
|
||||
} else if (!avatar.startsWith("http")) {
|
||||
EhUrl.URL_FORUMS + avatar
|
||||
File renamed without changes.
File renamed without changes.
+5
-9
@@ -2,18 +2,14 @@ package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object TorrentParser {
|
||||
fun parse(body: ByteBuffer) = runCatching {
|
||||
unmarshalParsingAs<TorrentResult>(body, ::parseTorrent)
|
||||
}.getOrElse {
|
||||
throw ParseException("Can't parse torrent list", it)
|
||||
}
|
||||
parseTorrent(body)
|
||||
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Torrent(
|
||||
class Torrent(
|
||||
val posted: String,
|
||||
val size: String,
|
||||
val seeds: Int,
|
||||
@@ -25,6 +21,6 @@ data class Torrent(
|
||||
|
||||
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
|
||||
|
||||
typealias TorrentResult = List<Torrent>
|
||||
typealias TorrentResult = ArrayList<Torrent>
|
||||
|
||||
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,65 @@
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import com.hippo.ehviewer.cronet.copyToChannel
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* A HttpUriFetcher impl use Cronet for jvmheapless IO
|
||||
* cacheHeader is never respected | recorded since thumb is immutable
|
||||
*/
|
||||
class CronetHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val diskCacheKey = options.diskCacheKey ?: data
|
||||
val diskCache = requireNotNull(imageLoader.diskCache)
|
||||
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
|
||||
val success = cronetRequest(data) {
|
||||
disableCache()
|
||||
}.execute {
|
||||
diskCache.suspendEdit(diskCacheKey) {
|
||||
RandomAccessFile(data.toFile(), "rw").use {
|
||||
copyToChannel(it.channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
check(success)
|
||||
// diskcache snapshot MUST exist here
|
||||
requireNotNull(diskCache.openSnapshot(diskCacheKey))
|
||||
}
|
||||
return SourceResult(
|
||||
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
|
||||
mimeType = getMimeType(data),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ComponentRegistry.Builder.installCronetHttpUriFetcher() = add { data: Uri, options, loader ->
|
||||
if (data.scheme != "http" && data.scheme != "https") return@add null
|
||||
CronetHttpUriFetcher(data.toString(), options, loader)
|
||||
}
|
||||
|
||||
private fun getMimeType(url: String) = MimeTypeMap.getSingleton().getMimeTypeFromUrl(url)
|
||||
|
||||
private fun MimeTypeMap.getMimeTypeFromUrl(url: String?): String? {
|
||||
if (url.isNullOrBlank()) return null
|
||||
val extension = url
|
||||
.substringBeforeLast('#') // Strip the fragment.
|
||||
.substringBeforeLast('?') // Strip the query.
|
||||
.substringAfterLast('/') // Get the last path segment.
|
||||
.substringAfterLast('.', missingDelimiterValue = "") // Get the file extension.
|
||||
return getMimeTypeFromExtension(extension)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.disk.DiskCache
|
||||
import coil.disk.DiskCache
|
||||
|
||||
inline fun DiskCache.edit(key: String, crossinline block: DiskCache.Editor.() -> Unit): Boolean {
|
||||
val editor = openEditor(key) ?: return false
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import coil.decode.DecodeResult
|
||||
import coil.decode.Decoder
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.client.data.NormalGalleryPreview
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.client.url
|
||||
|
||||
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
|
||||
val key = info.thumbKey!!
|
||||
data(info.thumbUrl)
|
||||
memoryCacheKey(key)
|
||||
diskCacheKey(key)
|
||||
}
|
||||
|
||||
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
|
||||
data(preview.url)
|
||||
memoryCacheKey(preview.imageKey)
|
||||
diskCacheKey(preview.imageKey)
|
||||
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
|
||||
}
|
||||
|
||||
private val stubResult = DecodeResult(ColorDrawable(Color.BLACK), false)
|
||||
private val stubFactory = Decoder { stubResult }
|
||||
|
||||
fun ImageRequest.Builder.justDownload() = apply {
|
||||
memoryCachePolicy(CachePolicy.DISABLED)
|
||||
decoderFactory { _, _, _ -> stubFactory }
|
||||
}
|
||||
+6
-6
@@ -1,10 +1,8 @@
|
||||
package com.hippo.ehviewer.coil
|
||||
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.request.ImageResult
|
||||
import coil.intercept.Interceptor
|
||||
import coil.request.ImageResult
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -13,6 +11,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object MergeInterceptor : Interceptor {
|
||||
private val pendingContinuationMap: HashMap<String, MutableList<CancellableContinuation<Unit>>> = hashMapOf()
|
||||
@@ -41,7 +41,7 @@ object MergeInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val req = chain.request
|
||||
val key = req.memoryCacheKey?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed() }
|
||||
val key = req.memoryCacheKey?.key?.takeIf { it.startsWith("m/") || Settings.preloadThumbAggressively } ?: return withContext(req.interceptorDispatcher) { chain.proceed(req) }
|
||||
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
@@ -62,7 +62,7 @@ object MergeInterceptor : Interceptor {
|
||||
}
|
||||
|
||||
try {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed() }.apply {
|
||||
return withContext(req.interceptorDispatcher) { chain.proceed(req) }.apply {
|
||||
// Wake all pending continuations shared with the same memory key since we have written it to memory cache
|
||||
notifyScope.launch {
|
||||
synchronized(pendingContinuationMapLock) {
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
import splitties.init.appCtx
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private const val TAG = "CronetRequest"
|
||||
val pool = DirectByteBufferPool(32)
|
||||
|
||||
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(CHROME_USER_AGENT)
|
||||
}.build()
|
||||
|
||||
val cronetHttpClientExecutor = EhApplication.baseOkHttpClient.dispatcher.executorService
|
||||
|
||||
// TODO: Rewrite this to use android.net.http.HttpEngine and make it Android 14 only when released
|
||||
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) {
|
||||
logcat(tag = TAG) { "Redirected to $url" }
|
||||
req.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
|
||||
onResponse(info)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
|
||||
data.flip()
|
||||
consumer(data)
|
||||
}
|
||||
|
||||
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
|
||||
readerCont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun cronetRequest(url: String, referer: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
|
||||
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
|
||||
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
}.apply(conf).build()
|
||||
}
|
||||
|
||||
suspend inline fun CronetRequest.awaitBodyFully(crossinline callback: (ByteBuffer) -> Unit) {
|
||||
val buffer = pool.borrow()
|
||||
return try {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
consumer = {
|
||||
callback(it)
|
||||
buffer.clear()
|
||||
request.read(buffer)
|
||||
}
|
||||
onError = { readerCont.resumeWithException(it) }
|
||||
readerCont = cont
|
||||
request.read(buffer)
|
||||
}
|
||||
} finally {
|
||||
pool.recycle(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun CronetRequest.copyToChannel(chan: FileChannel, crossinline listener: ((Int) -> Unit) = {}) = awaitBodyFully {
|
||||
val bytes = chan.write(it)
|
||||
listener(bytes)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-25
@@ -24,17 +24,14 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import splitties.init.appCtx
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
private val signedIn
|
||||
get() = EhCookieStore.hasSignedIn()
|
||||
|
||||
private const val CHANNEL_ID = "DailyCheckNotification"
|
||||
|
||||
@@ -54,43 +51,46 @@ val schedHour
|
||||
val schedMinute
|
||||
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
|
||||
|
||||
private val whenToWork
|
||||
get() = LocalDateTime.now()
|
||||
.run { withHour(schedHour) }
|
||||
.run { withMinute(schedMinute) }
|
||||
|
||||
private val initialDelay
|
||||
get() = Duration.between(LocalDateTime.now(), whenToWork)
|
||||
.run { if (isNegative) plusDays(1) else this }
|
||||
|
||||
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
|
||||
val now = Clock.System.now()
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val whenToWork = LocalDateTime(
|
||||
now.toLocalDateTime(timeZone).date,
|
||||
LocalTime(schedHour, schedMinute),
|
||||
).toInstant(timeZone)
|
||||
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
return PeriodicWorkRequestBuilder<DailyCheckWork>(Duration.ofDays(1))
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(initialDelay.inWholeSeconds, TimeUnit.SECONDS)
|
||||
.setInitialDelay(initialDelay)
|
||||
.build()
|
||||
}
|
||||
|
||||
private const val WORK_NAME = "DailyCheckWork"
|
||||
private const val workName = "DailyCheckWork"
|
||||
|
||||
fun updateDailyCheckWork(context: Context) {
|
||||
if (Settings.requestNews) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
workName,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
getDailyCheckWorkRequest(),
|
||||
)
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(workName)
|
||||
}
|
||||
}
|
||||
|
||||
val today
|
||||
get() = Instant.now().truncatedTo(ChronoUnit.DAYS).epochSecond
|
||||
|
||||
suspend fun checkDawn() = runCatching {
|
||||
val now = Clock.System.now()
|
||||
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
|
||||
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
|
||||
if (signedIn && Settings.lastDawnDay != today) {
|
||||
EhEngine.getNews(true)?.let {
|
||||
Settings.lastDawnTime = now.epochSeconds
|
||||
Settings.lastDawnDay = today
|
||||
showEventNotification(it)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "OK_HTTP_3_COOKIE")
|
||||
data class Cookie(
|
||||
@ColumnInfo(name = "NAME")
|
||||
var name: String,
|
||||
@ColumnInfo(name = "VALUE")
|
||||
var value: String,
|
||||
@ColumnInfo(name = "EXPIRES_AT")
|
||||
var expiresAt: Long,
|
||||
@ColumnInfo(name = "DOMAIN")
|
||||
var domain: String,
|
||||
@ColumnInfo(name = "PATH")
|
||||
var path: String,
|
||||
@ColumnInfo(name = "SECURE")
|
||||
var secure: Boolean,
|
||||
@ColumnInfo(name = "HTTP_ONLY")
|
||||
var httpOnly: Boolean,
|
||||
@ColumnInfo(name = "PERSISTENT")
|
||||
var persistent: Boolean,
|
||||
@ColumnInfo(name = "HOST_ONLY")
|
||||
var hostOnly: Boolean,
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "_id")
|
||||
var id: Long?,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CookiesDao {
|
||||
@Query("SELECT * FROM OK_HTTP_3_COOKIE")
|
||||
suspend fun list(): List<Cookie>
|
||||
}
|
||||
+15
@@ -181,6 +181,21 @@ abstract class EhDatabase : RoomDatabase() {
|
||||
abstract fun quickSearchDao(): QuickSearchDao
|
||||
}
|
||||
|
||||
// 1 -> 2 some nullability changes
|
||||
@Database(
|
||||
entities = [Cookie::class],
|
||||
version = 2,
|
||||
autoMigrations = [
|
||||
AutoMigration(
|
||||
from = 1,
|
||||
to = 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
abstract class CookiesDatabase : RoomDatabase() {
|
||||
abstract fun cookiesDao(): CookiesDao
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [Search::class],
|
||||
version = 2,
|
||||
File renamed without changes.
File renamed without changes.
+2
-2
@@ -10,7 +10,7 @@ import androidx.room.Relation
|
||||
import com.hippo.ehviewer.client.data.AbstractDownloadInfo
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import kotlinx.datetime.Clock
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "DOWNLOADS",
|
||||
@@ -37,7 +37,7 @@ data class DownloadEntity(
|
||||
override var legacy: Int = 0,
|
||||
|
||||
@ColumnInfo(name = "TIME")
|
||||
override var time: Long = Clock.System.now().toEpochMilliseconds(),
|
||||
override var time: Long = Instant.now().toEpochMilli(),
|
||||
|
||||
@ColumnInfo(name = "LABEL", index = true)
|
||||
override var label: String? = null,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 590 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user