Compare commits

...
97 Commits
Author SHA1 Message Date
FooIbar 1d9008a1a6 Prepare for release 1.10.3
Releases / Build (push) Has been cancelled
2024-01-15 23:39:56 +08:00
FooIbar fb577dc026 Update compose snapshot and r8 2024-01-15 20:01:11 +08:00
FooIbar 675eb1b7e2 Update compose-bom to v2024.01.00-alpha01 2024-01-15 17:41:46 +08:00
renovate[bot] 6a3d522dfa Update plugin spotless to v6.24.0 2024-01-15 02:54:24 +00:00
renovate[bot] 47aa5c64e7 Update compose.destinations to v1.9.60 2024-01-14 19:25:34 +00:00
FooIbar 5ecf5c41ca Don't use password type keyboard for number input
Some devices will use secure keyboard for password input.
2024-01-14 16:40:50 +08:00
FooIbar bc6d12a873 Fix typo 2024-01-13 18:26:42 +08:00
FooIbar 4d983830df Refine exception translation 2024-01-13 12:27:52 +08:00
FooIbar 2f1e0d030e Revert "Reduce image target size since we are using fill"
This reverts commit c07d9da7

Reason for revert: Coil provides more precise target size control
compared to `ImageDecoder.setTargetSampleSize`.
2024-01-12 15:03:38 +08:00
FooIbar b7c873a172 Minor cleanup 2024-01-12 14:16:38 +08:00
renovate[bot] 9d5eb55290 Update compose.destinations to v1.9.59 2024-01-11 12:08:16 +00:00
renovate[bot] 78ffb1c4fe Update dependency com.google.android.material:material to v1.12.0-alpha03 2024-01-11 00:37:42 +00:00
renovate[bot] 031d622d5d Update androidx.lifecycle to v2.7.0 2024-01-10 19:49:20 +00:00
renovate[bot] 34da120a43 Update compose.destinations to v1.9.58 2024-01-10 19:46:27 +00:00
FooIbar 5285e1ccf2 Update coil to 3.0.0-alpha02 2024-01-10 21:10:40 +08:00
FooIbar e28efc1f50 Fix request news 2024-01-09 22:52:21 +08:00
FooIbar 1c043a81a6 Adapt coil3 changes 2024-01-09 22:09:39 +08:00
Vladimir DostoyevskiyandGG Bond 9a769cc7b6 Rust refactor (#617)
* Introduce deref_direct_bytebuffer

* Cleanup

* Cleanup

* Cleanup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-07 17:56:49 +00:00
FooIbar 045c6abe40 Revert "Always enable predictive nav" (#616)
* Revert "Always enable predictive nav"

This reverts commit 0333a8c679.

* Disable predictive nav animations by default
2024-01-07 13:45:28 +00:00
FooIbar 4ad5cef412 Fix unable to move to first page on first attempt 2024-01-07 14:24:31 +08:00
GG Bond c3ffab3c79 Move to official aboutlibraries m3 widget 2024-01-07 13:36:53 +08:00
FooIbar c82de0b779 Fix delayed application of HTTP engine changes 2024-01-07 13:36:41 +08:00
FooIbar 4e36cbabc0 Fix crash on get 509 and mDownloadReference > 1 2024-01-06 23:49:26 +08:00
FooIbar 94f87b14b7 Fix download state not updating 2024-01-06 23:49:26 +08:00
FooIbar 6bc80d1afa [Marshmallow] Archive support 2024-01-06 22:02:00 +08:00
FooIbar fe4d004083 Fix request news 2024-01-06 20:41:30 +08:00
FooIbar 28410befa3 Java 21 2024-01-05 17:36:03 +08:00
Vladimir Dostoyevskiy 515f7deb7e Update compose snapshot 2024-01-04 09:18:07 +08:00
renovate[bot] 800e66261e Update dependency com.android.application to v8.2.1 2024-01-03 19:15:18 +00:00
renovate[bot] 65658c4a02 Update dependency io.github.raamcosta.compose-destinations:core to v1.9.57 2024-01-03 17:09:11 +00:00
FooIbar 0488c7ebf4 Fix restartGalleryProvider being called on start 2024-01-03 22:09:53 +08:00
FooIbar c30c1b29ef Workaround loading images from file uri 2024-01-03 19:49:47 +08:00
GG Bond 876001ad99 Adapt coil3 changes 2024-01-03 19:13:55 +08:00
FooIbar 60dc59adbc Fix concurrent modification 2024-01-03 17:17:19 +08:00
FooIbar c994890f28 Don't retry too many times on the same request 2024-01-03 17:16:10 +08:00
FooIbar 76223a2cd2 Drop fixed workaround 2024-01-03 15:10:37 +08:00
FooIbar 6274b39b68 Kotlin 2.0.0-Beta2 2024-01-03 11:24:26 +08:00
FooIbar cca3a3e350 Lower crop border threshold
Some images have white edges on both left and right.
2024-01-02 21:39:59 +08:00
FooIbar 86ef8f2b48 Ensure cropped images meet minimum width/height 2024-01-02 18:41:53 +08:00
Vladimir DostoyevskiyandGG Bond 5d116d9477 Image wrap a CoilImage (#588)
* Image wrap a CoilImage

* Make ImageSource sealed

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-02 07:43:31 +00:00
Vladimir DostoyevskiyandGG Bond 6e73fcc8c9 Cleanup Image decode (#587)
* Move to coil snapshot

* Unify decode with coil

* Unify decode with coil

* Add AnimatedImageDecoderDecoder

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-02 06:48:15 +00:00
FooIbar 3bd12a3047 Fix typo 2024-01-01 22:40:27 +08:00
FooIbar c99d8e8275 [Marshmallow] Support crop image borders 2024-01-01 20:18:00 +08:00
FooIbar 4a12e71877 Fix crash in comment screen after leaving the app 2024-01-01 17:36:53 +08:00
GG Bond 3043901b5a Resolve #579, Resolve #581 2024-01-01 15:25:49 +08:00
renovate[bot] 7cc28955d6 Update dependency com.mikepenz.aboutlibraries.plugin to v10.10.0 2023-12-31 13:20:37 +00:00
FooIbar ef44bb4857 Prepare for next iteration 2023-12-31 19:50:55 +08:00
FooIbar 37660ef697 Prepare for release 1.10.2
Releases / Build (push) Has been cancelled
2023-12-31 19:50:11 +08:00
FooIbar 9821b3aaab Fix lint 2023-12-31 18:55:48 +08:00
FooIbar 1f5331af5a Update lint plugin and fix some issues 2023-12-31 18:55:48 +08:00
Vladimir DostoyevskiyandGG Bond 3ea6c82a6d Don't unwind (#574)
* Don't unwind

* Fixes

* Abort in panic

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 09:39:29 +00:00
FooIbar ba53d01fa1 Show tip if move favorites failed 2023-12-31 17:29:58 +08:00
FooIbar df8ae8d11a Refine crop border (#573)
* Refresh adapter instead of recreate viewer

* Convert crop broder icons to ImageVector

* Cleanup res
2023-12-31 07:42:01 +00:00
Vladimir DostoyevskiyandGG Bond c817726ab3 Use Cbor (#572)
* Track marshal/unmarshal performance

* Use Cbor

* Refine

* Drop logging

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 07:36:42 +00:00
FooIbar 9eb606810f Avoid detecting gallery URL copied from share menu 2023-12-31 14:10:16 +08:00
Vladimir DostoyevskiyandGG Bond fbb030ec8e Drop custom PtrCursor (#569)
* Drop custom PtrCursor

* Drop unused crate

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-31 05:51:14 +00:00
FooIbar 28afebefb1 Follow coil 3 API changes (#570)
* Follow coil 3 API changes

* Use coil 3.0.0-alpha01
2023-12-31 05:14:25 +00:00
FooIbar da2610657c Fix typo 2023-12-31 12:37:17 +08:00
Vladimir DostoyevskiyandGG Bond 6e3471ba1b Refactor Rust parser, decouple with JNI for better further KMP support (#568)
* Use serde & kotlinx serialization to marshal jni data

* Introduce unmarshalParsingAs

* Comments

* Introduce parse_marshal_inplace

* Convert parseLimit

* Aha, convert parseGalleryInfoList

* Drop cache_unwind

* Unwind manually

* Fixes

* Drop jnix, update jni

* Data class

* Cleanups

* Cleanups

* Optimize a copy

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-30 20:45:11 +00:00
FooIbar 5a9ab6c4eb Fix snackbar dismiss action color 2023-12-31 01:25:47 +08:00
1fe553d039 Initial crop Border using cpu (#530)
* Initial get crop rect for bitmap

* Adapt for RGBA8888

* Support crop with rect in SubsamplingScaleImageView

* Settings to control crop border

* BottomBar to control crop border

* Fix rgb detect

* Update border.c

* Update Image.kt

* Fix webtoon

* Don't copy if not isAtLeastQ

* Refine

* Avoid too large bitmap

* Fix grey calculation

* Revert "Avoid too large bitmap"

This reverts commit d58dbe12062586ffe4ce4b872aace48b23ad9fde.

* Use matrix to draw bitmap to bypass the size limit

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2023-12-30 13:04:58 +00:00
GG Bond 42e0b0aeab Drop explicitly fabbuilder reallocation, buildFab within snapshotFlow 2023-12-30 11:44:27 +08:00
GG Bond 9e0ea32296 Update snapshot 2023-12-29 23:59:09 +08:00
FooIbar d3f5274b7e Hide comment button in guest mode 2023-12-29 22:37:10 +08:00
FooIbar faff0733e9 Fix unintended spaces in comments 2023-12-29 22:11:47 +08:00
FooIbar 7b32cde7dd Reword current image limits to reduce confusion 2023-12-29 21:14:12 +08:00
FooIbar 648d1d10b3 Always retrieve gallery detail from cache
Serialization & deserialization will create a new copy
2023-12-29 21:10:34 +08:00
Vladimir DostoyevskiyandGG Bond 52727100a0 Use ParcelFileDescriptor.open for RawFile, SAF sucks! (#561)
* Use ParcelFileDescriptor.open for RawFile, SAF sucks!

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-29 20:09:45 +08:00
FooIbar 8a2dbb99f0 Fix comments not showing after commenting 2023-12-29 13:22:36 +08:00
Vladimir DostoyevskiyandGG Bond d880b30ea9 Move to coil NetworkFetcher (#558)
* Move to coil NetworkFetcher

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-29 04:15:24 +00:00
renovate[bot] 0e2ca4e150 Update dependency org.jsoup:jsoup to v1.17.2 2023-12-29 03:48:18 +00:00
FooIbar e0eef82ed8 Fix crash when suggestions have the same keyword 2023-12-29 09:51:00 +08:00
FooIbar 09839086a2 Refactor drag reordering 2023-12-29 09:17:46 +08:00
renovate[bot] 3ca3ab7273 Update compose.destinations to v1.9.56 2023-12-28 05:09:40 +00:00
GG Bond 8386bf9c69 Fix regression again 2023-12-28 00:39:06 +08:00
GG Bond 99b8ee1f78 Adjust anim specs 2023-12-28 00:39:06 +08:00
GG Bond 0333a8c679 Always enable predictive nav 2023-12-28 00:39:06 +08:00
GG Bond e0bb09e902 Update compose snapshot, Resolve #413 2023-12-28 00:39:06 +08:00
GG Bond 77677cead3 Fix regression 2023-12-27 22:39:35 +08:00
GG Bond d4b2ff7de9 New nav anim motion 2023-12-27 22:39:35 +08:00
GG Bond b81bae09d1 Update Predictive Back for NavHost, use SeekableTransitionState 2023-12-27 22:39:35 +08:00
Vladimir DostoyevskiyandGG Bond 846e66a56c UniFile Refactors (#546)
* Use DocumentsContractCompat

* Make UniFile.imageSource a extension

* Make UniFile.openFileDescriptor a extension

* Use appCtx

* Use appCtx

* Use appCtx

* Use appCtx

* Drop saveImageToLauncher

* Cleanup

* Use operator resolve

* Simplify

* Fix Toast

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-27 08:00:32 +00:00
FooIbar 5cec635e52 Update lock-threads.yml
[no ci]
2023-12-27 15:45:35 +08:00
Vladimir DostoyevskiyandGG Bond 8d0dc88125 Implement lookup cache for UniFile (#545)
* Implement lookup cache for RawFile

* Implement lookup cache for TreeDocumentFile

* Drop unused UriHandler

* UniFile.subFile never return null

* Nonnull UniFile.fromFile

* SingleDocumentFile use appCtx

* TreeDocumentFile use appCtx

* MediaFile use appCtx

* Refine

* Refine

* Refine

* optimize TreeDocumentFile cache

* optimize RawFile cache

* Take care of dir lookup cache cause memleak

* Fixup

* Fixup

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-27 04:30:20 +00:00
FooIbar 51b58bb797 Convert MainActivity dialogs to use DialogState (#544)
* Convert MainActivity dialogs to use DialogState

* Always open settings if the button is clicked
2023-12-26 14:58:00 +00:00
Vladimir DostoyevskiyandGG Bond f79aa2350c Refactor UniFile, allow all image format, Resolve #529 (#543)
* Refactor UniFile, allow all image format, Resolve #529

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-26 14:50:28 +00:00
FooIbar 719dddeab9 Fix search bar hint not updating 2023-12-26 21:12:32 +08:00
FooIbar c1522568fd Cleanup legacy code 2023-12-26 20:50:31 +08:00
FooIbar 3ad6b9106b Merge ConfigureActivity into MainActivity 2023-12-26 20:45:22 +08:00
FooIbar 549387ac91 Strip okhttp's public suffix database (#539)
As cookies are handled by ktor now
2023-12-25 20:24:42 +08:00
Vladimir DostoyevskiyandGG Bond 7d2f985ffa Introduce DownloadThumbInterceptor (#538)
* Introduce DownloadThumbInterceptor

* Fixup

* Update DownloadInfoMagics.kt

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2023-12-25 09:27:49 +00:00
GG Bond 7e8b0a5800 No longer need restart to switch HC 2023-12-23 15:23:45 +08:00
FooIbar 69252ae8a4 Fix old download label not displayed when renaming 2023-12-22 14:53:45 +08:00
FooIbar eeed56b211 Fix crash when suggestions have the same keyword 2023-12-22 14:44:06 +08:00
renovate[bot] 8aaad071ea Update dependency com.google.devtools.ksp to v2.0.0-Beta2-1.0.16 2023-12-20 00:51:14 +00:00
FooIbar 7ef67e1bf9 Update CHANGELOG.zh-CN.MD
[no ci]
2023-12-19 19:05:16 +08:00
FooIbar 1c15d9a956 Prepare for next iteration 2023-12-19 18:36:49 +08:00
145 changed files with 1988 additions and 4658 deletions

No files matched your search

+2 -2
View File
@@ -17,11 +17,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 17
- name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
-1
View File
@@ -18,5 +18,4 @@ jobs:
- uses: dessant/lock-threads@v5
with:
issue-inactive-days: '1'
include-any-issue-labels: 'duplicate, wontfix'
process-only: 'issues'
+2 -2
View File
@@ -15,11 +15,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 17
- name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
+51 -5
View File
@@ -1,6 +1,52 @@
# 变更日志
## 未发布
## [1.10.3] - 2024-01-15
### 新功能
* [Marshmallow] 支持裁剪边缘、打开压缩包和 GIF
### 改进
* 由于部分设备上的非预期行为 [#578](https://github.com/FooIbar/EhViewer/issues/578) [#596](https://github.com/FooIbar/EhViewer/issues/596),默认禁用预见式返回导航动画,可于 设置-高级 中开启
* 杂项优化
### Bug 修复
* 刷新后页面无响应 [#579](https://github.com/FooIbar/EhViewer/issues/579)
* 浏览中保存图片后点击操作被阻挡 [#581](https://github.com/FooIbar/EhViewer/issues/581)
* 在画廊详情页面切换至后台后点击评论闪退 [#580](https://github.com/FooIbar/EhViewer/issues/580)
* 已下载的画廊中未阅读缓存过的图片解码失败 [#592](https://github.com/FooIbar/EhViewer/issues/592)
* 在收藏页多选并下载会使得下载状态出现问题 [#603](https://github.com/FooIbar/EhViewer/issues/603)
* 未能在不重启情况下切换 Http 引擎 [#610](https://github.com/FooIbar/EhViewer/issues/610)
* 在有进度的画廊阅读界面无法直接回到第一页 [#614](https://github.com/FooIbar/EhViewer/issues/614)
* 请求新闻界面失效 [#605](https://github.com/FooIbar/EhViewer/issues/605)
* 收藏夹为空解析失败 [#630](https://github.com/FooIbar/EhViewer/issues/630)
## [1.10.2] - 2023-12-31
### 新功能
* [阅读器] 支持裁剪图片黑色/白色边缘
### 改进
* 切换 HTTP 引擎无需重启
* 优化过渡动画
* 杂项性能优化
### Bug 修复
* 修复搜索建议中出现关键词相同的条目时崩溃 [#533](https://github.com/FooIbar/EhViewer/issues/533) [#555](https://github.com/FooIbar/EhViewer/issues/555)
* 修复重命名下载标签时不显示原标签 [#534](https://github.com/FooIbar/EhViewer/issues/534)
* 修复调整下载项目顺序时卡顿 [#552](https://github.com/FooIbar/EhViewer/issues/552)
* 修复新增或修改评论后黑屏 [#554](https://github.com/FooIbar/EhViewer/issues/554)
* 修复剪贴板读取bug [#566](https://github.com/FooIbar/EhViewer/issues/566)
* 修复画廊信息对话框无法通过返回按钮/手势关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
## [1.10.1] - 2023-12-19
### 重大变更
@@ -13,7 +59,7 @@
* 修复 Android 9 以下的设备上无法退出搜索栏 [#513](https://github.com/FooIbar/EhViewer/issues/513)
## 1.10.0
## [1.10.0] - 2023-12-14 [YANKED]
### 重大变更
@@ -37,7 +83,7 @@
* 修复 CI 版本不显示变更日志
## 1.9.0
## [1.9.0] - 2023-12-01
### 重大变更
@@ -51,7 +97,7 @@
### 改进
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* [阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* 优化原图下载重试策略
* 优化小屏设备上的搜索类别布局
* 优化过渡动画
@@ -69,7 +115,7 @@
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* [Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* 修复已舍弃画廊上传者解析
### 已知问题
+1 -1
View File
@@ -73,7 +73,7 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
\* Archives and GIFs are not supported
\* May not support some archives
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
+6 -8
View File
@@ -64,8 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180048
versionName = "1.10.1"
versionCode = 180050
versionName = "1.10.3"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -100,11 +100,6 @@ android {
minSdk = 23
applicationIdSuffix = ".m"
versionNameSuffix = "-M"
externalNativeBuild {
cmake {
targets += "noop"
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
@@ -158,6 +153,7 @@ android {
packaging {
resources {
excludes += "/META-INF/**"
excludes += "/okhttp3/**"
excludes += "/kotlin/**"
excludes += "**.txt"
excludes += "**.bin"
@@ -232,6 +228,7 @@ dependencies {
implementation(libs.photoview) // Dead Dependency
implementation(libs.directionalviewpager) // Dead Dependency
implementation(libs.material)
implementation(libs.material.motion.core)
implementation(libs.bundles.splitties)
@@ -244,6 +241,7 @@ dependencies {
implementation(libs.diff)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.insetter) // Dead Dependency
@@ -276,7 +274,7 @@ configurations.all {
}
kotlin {
jvmToolchain(17)
jvmToolchain(21)
}
ksp {
-5
View File
@@ -37,11 +37,6 @@
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"
+2 -4
View File
@@ -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 gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log)
add_custom_target("noop")
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics)
+7 -5
View File
@@ -86,6 +86,8 @@ const char supportExt[10][6] = {
};
static inline int filename_is_playable_file(const char *name) {
if (!name)
return false;
const char *dotptr = strrchr(name, '.');
if (!dotptr++)
return false;
@@ -105,7 +107,7 @@ static inline int compare_entries(const void *a, const void *b) {
static long archive_map_entries_index(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname(ctx->entry);
const char *name = archive_entry_pathname_utf8(ctx->entry);
if (filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
@@ -164,7 +166,7 @@ static void mempool_release_pages(void *addr, size_t size) {
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (filename_is_playable_file(archive_entry_pathname_utf8(ctx->entry)))
count++;
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
@@ -212,7 +214,7 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
static int archive_skip_to_index(archive_ctx *ctx, int index) {
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (!filename_is_playable_file(archive_entry_pathname_utf8(ctx->entry)))
continue;
if (ctx->next_index++ == index) {
return ctx->next_index - 1;
@@ -431,7 +433,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(entry)))
if (!filename_is_playable_file(archive_entry_pathname_utf8(entry)))
continue;
if (!archive_entry_is_encrypted(entry))
continue;
@@ -456,7 +458,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
ret = archive_get_ctx(&ctx, index);
if (ret)
return NULL;
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname(ctx->entry));
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname_utf8(ctx->entry));
ctx->using = 0;
return str;
}
+241
View File
@@ -0,0 +1,241 @@
#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,989 +0,0 @@
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
package androidx.compose.material3
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.view.Gravity
import android.view.View
import android.view.ViewTreeObserver
import android.view.WindowManager
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewRootForInspector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.popup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import com.hippo.ehviewer.util.isAtLeastT
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults2 {
/**
* The default animation used by [AnchoredDraggableState].
*/
@get:ExperimentalMaterial3Api
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalMaterial3Api
val AnimationSpec = SpringSpec<Float>()
}
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@Stable
@ExperimentalMaterial3Api
class SheetState2
@Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)",
),
)
constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = SheetValue.Hidden,
val confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheetFix]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = SheetValue.Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != SheetValue.PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != SheetValue.Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(SheetValue.Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(SheetValue.PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
else -> SheetValue.Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(SheetValue.Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun animateTo(
targetValue: SheetValue,
velocity: Float = anchoredDraggableState.lastVelocity,
) {
anchoredDraggableState.animateTo(targetValue, velocity)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
internal var anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState2].
*/
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
},
)
/**
* The default [Saver] implementation for [SheetState2].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
),
)
@Suppress("Deprecation")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
) = Saver<SheetState2, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
},
)
}
}
/**
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
*
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
* especially when offering a long list of action items, or when items require longer descriptions
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
* required action has been taken.
*
* ![Bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png)
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
*
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
* animates to [Hidden].
* @param modifier Optional [Modifier] for the bottom sheet.
* @param sheetState The state of the bottom sheet.
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param shape The shape of the bottom sheet.
* @param containerColor The color used for the background of this bottom sheet
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
* the matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param tonalElevation The tonal elevation of this bottom sheet.
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
* @param dragHandle Optional visual marker to swipe the bottom sheet.
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
* params.
* @param properties [ModalBottomSheetProperties2] for further customization of this
* modal bottom sheet's behavior.
* @param content The content to be displayed inside the bottom sheet.
*/
@Composable
@ExperimentalMaterial3Api
fun ModalBottomSheetFix(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState2 = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = BottomSheetDefaults.Elevation,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
content: @Composable ColumnScope.() -> Unit,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
val scope = rememberCoroutineScope()
val animateToDismiss: () -> Unit = {
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) {
onDismissRequest()
}
}
}
}
LaunchedEffect(sheetState) {
snapshotFlow {
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
}.drop(1).collectLatest {
if (it) onDismissRequest()
}
}
val settleToDismiss: (velocity: Float) -> Unit = {
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
if (!sheetState.isVisible) onDismissRequest()
}
}
val popupOnDismissRequest: () -> Unit = {
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
scope.launch { sheetState.partialExpand() }
} else { // Is expanded without collapsed state or is collapsed.
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
}
}
ModalBottomSheetPopup(
properties = properties,
onDismissRequest = popupOnDismissRequest,
windowInsets = windowInsets,
) {
val configuration = LocalConfiguration.current
val widthDp = configuration.screenWidthDp.dp
val height = configuration.screenHeightDp.dp
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
sheetState.hide()
onDismissRequest()
}
val predictiveModifier = Modifier.graphicsLayer {
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
scaleX = scale
scaleY = scale
}.offset {
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
IntOffset(ofsX, ofsY)
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight
Scrim(
color = scrimColor,
onDismissRequest = animateToDismiss,
visible = sheetState.targetValue != SheetValue.Hidden,
)
Surface(
modifier = modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.align(Alignment.TopCenter)
.offset {
IntOffset(
0,
sheetState
.requireOffset()
.toInt(),
)
} then predictiveModifier
.nestedScroll(
remember(sheetState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = sheetState,
orientation = Orientation.Vertical,
onFling = settleToDismiss,
)
},
)
.anchoredDraggable(
state = sheetState.anchoredDraggableState,
orientation = Orientation.Vertical,
enabled = sheetState.isVisible,
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
)
.modalBottomSheetAnchors(
sheetState = sheetState,
fullHeight = fullHeight.toFloat(),
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
dragHandle()
}
}
content()
}
}
}
}
if (sheetState.hasExpandedState) {
LaunchedEffect(sheetState) {
sheetState.show()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState2,
orientation: Orientation,
onFling: (velocity: Float) -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
return if (source == NestedScrollSource.Drag) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f,
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
@ExperimentalMaterial3Api
class ModalBottomSheetProperties2(
val securePolicy: SecureFlagPolicy,
val isFocusable: Boolean,
val shouldDismissOnBackPress: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModalBottomSheetProperties2) return false
if (securePolicy != other.securePolicy) return false
if (isFocusable != other.isFocusable) return false
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
return true
}
override fun hashCode(): Int {
var result = securePolicy.hashCode()
result = 31 * result + isFocusable.hashCode()
result = 31 * result + shouldDismissOnBackPress.hashCode()
return result
}
}
/**
* Default values for [ModalBottomSheetFix]
*/
@Immutable
@ExperimentalMaterial3Api
object ModalBottomSheetDefaults2 {
/**
* Properties used to customize the behavior of a [ModalBottomSheetFix].
*
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
* sheet's window.
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
* the modal bottom sheet will receive IME events and key presses, such as when
* the back button is pressed.
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
* the back button. If true, pressing the back button will call onDismissRequest.
* Note that [isFocusable] must be set to true in order to receive key events such as
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
*/
fun properties(
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
isFocusable: Boolean = true,
shouldDismissOnBackPress: Boolean = true,
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
}
/**
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberModalBottomSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): SheetState2 {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = SheetState2.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density,
),
) {
SheetState2(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState,
)
}
}
@Composable
private fun Scrim(
color: Color,
onDismissRequest: () -> Unit,
visible: Boolean,
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec(),
label = "scrim",
)
val dismissSheet = if (visible) {
Modifier
.pointerInput(onDismissRequest) {
detectTapGestures {
onDismissRequest()
}
}
.clearAndSetSemantics {}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissSheet),
) {
drawRect(color = color, alpha = alpha)
}
}
}
@ExperimentalMaterial3Api
private fun Modifier.modalBottomSheetAnchors(
sheetState: SheetState2,
fullHeight: Float,
) = onSizeChanged { sheetSize ->
val newAnchors = DraggableAnchors {
SheetValue.Hidden at fullHeight
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
SheetValue.PartiallyExpanded at fullHeight / 2f
}
if (sheetSize.height != 0) {
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
}
}
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
SheetValue.Hidden -> SheetValue.Hidden
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
val newTarget = if (hasPartiallyExpandedState) {
SheetValue.PartiallyExpanded
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
newTarget
}
}
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
}
/**
* Popup specific for modal bottom sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ModalBottomSheetPopup(
properties: ModalBottomSheetProperties2,
onDismissRequest: () -> Unit,
windowInsets: WindowInsets,
content: @Composable () -> Unit,
) {
val view = LocalView.current
val id = rememberSaveable { UUID.randomUUID() }
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val layoutDirection = LocalLayoutDirection.current
val configuration = LocalConfiguration.current
val modalBottomSheetWindow = remember(configuration) {
ModalBottomSheetWindow2(
properties = properties,
onDismissRequest = onDismissRequest,
composeView = view,
saveId = id,
).apply {
setCustomContent(
parent = parentComposition,
content = {
Box(
Modifier
.semantics { this.popup() }
.windowInsetsPadding(windowInsets)
.then(
// TODO(b/290893168): Figure out a solution for APIs < 30.
if (Build.VERSION.SDK_INT >= 33) {
Modifier.imePadding()
} else {
Modifier
},
),
) {
currentContent()
}
},
)
}
}
DisposableEffect(modalBottomSheetWindow) {
modalBottomSheetWindow.show()
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
onDispose {
modalBottomSheetWindow.disposeComposition()
modalBottomSheetWindow.dismiss()
}
}
}
/** Custom compose view for [ModalBottomSheetFix] */
@SuppressLint("ViewConstructor")
@OptIn(ExperimentalMaterial3Api::class)
private class ModalBottomSheetWindow2(
private val properties: ModalBottomSheetProperties2,
private var onDismissRequest: () -> Unit,
private val composeView: View,
saveId: UUID,
) : AbstractComposeView(composeView.context),
ViewTreeObserver.OnGlobalLayoutListener,
ViewRootForInspector,
OnBackPressedDispatcherOwner {
init {
id = android.R.id.content
// Set up view owners
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
// Enable children to draw their shadow by not clipping them
clipChildren = false
}
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
override val onBackPressedDispatcher = OnBackPressedDispatcher {
onDismissRequest()
}
private val windowManager =
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val displayWidth: Int
get() = context.resources.displayMetrics.widthPixels
private val params: WindowManager.LayoutParams =
WindowManager.LayoutParams().apply {
// Position bottom sheet from the bottom of the screen
gravity = Gravity.BOTTOM or Gravity.START
// Application panel window
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
// Fill up the entire app view
width = displayWidth
height = WindowManager.LayoutParams.MATCH_PARENT
// Format of screen pixels
format = PixelFormat.TRANSLUCENT
// Title used as fallback for a11y services
// TODO: Provide bottom sheet window resource
title = composeView.context.resources.getString(
androidx.compose.ui.R.string.default_popup_window_title,
)
// Get the Window token from the parent view
token = composeView.applicationWindowToken
// Flags specific to modal bottom sheet.
flags = flags and (
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
).inv()
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
// Security flag
val secureFlagEnabled =
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
if (secureFlagEnabled) {
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
}
// Focusable
if (!properties.isFocusable) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
} else {
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
}
}
private var content: @Composable () -> Unit by mutableStateOf({})
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
content()
}
}
fun setCustomContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit,
) {
parent?.let { setParentCompositionContext(it) }
this.content = content
shouldCreateCompositionOnAttachedToWindow = true
}
fun show() {
windowManager.addView(this, params)
if (isAtLeastT) {
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
}
}
fun dismiss() {
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
windowManager.removeViewImmediate(this)
}
override fun onGlobalLayout() {
// No-op
}
override fun setLayoutDirection(layoutDirection: Int) {
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
// from the context's locale, but we have one already from the parent composition.
}
// Sets the "real" layout direction for our content that we obtain from the parent composition.
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
val direction = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
}
super.setLayoutDirection(direction)
}
}
// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
if (windowParams != null) {
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
}
return false
}
// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
return when (this) {
SecureFlagPolicy.SecureOff -> false
SecureFlagPolicy.SecureOn -> true
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
}
}
@@ -5,6 +5,7 @@ package androidx.compose.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
@@ -25,6 +26,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -52,7 +54,6 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
import kotlin.math.roundToInt
@@ -73,7 +74,8 @@ class DrawerState2(
val anchoredDraggableState = AnchoredDraggableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
snapAnimationSpec = AnimationSpec,
decayAnimationSpec = exponentialDecay(7.5f),
confirmValueChange = confirmStateChange,
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
@@ -254,7 +256,7 @@ fun ModalNavigationDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -391,7 +393,7 @@ fun SideDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -17,17 +17,17 @@
package androidx.navigation.compose
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.ExperimentalTransitionApi
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
@@ -39,8 +39,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -56,7 +58,6 @@ import androidx.navigation.createGraph
import androidx.navigation.get
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import kotlin.math.roundToLong
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
@@ -189,6 +190,7 @@ public fun NavHost(
* @param popEnterTransition callback to define popEnter transitions for destination in this host
* @param popExitTransition callback to define popExit transitions for destination in this host
*/
@OptIn(ExperimentalTransitionApi::class)
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
public fun NavHost(
@@ -247,10 +249,40 @@ public fun NavHost(
val zIndices = remember { mutableMapOf<String, Float>() }
if (backStackEntry != null) {
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
val transitionState = remember { SeekableTransitionState(backStackEntry) }
val transition = if (enablePredictiveBack) {
LaunchedEffect(backStackEntry) {
transitionState.animateTo(backStackEntry)
}
rememberTransition(transitionState, label = "entry")
} else {
updateTransition(backStackEntry, label = "entry")
}
var inPredictiveBack by remember { mutableStateOf(false) }
PredictiveBackHandler(currentBackStack.size > 1) { backEvent ->
inPredictiveBack = true
try {
val prev = requireNotNull(navController.previousBackStackEntry)
backEvent.collect {
val interpolated = EaseOut.transform(it.progress)
transitionState.snapTo(prev, interpolated)
}
inPredictiveBack = false
navController.popBackStack()
} catch (e: CancellationException) {
withContext(NonCancellable) {
transitionState.animateTo(backStackEntry, snap())
inPredictiveBack = false
}
}
}
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
val targetDestination = targetState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
if (composeNavigator.isPop.value || inPredictiveBack) {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopEnterTransition(this)
} ?: popEnterTransition.invoke(this)
@@ -264,7 +296,7 @@ public fun NavHost(
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
val initialDestination = initialState.destination as ComposeNavigator.Destination
if (composeNavigator.isPop.value) {
if (composeNavigator.isPop.value || inPredictiveBack) {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
destination.createPopExitTransition(this)
} ?: popExitTransition.invoke(this)
@@ -283,65 +315,6 @@ public fun NavHost(
}
}
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
val transition = if (enablePredictiveBack) {
val duration = 500000000L
val transitionState = remember { MutableTransitionState(backStackEntry) }
val transition = updateTransition(transitionState = transitionState, label = "entry")
val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } }
fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) {
val fraction = animatedFraction.value
val playTimeNanos = (fraction * duration).roundToLong()
transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos)
}
LaunchedEffect(backStackEntry) {
try {
animatedFraction.animateTo(1f, animationSpec = tween(500, easing = LinearEasing)) {
seekToFraction(transitionState.currentState, backStackEntry)
}
} finally {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
PredictiveBackHandler(currentBackStack.size > 1) { flow ->
// Prev can never be null, i.e. BackHandler should not enabled when prev is null
val prev = currentBackStack.last { it != backStackEntry }
try {
flow.collect {
val interpolated = EaseOut.transform(it.progress)
animatedFraction.snapTo(interpolated)
seekToFraction(backStackEntry, prev)
}
navController.popBackStack()
} catch (e: CancellationException) {
withContext(NonCancellable) {
animatedFraction.snapTo(0f)
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
backStackEntry,
backStackEntry,
0,
)
}
}
}
transition
} else {
BackHandler(currentBackStack.size > 1) {
navController.popBackStack()
}
updateTransition(backStackEntry, label = "entry")
}
transition.AnimatedContent(
modifier,
transitionSpec = {
@@ -364,12 +337,12 @@ public fun NavHost(
},
contentAlignment,
contentKey = { it.id },
) {
) { currentEntry ->
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
it.LocalOwnersProvider(saveableStateHolder) {
(it.destination as ComposeNavigator.Destination)
.content(this, it)
currentEntry.LocalOwnersProvider(saveableStateHolder) {
(currentEntry.destination as ComposeNavigator.Destination)
.content(this, currentEntry)
}
}
LaunchedEffect(transition.currentState, transition.targetState) {
@@ -145,7 +145,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
// Min scale allowed (prevent infinite zoom)
private var minScale = minScale()
private val sRegion = Rect(0, 0, 0, 0)
private var srcRect = Rect(0, 0, 0, 0)
// Is two-finger zooming in progress
private var isZooming = false
@@ -230,8 +230,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
*
* @param bitmap Image source.
*/
fun setImage(bitmap: Bitmap) {
fun setImage(bitmap: Bitmap, rect: Rect) {
reset(true)
srcRect = Rect(rect)
onImageLoaded(bitmap)
}
@@ -267,7 +268,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
sWidth = 0
sHeight = 0
sRegion.setEmpty()
srcRect.setEmpty()
isReady = false
isImageLoaded = false
bitmap = null
@@ -760,6 +761,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
val xScale = scale
val yScale = scale
mtrx.reset()
mtrx.postTranslate((-srcRect.left).toFloat(), (-srcRect.top).toFloat())
mtrx.postScale(xScale, yScale)
mtrx.postTranslate(vTranslate!!.x, vTranslate!!.y)
canvas.drawBitmap(bitmap!!, mtrx, bitmapPaint)
@@ -908,7 +910,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
@Synchronized
private fun onImageLoaded(bitmap: Bitmap) {
// If actual dimensions don't match the declared size, reset everything.
if (sWidth > 0 && sHeight > 0 && (sWidth != bitmap.width || sHeight != bitmap.height)) {
if (sWidth > 0 && sHeight > 0 && (sWidth != srcRect.width() || sHeight != srcRect.height())) {
reset(false)
}
if (!this.bitmapIsCached) {
@@ -916,8 +918,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
this.bitmapIsCached = true
this.bitmap = bitmap
sWidth = bitmap.width
sHeight = bitmap.height
sWidth = srcRect.width()
sHeight = srcRect.height()
val ready = checkReady()
val imageLoaded = checkImageLoaded()
if (ready || imageLoaded) {
@@ -18,6 +18,7 @@ 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
@@ -28,14 +29,16 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.decode.ImageDecoderDecoder
import coil3.gif.AnimatedImageDecoder
import coil3.network.NetworkFetcher
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.coil.DownloadThumbInterceptor
import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.coil.installKtorHttpUriFetcher
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
@@ -50,10 +53,12 @@ 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.delegateLazy
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 eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
@@ -97,9 +102,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
handler?.uncaughtException(t, e)
}
super.onCreate()
if (isAtLeastP) {
System.loadLibrary("ehviewer")
}
System.loadLibrary("ehviewer")
System.loadLibrary("ehviewer_rust")
ReadableTime.initialize(this)
lifecycleScope.launchIO {
@@ -173,17 +176,20 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
galleryDetailCache.evictAll()
// Keep one entry for GalleryCommentsScreen
galleryDetailCache.trimToSize(1)
}
}
override fun newImageLoader() = imageLoader {
override fun newImageLoader(context: Context) = context.imageLoader {
components {
installKtorHttpUriFetcher()
if (isAtLeastP) {
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
serviceLoaderEnabled(false)
add(NetworkFetcher.Factory(delegateLazy { ktorClient }))
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
}
}
diskCache(imageCache)
crossfade(300)
@@ -193,7 +199,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
}
companion object {
val ktorClient by lazy {
var ktorClient by resettableLazy {
if (Settings.enableCronet && isCronetAvailable) {
HttpClient(CronetEngine) {
install(HttpCookies) {
@@ -217,7 +223,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
}
}
val noRedirectKtorClient by lazy {
var noRedirectKtorClient by resettableLazy {
HttpClient(ktorClient.engine) {
followRedirects = false
install(HttpCookies) {
@@ -17,8 +17,6 @@ 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
@@ -32,6 +30,7 @@ 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
@@ -261,11 +260,7 @@ object EhDB {
db.filterDao().list().let { newDb.filterDao().insert(it) }
newDb.close()
val dbFile = context.getDatabasePath(ehExportName)
context.contentResolver.openFileDescriptor(uri, "rw")!!.use { toFd ->
ParcelFileDescriptor.open(dbFile, MODE_READ_ONLY).use { fromFd ->
fromFd sendTo toFd
}
}
dbFile.asUniFile() sendTo uri.asUniFile()
}
}
@@ -109,10 +109,9 @@ object Settings : DataStorePreferences(null) {
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
val predictiveNavAnim = boolPref("predictive_nav_anim_2", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
val needSignInFlow: Flow<Boolean>
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
var downloadPath by stringOrNullPref("image_path", null)
@@ -146,19 +145,19 @@ object Settings : DataStorePreferences(null) {
var saveCrashLog by boolPref("save_crash_log", true)
var security by boolPref("require_unlock", false)
var removeImageFiles by boolPref("include_pic", true)
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
var needSignIn by boolPref("need_sign_in", true)
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 enableCronet by boolPref("enable_cronet", true).observed { updateWhenEnableCronetChanges() }
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 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 requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
@@ -166,7 +165,6 @@ object Settings : DataStorePreferences(null) {
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastDawnTime by longPref("last_dawn_time", 0)
var lastUpdateTime by longPref("last_update_time", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
@@ -174,6 +172,7 @@ object Settings : DataStorePreferences(null) {
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)
@@ -3,6 +3,8 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
@@ -43,6 +45,13 @@ fun updateWhenKeepMediaStatusChanges() {
}
}
fun updateWhenEnableCronetChanges() {
collectScope.launchIO {
ktorClient = ktorClient.apply { close() }
noRedirectKtorClient = noRedirectKtorClient.apply { close() }
}
}
fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
@@ -36,7 +36,7 @@ object EhCookieStore : CookiesStorage {
val cookies = load(Url(EhUrl.HOST_E))
cookies.fastForEach {
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
manager.setCookie(EhUrl.HOST_EX, it.toString())
manager.setCookie(EhUrl.HOST_EX, it.copy(maxAge = Int.MAX_VALUE).toString())
}
}
}
@@ -69,7 +69,7 @@ object EhCookieStore : CookiesStorage {
val checkTips = EhUrl.DOMAIN_E in url.host
return cookies?.let {
parseClientCookiesHeader(cookies)
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
.map { Cookie(it.key, it.value) }
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
if (checkTips) {
removeAll { it.name == KEY_CONTENT_WARNING }
@@ -31,6 +31,7 @@ import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.EventPaneParser
import com.hippo.ehviewer.client.parser.FavParserResult
import com.hippo.ehviewer.client.parser.FavoritesParser
import com.hippo.ehviewer.client.parser.ForumsParser
import com.hippo.ehviewer.client.parser.GalleryApiParser
@@ -49,6 +50,7 @@ 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.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
@@ -67,7 +69,6 @@ import java.nio.channels.FileChannel
import kotlin.math.ceil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonArray
@@ -130,6 +131,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
}
if (Settings.saveParseErrorBody) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
@@ -216,7 +223,7 @@ object EhEngine {
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
Settings.lastDawnTime = Clock.System.now().epochSeconds
Settings.lastDawnDays = today
showEventNotification(eventPane)
}
GalleryDetailParser.parse(this)
@@ -254,13 +261,18 @@ object EhEngine {
append("edit_comment", id.toString())
}
}
}.fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}.execute { response ->
// Ktor does not handle POST redirect, we need to do it manually
// https://youtrack.jetbrains.com/issue/KTOR-478
val location = response.headers["Location"] ?: url
ehRequest(location, url).fetchUsingAsText {
val document = Jsoup.parse(this)
val elements = document.select("#chd + p")
if (elements.size > 0) {
throw EhException(elements[0].text())
}
GalleryDetailParser.parseComments(document)
}
GalleryDetailParser.parseComments(document)
}
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
@@ -319,7 +331,7 @@ object EhEngine {
}
}.fetchUsingAsText(HomeParser::parseResetLimits)
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavParserResult {
val url = ehUrl(EhUrl.FAV_PATH) {
if (FavListUrlBuilder.isValidFavCat(srcCat)) addQueryParameter("favcat", srcCat.toString())
}.buildString()
@@ -16,17 +16,18 @@
package com.hippo.ehviewer.client.data
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
@Entity(tableName = "GALLERIES")
data class BaseGalleryInfo @Keep constructor(
data class BaseGalleryInfo(
@PrimaryKey
@ColumnInfo(name = "GID")
override var gid: Long = 0,
@@ -0,0 +1,13 @@
package com.hippo.ehviewer.client.parser
import io.ktor.util.moveToByteArray
import java.nio.ByteBuffer
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
inline fun <reified T> unmarshalParsingAs(body: ByteBuffer, parser: (ByteBuffer, Int) -> Int): T {
val cborBytes = parser(body, body.limit())
body.limit(cborBytes)
val array = body.moveToByteArray()
return Cbor.decodeFromByteArray<T>(array)
}
@@ -1,32 +1,29 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.NotLoggedInException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
@Serializable
data class FavParserResult(
val catArray: List<String>,
val countArray: List<Int>,
val galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
object FavoritesParser {
suspend fun parse(body: ByteBuffer): Result {
val catArray = arrayOfNulls<String>(10)
val countArray = runSuspendCatching {
parseFav(body, favCat = catArray).also { check(it.isNotEmpty()) }
fun parse(body: ByteBuffer): FavParserResult {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
val result = GalleryListParser.parse(body)
return Result(catArray.requireNoNulls(), countArray, result)
}
class Result(
val catArray: Array<String>,
val countArray: IntArray,
galleryListResult: GalleryListResult,
) {
val prev = galleryListResult.prev
val next = galleryListResult.next
val galleryInfoList = galleryListResult.galleryInfoList
}
}
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit(), favCat: Array<String?>): IntArray
private external fun parseFav(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -93,8 +93,7 @@ object GalleryDetailParser {
// Error info
PATTERN_ERROR.find(body)?.run { throw EhException(groupValues[1]) }
// Temporary workaround, see https://github.com/jhy/jsoup/issues/1850
val document = Jsoup.parse(body.replace("del>", "s>"))
val document = Jsoup.parse(body)
val galleryDetail = GalleryDetail(
tags = parseTagGroups(document),
comments = parseComments(document),
@@ -412,6 +411,8 @@ 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")
@@ -1,33 +1,20 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import moe.tarsin.coroutines.runSuspendCatching
object GalleryListParser {
suspend fun parse(body: ByteBuffer) = runSuspendCatching {
parseGalleryInfoList(body)
}.onFailure {
if (it is RuntimeException) {
if (it.message?.startsWith("Your IP address") == true) {
throw EhException(it.message)
}
if (it.message == "No watched tags!") {
throw EhException(R.string.gallery_list_empty_hit_subscription)
}
if (it.message == "No hits found!") {
throw EhException(R.string.gallery_list_empty_hit)
}
}
unmarshalParsingAs<GalleryListResult>(body, ::parseGalleryInfoList)
}.getOrElse {
throw ParseException("Can't parse gallery list", it)
}.getOrThrow().apply {
}.apply {
galleryInfoList.onEach {
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = LOCAL_FAVORITED
@@ -39,10 +26,11 @@ object GalleryListParser {
val emptyResult = GalleryListResult(null, null, arrayListOf())
}
class GalleryListResult @Keep constructor(
@Serializable
data class GalleryListResult(
val prev: String?,
val next: String?,
val galleryInfoList: ArrayList<BaseGalleryInfo>,
)
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): GalleryListResult
private external fun parseGalleryInfoList(body: ByteBuffer, size: Int = body.limit()): Int
@@ -1,18 +1,18 @@
package com.hippo.ehviewer.client.parser
import android.os.Parcelable
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.InsufficientFundsException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
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 {
parseLimit(body)
unmarshalParsingAs<Limits>(body, ::parseLimit)
}.getOrElse { throw ParseException("Parse image limits error", it) }
fun parseResetLimits(body: String) {
@@ -36,6 +36,7 @@ object HomeParser {
}
@Parcelize
data class Limits @Keep constructor(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Limits
private external fun parseLimit(body: ByteBuffer, limit: Int = body.limit()): Int
@@ -31,7 +31,7 @@ object ProfileParser {
val avatar = runCatching {
val avatar =
profilename.nextElementSibling()!!.nextElementSibling()!!.child(0).attr("src")
if (avatar.isNullOrEmpty()) {
if (avatar.isEmpty()) {
null
} else if (!avatar.startsWith("http")) {
EhUrl.URL_FORUMS + avatar
@@ -1,16 +1,19 @@
package com.hippo.ehviewer.client.parser
import androidx.annotation.Keep
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
object TorrentParser {
fun parse(body: ByteBuffer) = runCatching {
parseTorrent(body)
}.getOrElse { throw ParseException("Can't parse torrent list", it) }
unmarshalParsingAs<TorrentResult>(body, ::parseTorrent)
}.getOrElse {
throw ParseException("Can't parse torrent list", it)
}
}
class Torrent @Keep constructor(
@Serializable
data class Torrent(
val posted: String,
val size: String,
val seeds: Int,
@@ -24,4 +27,4 @@ fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloa
typealias TorrentResult = List<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): ArrayList<Torrent>
private external fun parseTorrent(body: ByteBuffer, size: Int = body.limit()): Int
@@ -0,0 +1,45 @@
package com.hippo.ehviewer.coil
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.hippo.ehviewer.EhApplication.Companion.imageCache
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.DownloadInfoMagics.decodeMagicRequestOrUrl
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.asUniFile
import eu.kanade.tachiyomi.util.lang.withIOContext
object DownloadThumbInterceptor : Interceptor {
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val magicOrUrl = chain.request.data as? String
if (magicOrUrl != null) {
val (url, location) = decodeMagicRequestOrUrl(magicOrUrl)
if (location != null) {
val thumb = withIOContext { downloadLocation / location / ".thumb" }
if (withIOContext { thumb.isFile }) {
val new = chain.request.newBuilder().data(thumb.uri.toString()).build()
when (val result = chain.withRequest(new).proceed()) {
is SuccessResult -> return result
is ErrorResult -> withIOContext { thumb.delete() }
}
}
val new = chain.request.newBuilder().data(url).build()
return chain.withRequest(new).proceed().also {
if (it is SuccessResult) {
withIOContext {
if (thumb.ensureFile()) {
val key = requireNotNull(chain.request.memoryCacheKey)
imageCache.read(key) {
data.asUniFile() sendTo thumb
}
}
}
}
}
}
}
return chain.proceed()
}
}
@@ -5,20 +5,33 @@ import android.graphics.drawable.ColorDrawable
import coil3.asCoilImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.httpHeaders
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.size.Size
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
import com.hippo.ehviewer.client.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
import com.hippo.ehviewer.spider.DownloadInfoMagics.encodeMagicRequestOrUrl
import io.ktor.http.HttpHeaders
import io.ktor.http.headers
private val header = headers {
append(HttpHeaders.UserAgent, Settings.userAgent)
append(HttpHeaders.Accept, CHROME_ACCEPT)
append(HttpHeaders.AcceptLanguage, CHROME_ACCEPT_LANGUAGE)
}
fun ImageRequest.Builder.ehUrl(info: GalleryInfo) = apply {
val key = info.thumbKey!!
data(info.thumbUrl)
data(encodeMagicRequestOrUrl(info))
memoryCacheKey(key)
diskCacheKey(key)
httpHeaders(header)
}
fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
@@ -26,6 +39,7 @@ fun ImageRequest.Builder.ehPreview(preview: GalleryPreview) = apply {
memoryCacheKey(preview.imageKey)
diskCacheKey(preview.imageKey)
if (preview is NormalGalleryPreview) size(Size.ORIGINAL)
httpHeaders(header)
}
private val stubImage = ColorDrawable(Color.BLACK).asCoilImage(true)
@@ -1,60 +0,0 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.hippo.ehviewer.coil
import android.webkit.MimeTypeMap
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.hippo.ehviewer.client.ehRequest
import com.hippo.ehviewer.util.copyTo
import io.ktor.client.statement.bodyAsChannel
/**
* A HttpUriFetcher impl use Ktor (Will do jvmheapless IO when backing engine is Cronet)
* cacheHeader is never respected | recorded since thumb is immutable
*/
class KtorHttpUriFetcher(private val data: String, private val options: Options, private val imageLoader: ImageLoader) : Fetcher {
override suspend fun fetch(): FetchResult {
val diskCacheKey = options.diskCacheKey ?: data
val diskCache = requireNotNull(imageLoader.diskCache)
val snapshot = diskCache.openSnapshot(diskCacheKey) ?: run {
val success = ehRequest(data, verbose = false).execute {
diskCache.suspendEdit(diskCacheKey) {
it.bodyAsChannel().copyTo(data.toFile())
}
}
check(success)
// diskcache snapshot MUST exist here
requireNotNull(diskCache.openSnapshot(diskCacheKey))
}
return SourceFetchResult(
source = ImageSource(snapshot.data, diskCache.fileSystem, diskCacheKey, snapshot),
mimeType = getMimeType(data),
dataSource = DataSource.DISK,
)
}
}
fun ComponentRegistry.Builder.installKtorHttpUriFetcher() = add { data: Uri, options, loader ->
if (data.scheme != "http" && data.scheme != "https") return@add null
KtorHttpUriFetcher(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)
}
@@ -28,7 +28,6 @@ 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
@@ -48,18 +47,12 @@ class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
}
}
val schedHour
get() = Settings.requestNewsTimerHour.takeUnless { it == -1 } ?: 0
val schedMinute
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
val now = Clock.System.now()
val timeZone = TimeZone.currentSystemDefault()
val whenToWork = LocalDateTime(
now.toLocalDateTime(timeZone).date,
LocalTime(schedHour, schedMinute),
LocalTime.fromSecondOfDay(Settings.requestNewsTime),
).toInstant(timeZone)
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
val constraints = Constraints.Builder()
@@ -77,7 +70,7 @@ fun updateDailyCheckWork(context: Context) {
if (Settings.requestNews) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
getDailyCheckWorkRequest(),
)
} else {
@@ -85,12 +78,13 @@ fun updateDailyCheckWork(context: Context) {
}
}
val today
get() = (Clock.System.now().epochSeconds / 86400).toInt()
suspend fun checkDawn() = runCatching {
val now = Clock.System.now()
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
EhEngine.getNews(true)?.let {
Settings.lastDawnTime = now.epochSeconds
Settings.lastDawnDays = today
showEventNotification(it)
}
}
@@ -46,14 +46,16 @@ import com.hippo.ehviewer.util.assertNotMainThread
import com.hippo.ehviewer.util.mapNotNull
import com.hippo.ehviewer.util.runAssertingNotMainThread
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.asUniFileOrNull
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import splitties.init.appCtx
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import splitties.preferences.edit
object DownloadManager : OnSpiderListener {
@@ -83,6 +85,8 @@ object DownloadManager : OnSpiderListener {
private val mutableNotifyFlow = MutableSharedFlow<DownloadInfo>(extraBufferCapacity = 1)
val notifyFlow = mutableNotifyFlow.asSharedFlow()
private val mutex = Mutex()
init {
assertNotMainThread()
map = runAssertingNotMainThread {
@@ -134,35 +138,37 @@ object DownloadManager : OnSpiderListener {
}
private suspend fun ensureDownload() {
if (mCurrentTask != null) {
// Only one download
return
}
mutex.withLock {
if (mCurrentTask != null) {
// Only one download
return
}
// Get download from wait list
if (!mWaitList.isEmpty()) {
val info = mWaitList.removeFirst()
val spider = SpiderQueen.obtainSpiderQueen(info, SpiderQueen.MODE_DOWNLOAD)
mCurrentTask = info
mCurrentSpider = spider
spider.addOnSpiderListener(this)
info.state = DownloadInfo.STATE_DOWNLOAD
info.speed = -1
info.remaining = -1
info.total = -1
info.finished = 0
info.downloaded = 0
info.legacy = -1
// Update in DB
EhDB.putDownloadInfo(info)
// Start speed count
mSpeedReminder.start()
// Notify start downloading
if (mDownloadListener != null) {
mDownloadListener!!.onStart(info)
// Get download from wait list
if (!mWaitList.isEmpty()) {
val info = mWaitList.removeFirst()
val spider = SpiderQueen.obtainSpiderQueen(info, SpiderQueen.MODE_DOWNLOAD)
mCurrentTask = info
mCurrentSpider = spider
spider.addOnSpiderListener(this)
info.state = DownloadInfo.STATE_DOWNLOAD
info.speed = -1
info.remaining = -1
info.total = -1
info.finished = 0
info.downloaded = 0
info.legacy = -1
// Update in DB
EhDB.putDownloadInfo(info)
// Start speed count
mSpeedReminder.start()
// Notify start downloading
if (mDownloadListener != null) {
mDownloadListener!!.onStart(info)
}
// Notify state update
mutableNotifyFlow.emit(info)
}
// Notify state update
mutableNotifyFlow.tryEmit(info)
}
}
@@ -183,7 +189,7 @@ object DownloadManager : OnSpiderListener {
// Update in DB
EhDB.putDownloadInfo(info)
// Notify state update
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Make sure download is running
ensureDownload()
}
@@ -213,7 +219,7 @@ object DownloadManager : OnSpiderListener {
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Make sure download is running
ensureDownload()
@@ -234,7 +240,7 @@ object DownloadManager : OnSpiderListener {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
mWaitList.addAll(updateList)
ensureDownload()
@@ -270,7 +276,7 @@ object DownloadManager : OnSpiderListener {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
mWaitList.addAll(updateList)
ensureDownload()
@@ -329,14 +335,14 @@ object DownloadManager : OnSpiderListener {
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
}
suspend fun stopDownload(gid: Long) {
val info = stopDownloadInternal(gid)
if (info != null) {
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
}
@@ -346,7 +352,7 @@ object DownloadManager : OnSpiderListener {
val info = stopCurrentDownloadInternal()
if (info != null) {
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
}
@@ -357,7 +363,7 @@ object DownloadManager : OnSpiderListener {
// Update listener
gidList.mapNotNull { mAllInfoMap[it] }.forEach {
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
// Ensure download
@@ -365,19 +371,22 @@ object DownloadManager : OnSpiderListener {
}
suspend fun stopAllDownload() {
// Stop all in wait list
for (info in mWaitList) {
info.state = DownloadInfo.STATE_NONE
// Update in DB
EhDB.putDownloadInfo(info)
mutex.withLock {
// Stop all in wait list
for (info in mWaitList) {
info.state = DownloadInfo.STATE_NONE
// Update in DB
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
// Notify
mutableNotifyFlow.emit(info)
}
mWaitList.clear()
// Stop current
val info = stopCurrentDownloadInternal()
info?.let { mutableNotifyFlow.emit(it) }
}
mWaitList.clear()
// Stop current
stopCurrentDownloadInternal()
}
suspend fun deleteDownload(gid: Long, deleteFiles: Boolean = false) {
@@ -398,7 +407,7 @@ object DownloadManager : OnSpiderListener {
if (index >= 0) {
list.remove(info)
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
}
}
@@ -421,7 +430,7 @@ object DownloadManager : OnSpiderListener {
allInfoList.removeAll(list.toSet())
// Update listener
list.forEach { mutableNotifyFlow.tryEmit(it) }
list.forEach { mutableNotifyFlow.emit(it) }
// Ensure download
ensureDownload()
@@ -434,8 +443,7 @@ object DownloadManager : OnSpiderListener {
val list = allInfoList.slice(range)
val limit = allInfoList.size - 1
list.zip(range).forEach { it.first.position = limit - it.second }
val label = allInfoList[fromPosition].label
getInfoListForLabel(label)!!.sortByPositionDescending()
getInfoListForLabel(info.label)!!.sortByPositionDescending()
return list
}
@@ -595,17 +603,6 @@ object DownloadManager : OnSpiderListener {
map[label] = mutableStateListOf()
}
suspend fun moveLabel(fromPosition: Int, toPosition: Int) {
val item = labelList.removeAt(fromPosition)
labelList.add(toPosition, item)
withIOContext {
val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition
val list = labelList.slice(range)
list.zip(range).forEach { it.first.position = it.second }
EhDB.updateDownloadLabel(list)
}
}
suspend fun renameLabel(from: String, to: String) {
val index = labelList.indexOfFirst { it.label == from }
if (index != -1) {
@@ -826,7 +823,9 @@ object DownloadManager : OnSpiderListener {
Log.e(TAG, "Current task is null, but it should not be")
} else {
info.total = mPages
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -855,7 +854,9 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onGetPage(info)
}
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -868,7 +869,9 @@ object DownloadManager : OnSpiderListener {
info.finished = mFinished
info.downloaded = mDownloaded
info.total = mTotal
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -907,7 +910,7 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onFinish(info)
}
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Start next download
ensureDownload()
}
@@ -993,7 +996,9 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onDownload(info)
}
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
mBytesRead = 0
if (!mStop) {
@@ -1017,16 +1022,14 @@ object DownloadManager : OnSpiderListener {
var downloadLocation: UniFile
get() = with(Settings) {
UniFile.fromUri(
appCtx,
Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build(),
) ?: UniFile.fromFile(AppConfig.defaultDownloadDir)!!
val uri = Uri.Builder().apply {
scheme(downloadScheme)
encodedAuthority(downloadAuthority)
encodedPath(downloadPath)
encodedQuery(downloadQuery)
encodedFragment(downloadFragment)
}.build()
uri.asUniFileOrNull() ?: requireNotNull(AppConfig.defaultDownloadDir).asUniFile()
}
set(value) = with(value.uri) {
Settings.edit {
@@ -1038,4 +1041,4 @@ var downloadLocation: UniFile
}
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation.subFile(it) }
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
@@ -13,17 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.gallery
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.RequiresApi
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ByteBufferSource
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.jni.closeArchive
import com.hippo.ehviewer.jni.extractToByteBuffer
@@ -110,7 +107,7 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider:
val buffer = extractToByteBuffer(index)
buffer ?: return
check(buffer.isDirect)
val src = object : Image.ByteBufferSource {
val src = object : ByteBufferSource {
override val source: ByteBuffer = buffer
override fun close() {
@@ -169,11 +166,7 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider:
return true
}
override fun save(index: Int, dir: UniFile, filename: String): UniFile {
val dst = dir.subFile(filename)
save(index, dst!!)
return dst
}
override fun save(index: Int, dir: UniFile, filename: String) = (dir / filename).apply { save(index, this) }
override fun preloadPages(pages: List<Int>, pair: Pair<Int, Int>) {}
}
@@ -40,9 +40,3 @@ abstract class PageLoader2 : PageLoader() {
*/
abstract fun save(index: Int, dir: UniFile, filename: String): UniFile?
}
val SUPPORT_IMAGE_EXTENSIONS = arrayOf(
".jpg",
".png",
".gif",
)
@@ -0,0 +1,39 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Filled.Crop by unsafeLazy {
materialIcon(name = "Crop") {
materialPath {
moveTo(17.0F, 15.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(7.0F)
curveToRelative(0.0F, -1.1F, -0.9F, -2.0F, -2.0F, -2.0F)
horizontalLineTo(9.0F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(8.0F)
verticalLineToRelative(8.0F)
close()
moveTo(7.0F, 17.0F)
verticalLineTo(1.0F)
horizontalLineTo(5.0F)
verticalLineToRelative(4.0F)
horizontalLineTo(1.0F)
verticalLineToRelative(2.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(10.0F)
curveToRelative(0.0F, 1.1F, 0.9F, 2.0F, 2.0F, 2.0F)
horizontalLineToRelative(10.0F)
verticalLineToRelative(4.0F)
horizontalLineToRelative(2.0F)
verticalLineToRelative(-4.0F)
horizontalLineToRelative(4.0F)
verticalLineToRelative(-2.0F)
horizontalLineTo(7.0F)
close()
}
}
}
@@ -0,0 +1,60 @@
package com.hippo.ehviewer.icons.filled
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.util.unsafeLazy
val EhIcons.Filled.CropOff by unsafeLazy {
materialIcon(name = "CropOff") {
materialPath {
moveTo(7.0F, 1.0F)
lineToRelative(-2.0F, 0.0F)
lineToRelative(0.0F, 1.18F)
lineToRelative(2.0F, 2.0F)
lineToRelative(0.0F, -3.18F)
close()
moveTo(17.0F, 7.0F)
verticalLineToRelative(7.18F)
lineToRelative(0.82F, 0.82F)
horizontalLineTo(19.0F)
verticalLineTo(7.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, -2.0F, -2.0F)
horizontalLineTo(9.0F)
verticalLineTo(6.18F)
lineTo(9.82F, 7.0F)
close()
moveTo(19.0001F, 19.0F)
lineToRelative(-2.0F, -2.0F)
horizontalLineToRelative(0.0F)
lineTo(2.1F, 2.1F)
lineTo(0.68F, 3.51F)
lineTo(2.1707F, 5.0F)
horizontalLineTo(1.0F)
verticalLineTo(7.0F)
horizontalLineTo(4.1718F)
lineTo(5.0F, 7.8278F)
verticalLineTo(17.0F)
arcToRelative(2.0059F, 2.0059F, 0.0F, false, false, 2.0F, 2.0F)
horizontalLineToRelative(9.1778F)
lineTo(17.0F, 19.8218F)
verticalLineTo(23.0F)
horizontalLineToRelative(2.0F)
verticalLineTo(21.8207F)
lineTo(20.49F, 23.31F)
lineTo(21.9F, 21.9F)
lineToRelative(-2.9F, -2.9F)
close()
moveTo(7.0F, 17.0F)
verticalLineTo(9.8268F)
lineTo(14.1768F, 17.0F)
close()
moveTo(19.82F, 17.0F)
lineToRelative(2.0F, 2.0F)
lineToRelative(1.18F, 0.0F)
lineToRelative(0.0F, -2.0F)
lineToRelative(-3.18F, 0.0F)
close()
}
}
}
@@ -19,75 +19,86 @@ package com.hippo.ehviewer.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ColorSpace
import android.graphics.ImageDecoder
import android.graphics.ImageDecoder.ImageInfo
import android.graphics.ImageDecoder.Source
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import coil3.decode.BitmapFactoryDecoder
import coil3.decode.DecodeUtils
import coil3.decode.ImageSource
import coil3.request.Options
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image as CoilImage
import coil3.asCoilImage
import coil3.executeBlocking
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.request.colorSpace
import coil3.size.Precision
import coil3.size.Scale
import coil3.size.Size
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap
import com.hippo.ehviewer.jni.munmap
import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import java.nio.ByteBuffer
import kotlin.math.min
import okio.FileSystem
import okio.buffer
import okio.source
import splitties.init.appCtx
class Image private constructor(drawable: Drawable, private val src: AutoCloseable) {
var mObtainedDrawable: Drawable? = drawable
private set
private const val CROP_THRESHOLD = 0.75f
val size = drawable.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
val size = image.size
private val intrinsicRect = image.run { Rect(0, 0, width, height) }
val rect: Rect = if (Settings.cropBorder.value && image is BitmapImage) {
val array = detectBorder(image.bitmap)
val r = Rect(array[0], array[1], array[2], array[3])
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
if (r.width() > minWidth && r.height() > minHeight) r else intrinsicRect
} else {
intrinsicRect
}
var innerImage: CoilImage? = if (image is BitmapImage && isAtLeastQ) {
val bitmap = image.bitmap
// Upload to Graphical Buffer to accelerate render
bitmap.copy(Bitmap.Config.HARDWARE, false).apply {
bitmap.recycle()
}.asCoilImage()
} else {
image
}
@Synchronized
fun recycle() {
(mObtainedDrawable as? Animatable)?.stop()
(mObtainedDrawable as? BitmapDrawable)?.bitmap?.recycle()
mObtainedDrawable?.callback = null
if (mObtainedDrawable is Animatable) src.close()
mObtainedDrawable = null
when (val image = innerImage ?: return) {
is DrawableImage -> {
(image.drawable as Animatable).stop()
image.drawable.callback = null
src.close()
}
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
}
companion object {
@RequiresApi(Build.VERSION_CODES.P)
fun calculateSampleSize(info: ImageInfo, targetHeight: Int, targetWeight: Int): Int {
return min(
info.size.width / targetWeight,
info.size.height / targetHeight,
).coerceAtLeast(1)
}
private val imageSearchMaxSize = appCtx.resources.getDimensionPixelOffset(R.dimen.image_search_max_size)
@delegate:RequiresApi(Build.VERSION_CODES.P)
val imageSearchDecoderSampleListener by lazy {
ImageDecoder.OnHeaderDecodedListener { decoder, info, _ ->
decoder.setTargetSampleSize(
calculateSampleSize(info, imageSearchMaxSize, imageSearchMaxSize),
)
}
}
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 / 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 3 / 2
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
@delegate:RequiresApi(Build.VERSION_CODES.O)
val isWideColorGamut by lazy { appCtx.resources.configuration.isScreenWideColorGamut }
@@ -95,63 +106,60 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
@RequiresApi(Build.VERSION_CODES.O)
lateinit var colorSpace: ColorSpace
suspend fun decode(src: AutoCloseable): Image? {
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.uri) }
size(targetWidth, targetHeight)
scale(Scale.FILL)
precision(Precision.INEXACT)
if (isAtLeastO) {
colorSpace(colorSpace)
}
if (Settings.cropBorder.value) {
allowHardware(false)
}
memoryCachePolicy(CachePolicy.DISABLED)
}
return when (val result = appCtx.imageLoader.execute(req)) {
is SuccessResult -> result.image
is ErrorResult -> throw result.throwable
}
}
suspend fun decode(src: ImageSource): Image? {
return runCatching {
when (src) {
is UniFileSource -> {
if (isAtLeastP) {
if (!isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
val buffer = mmap(fd)!!
val source = object : ByteBufferSource {
override val source = buffer
override fun close() {
munmap(buffer)
src.close()
}
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
val buffer = mmap(fd)!!
val source = object : ByteBufferSource {
override val source = buffer
override fun close() {
munmap(buffer)
src.close()
}
return decode(source)
}
return decode(source)
}
}
val drawable = decodeDrawable(src.source.imageSource)
if (drawable !is Animatable) src.close()
Image(drawable, src)
} else {
val options = Options(
appCtx,
size = Size(targetWidth, targetHeight),
scale = Scale.FILL,
allowInexactSize = true,
)
val drawable = ImageSource(
src.source.openInputStream().source().buffer(),
FileSystem.SYSTEM,
).use {
BitmapFactoryDecoder(it, options).decode().image.asDrawable(appCtx.resources)
}
src.close()
Image(drawable, src)
}
val image = src.right().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
}
is ByteBufferSource -> {
if (isAtLeastP) {
if (!isAtLeastU) {
rewriteGifSource(src.source)
}
val source = ImageDecoder.createSource(src.source)
val drawable = decodeDrawable(source)
if (drawable !is Animatable) src.close()
Image(drawable, src)
} else {
TODO("Unsupported")
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
val image = src.left().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
}
else -> TODO("Unsupported")
}
}.onFailure {
src.close()
@@ -159,48 +167,30 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
}.getOrNull()
}
@RequiresApi(Build.VERSION_CODES.P)
private fun decodeDrawable(src: Source) = ImageDecoder.decodeDrawable(src) { decoder, info, _ ->
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// Allocating hardware bitmap may cause a crash on framework versions prior to Android Q
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
fun Context.decodeBitmap(uri: Uri): Bitmap {
val req = imageRequest {
memoryCachePolicy(CachePolicy.DISABLED)
data(uri)
size(imageSearchMaxSize)
scale(Scale.FILL)
}
decoder.setTargetColorSpace(colorSpace)
decoder.setTargetSampleSize(calculateSampleSize(info, targetHeight, targetWidth))
}
fun Context.decodeBitmap(uri: Uri): Bitmap? = if (isAtLeastP) {
val src = ImageDecoder.createSource(contentResolver, uri)
ImageDecoder.decodeBitmap(src, imageSearchDecoderSampleListener)
} else {
contentResolver.openFileDescriptor(uri, "r")!!.use {
val options = BitmapFactory.Options()
// Disable these since we straight up compress the bitmap to JPEG
options.inScaled = false
options.inPremultiplied = false
options.inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(it.fileDescriptor, null, options)
options.inJustDecodeBounds = false
options.inSampleSize = DecodeUtils.calculateInSampleSize(
options.outWidth,
options.outHeight,
imageSearchMaxSize,
imageSearchMaxSize,
Scale.FILL,
)
BitmapFactory.decodeFileDescriptor(it.fileDescriptor, null, options)
val image = when (val result = appCtx.imageLoader.executeBlocking(req)) {
is SuccessResult -> result.image
is ErrorResult -> throw result.throwable
}
return (image as BitmapImage).bitmap
}
}
interface UniFileSource : AutoCloseable {
val source: UniFile
}
interface ByteBufferSource : AutoCloseable {
val source: ByteBuffer
}
}
sealed interface ImageSource : AutoCloseable
interface UniFileSource : ImageSource {
val source: UniFile
}
interface ByteBufferSource : ImageSource {
val source: ByteBuffer
}
private external fun detectBorder(bitmap: Bitmap): IntArray
@@ -1,9 +1,5 @@
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.jni
import android.os.Build
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
external fun releaseByteBuffer(buffer: ByteBuffer)
@@ -1,9 +1,5 @@
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.jni
import android.os.Build
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
external fun isGif(fd: Int): Boolean
@@ -0,0 +1,29 @@
package com.hippo.ehviewer.spider
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.thumbUrl
import com.hippo.ehviewer.dao.DownloadInfo
object DownloadInfoMagics {
private const val DOWNLOAD_INFO_DIRNAME_URL_MAGIC = "$"
private const val DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR = "|"
fun encodeMagicRequestOrUrl(info: GalleryInfo): String {
val url = info.thumbUrl
val location = (info as? DownloadInfo)?.dirname
return if (location.isNullOrBlank()) {
url
} else {
DOWNLOAD_INFO_DIRNAME_URL_MAGIC + url + DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR + location
}
}
fun decodeMagicRequestOrUrl(encoded: String): Pair<String, String?> {
return if (encoded.startsWith(DOWNLOAD_INFO_DIRNAME_URL_MAGIC)) {
val (a, b) = encoded.removePrefix(DOWNLOAD_INFO_DIRNAME_URL_MAGIC).split(DOWNLOAD_INFO_DIRNAME_URL_SEPARATOR)
a to b
} else {
encoded to null
}
}
}
@@ -15,8 +15,6 @@
*/
package com.hippo.ehviewer.spider
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_WRITE
import com.hippo.ehviewer.EhApplication.Companion.imageCache as sCache
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.client.EhUtils.getSuitableTitle
@@ -26,11 +24,11 @@ import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.gallery.SUPPORT_IMAGE_EXTENSIONS
import com.hippo.ehviewer.image.Image.UniFileSource
import com.hippo.ehviewer.image.UniFileSource
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.openOutputStream
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
@@ -72,13 +70,9 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
return runCatching {
sCache.read(key) {
val extension = fixExtension("." + metadata.toFile().readText())
val extension = "." + metadata.toFile().readText()
val file = dir.createFile(perFilename(index, extension)) ?: return false
file.openFileDescriptor("w").use { outFd ->
ParcelFileDescriptor.open(data.toFile(), MODE_READ_WRITE).use {
it sendTo outFd
}
}
data.asUniFile() sendTo file
true
} ?: false
}.onFailure {
@@ -117,7 +111,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
private fun findDownloadFileForIndex(index: Int, extension: String): UniFile? {
val dir = downloadDir ?: return null
val ext = fixExtension(".$extension")
val ext = ".$extension"
return dir.createFile(perFilename(index, ext))
}
@@ -155,7 +149,7 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
return sCache.suspendEdit(key) {
metadata.toFile().writeText(ext)
fops(UniFile.fromFile(data.toFile())!!)
fops(data.asUniFile())
}
}
return false
@@ -173,34 +167,28 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
}
fun saveToUniFile(index: Int, file: UniFile): Boolean {
file.openFileDescriptor("w").use { toFd ->
val key = getImageKey(mGid, index)
val key = getImageKey(mGid, index)
// Read from diskCache first
sCache.read(key) {
runCatching {
UniFile.fromFile(data.toFile())!!.openFileDescriptor("r").use {
it sendTo toFd
}
return true
}.onFailure {
it.printStackTrace()
return false
}
// Read from diskCache first
sCache.read(key) {
runCatching {
data.asUniFile() sendTo file
return true
}.onFailure {
it.printStackTrace()
return false
}
}
// Read from download dir
downloadDir?.let { uniFile ->
runCatching {
findImageFile(uniFile, index)?.openFileDescriptor("r")?.use {
it sendTo toFd
}
}.onFailure {
it.printStackTrace()
return false
}.onSuccess {
return true
}
// Read from download dir
downloadDir?.let { uniFile ->
runCatching {
requireNotNull(findImageFile(uniFile, index)) sendTo file
}.onFailure {
it.printStackTrace()
return false
}.onSuccess {
return true
}
}
return false
@@ -217,9 +205,8 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
val key = getImageKey(mGid, index)
val snapshot = sCache.openSnapshot(key)
if (snapshot != null) {
val source = UniFile.fromFile(snapshot.data.toFile())!!
return object : UniFileSource, AutoCloseable by snapshot {
override val source = source
override val source = snapshot.data.asUniFile()
}
}
}
@@ -233,8 +220,6 @@ class SpiderDen(mGalleryInfo: GalleryInfo) {
}
}
private val COMPAT_IMAGE_EXTENSIONS = SUPPORT_IMAGE_EXTENSIONS + ".jpeg"
/**
* @param extension with dot
*/
@@ -242,15 +227,9 @@ fun perFilename(index: Int, extension: String?): String {
return String.format(Locale.US, "%08d%s", index + 1, extension)
}
/**
* @param extension with dot
*/
private fun fixExtension(extension: String): String {
return extension.takeIf { SUPPORT_IMAGE_EXTENSIONS.contains(it) } ?: SUPPORT_IMAGE_EXTENSIONS[0]
}
private fun findImageFile(dir: UniFile, index: Int): UniFile? {
return COMPAT_IMAGE_EXTENSIONS.firstNotNullOfOrNull { dir.findFile(perFilename(index, it)) }
val head = perFilename(index, ".")
return dir.findFirst { name -> name.startsWith(head) }
}
suspend fun GalleryInfo.putToDownloadDir(): String {
@@ -262,5 +241,5 @@ suspend fun GalleryInfo.putToDownloadDir(): String {
suspend fun getGalleryDownloadDir(gid: Long): UniFile? {
val dirname = EhDB.getDownloadDirname(gid) ?: return null
return downloadLocation.subFile(dirname)
return downloadLocation / dirname
}
@@ -212,7 +212,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
MODE_READ -> mReadReference++
MODE_DOWNLOAD -> mDownloadReference++
}
check(mDownloadReference <= 1) { "mDownloadReference can't more than 0" }
check(mDownloadReference <= 1) { "mDownloadReference can't more than 1" }
}
private fun clearMode(@Mode mode: Int) {
@@ -310,11 +310,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
fun save(index: Int, dir: UniFile, filename: String): UniFile? {
val state = getPageState(index)
if (STATE_FINISHED != state) {
return null
}
val dst = dir.subFile(filename) ?: return null
return if (!mSpiderDen.saveToUniFile(index, dst)) null else dst
if (STATE_FINISHED != state) return null
return (dir / filename).takeIf { mSpiderDen.saveToUniFile(index, it) }
}
fun getExtension(index: Int): String? {
@@ -565,9 +562,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}.onFailure {
if (it is CancellationException) {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
if (it.message != FORCE_RETRY) {
updatePageState(index, STATE_FAILED, "Cancelled")
if (mReadReference > 0) {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
if (it.message != FORCE_RETRY) {
updatePageState(index, STATE_FAILED, "Cancelled")
}
}
throw it
}
@@ -605,19 +604,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
val previousPToken: String?
val pToken: String?
val pToken: String
pTokenLock.withLock {
// Non-local return will not release the lock
// https://github.com/Kotlin/kotlinx.coroutines/issues/3985
pToken = getPToken(index)
if (pToken == null) {
updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE)
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
previousPToken = getPToken(index - 1)
}
pToken ?: return
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
@@ -692,7 +687,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
checkNotNull(targetImageUrl)
repeat(3) { times ->
repeat(2) { times ->
runCatching {
Log.d(WORKER_DEBUG_TAG, "Start download image $index attempt #$times")
val success = withTimeout(downloadTimeout) {
@@ -81,7 +81,7 @@ import moe.tarsin.coroutines.runSuspendCatching
import splitties.init.appCtx
private fun removeNoMediaFile(downloadDir: UniFile) {
val noMedia = downloadDir.subFile(".nomedia") ?: return
val noMedia = downloadDir / ".nomedia"
noMedia.delete()
}
@@ -1,51 +0,0 @@
package com.hippo.ehviewer.ui
import android.app.Activity
import android.os.Bundle
import androidx.compose.foundation.layout.imePadding
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.ui.destinations.BaseScreenDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.findActivity
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.rememberNavHostEngine
class ConfigureActivity : EhActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setMD3Content {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
DestinationsNavHost(
navGraph = NavGraphs.root,
modifier = Modifier.imePadding(),
startRoute = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
} else {
BaseScreenDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
)
}
}
}
}
@Destination
@Composable
fun Finish() {
val context = LocalContext.current
SideEffect {
context.findActivity<Activity>().finish()
}
}
@@ -17,24 +17,22 @@ package com.hippo.ehviewer.ui
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState.DOMAIN_STATE_NONE
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
@@ -54,6 +52,7 @@ import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
@@ -65,6 +64,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -73,9 +73,11 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -89,6 +91,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation.compose.rememberNavController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
@@ -105,16 +108,21 @@ import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
import com.hippo.ehviewer.ui.destinations.HistoryScreenDestination
import com.hippo.ehviewer.ui.destinations.HomePageScreenDestination
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.SettingsScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.destinations.SubscriptionScreenDestination
import com.hippo.ehviewer.ui.destinations.ToplistScreenDestination
import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.screen.TokenArgs
import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.ui.screen.navigate
import com.hippo.ehviewer.ui.settings.showNewVersion
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ExceptionUtils
@@ -122,6 +130,7 @@ import com.hippo.ehviewer.util.addTextToClipboard
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getUrlFromClipboard
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.utils.currentDestinationAsState
@@ -147,8 +156,12 @@ private val navItems = arrayOf(
Triple(FavouritesScreenDestination, R.string.favourite, Icons.Default.Favorite),
Triple(HistoryScreenDestination, R.string.history, Icons.Default.History),
Triple(DownloadsScreenDestination, R.string.downloads, Icons.Default.Download),
Triple(SettingsScreenDestination, R.string.settings, Icons.Default.Settings),
)
val StartDestination
get() = navItems[Settings.launchPage].first
class MainActivity : EhActivity() {
private val sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>()
@@ -212,6 +225,11 @@ class MainActivity : EhActivity() {
callback()
}
LaunchedEffect(Unit) {
launch { dialogState.checkDownloadLocation() }.join()
launch { dialogState.checkAppLinkVerify() }
}
val cannotParse = stringResource(R.string.error_cannot_parse_the_url)
LaunchedEffect(Unit) {
intentFlow.collect { intent ->
@@ -289,7 +307,7 @@ class MainActivity : EhActivity() {
val snackMessage = stringResource(R.string.clipboard_gallery_url_snack_message)
val snackAction = stringResource(R.string.clipboard_gallery_url_snack_action)
LifecycleResumeEffect {
scope.launch {
val job = scope.launch {
delay(300)
val text = clipboardManager.getUrlFromClipboard(applicationContext)
val hashCode = text?.hashCode() ?: 0
@@ -310,7 +328,7 @@ class MainActivity : EhActivity() {
}
Settings.clipboardTextHashCode = hashCode
}
onPauseOrDispose { }
onPauseOrDispose { job.cancel() }
}
val currentDestination by navController.currentDestinationAsState()
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
@@ -357,19 +375,6 @@ class MainActivity : EhActivity() {
},
)
}
NavigationDrawerItem(
label = {
Text(text = stringResource(id = R.string.settings))
},
selected = false,
onClick = {
closeDrawer { startActivity(Intent(applicationContext, ConfigureActivity::class.java)) }
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
},
)
}
}
},
@@ -394,11 +399,19 @@ class MainActivity : EhActivity() {
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(
LocalViewConfiguration provides viewConfiguration,
LocalWindowSizeClass provides windowSizeClass,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = navItems[Settings.launchPage].first,
engine = rememberNavHostEngine(rootDefaultAnimations = ehNavAnim),
startRoute = if (Settings.needSignIn) {
if (EhCookieStore.hasSignedIn()) SelectSiteScreenDestination else SignInScreenDestination
} else {
StartDestination
},
engine = rememberNavHostEngine(rootDefaultAnimations = rememberEhNavAnim()),
navController = navController,
)
}
@@ -413,70 +426,75 @@ class MainActivity : EhActivity() {
if (intent.action != Intent.ACTION_MAIN) {
onNewIntent(intent)
}
checkDownloadLocation()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (!Settings.appLinkVerifyTip) {
try {
checkAppLinkVerify()
} catch (ignored: PackageManager.NameNotFoundException) {
}
}
private suspend fun DialogState.checkAppLinkVerify() {
if (isAtLeastS && !Settings.appLinkVerifyTip) {
val manager = getSystemService(DomainVerificationManager::class.java)
val packageName = packageName
val userState = manager.getDomainVerificationUserState(packageName) ?: return
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
var checked by mutableStateOf(false)
awaitPermissionOrCancel(
confirmText = R.string.open_settings,
title = R.string.app_link_not_verified_title,
onDismiss = {
if (checked) Settings.appLinkVerifyTip = true
},
) {
Column {
Text(
text = stringResource(id = R.string.app_link_not_verified_message),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(16.dp))
LabeledCheckbox(
modifier = Modifier.fillMaxWidth(),
checked = checked,
onCheckedChange = { checked = it },
label = stringResource(id = R.string.dont_show_again),
indication = null,
)
}
}
if (checked) {
Settings.appLinkVerifyTip = true
}
try {
val intent = Intent(
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun checkAppLinkVerify() {
val manager = getSystemService(DomainVerificationManager::class.java)
val userState = manager.getDomainVerificationUserState(packageName) ?: return
val hasUnverified = userState.hostToStateMap.values.any { it == DOMAIN_STATE_NONE }
if (hasUnverified) {
BaseDialogBuilder(this)
.setTitle(R.string.app_link_not_verified_title)
.setMessage(R.string.app_link_not_verified_message)
.setPositiveButton(R.string.open_settings) { _: DialogInterface?, _: Int ->
try {
val intent = Intent(
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
} catch (t: Throwable) {
val intent = Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(intent)
}
}
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.dont_show_again) { _: DialogInterface?, _: Int ->
Settings.appLinkVerifyTip = true
}
.show()
private suspend fun DialogState.checkDownloadLocation() {
val valid = withIOContext { downloadLocation.ensureDir() }
if (!valid) {
awaitPermissionOrCancel(
confirmText = R.string.get_it,
showCancelButton = false,
title = R.string.waring,
) {
Text(
text = stringResource(id = R.string.invalid_download_location),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
private fun checkDownloadLocation() {
val uniFile = downloadLocation
// null == uniFile for first start
if (uniFile.ensureDir()) {
return
}
BaseDialogBuilder(this)
.setTitle(R.string.waring)
.setMessage(R.string.invalid_download_location)
.setPositiveButton(R.string.get_it, null)
.show()
}
override fun onResume() {
if (Settings.needSignIn) {
startActivity(Intent(this, ConfigureActivity::class.java))
}
super.onResume()
}
fun showTip(@StringRes id: Int, useToast: Boolean = false) {
showTip(getString(id), useToast)
}
@@ -2,9 +2,6 @@ package com.hippo.ehviewer.ui
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -16,6 +13,9 @@ import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.ProvideVectorPainterCache
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import soup.compose.material.motion.animation.materialSharedAxisXIn
import soup.compose.material.motion.animation.materialSharedAxisXOut
import soup.compose.material.motion.animation.rememberSlideDistance
inline fun ComposeView.setMD3Content(crossinline content: @Composable () -> Unit) = setContent {
EhTheme {
@@ -45,9 +45,15 @@ inline fun ComponentActivity.setMD3Content(crossinline content: @Composable () -
}
}
val ehNavAnim by lazy {
RootNavGraphDefaultAnimations(
enterTransition = { fadeIn(animationSpec = tween(500)) },
exitTransition = { fadeOut(animationSpec = tween(500)) },
)
@Composable
fun rememberEhNavAnim(): RootNavGraphDefaultAnimations {
val slideDistance = rememberSlideDistance()
return remember {
RootNavGraphDefaultAnimations(
enterTransition = { materialSharedAxisXIn(true, slideDistance) },
exitTransition = { materialSharedAxisXOut(true, slideDistance) },
popEnterTransition = { materialSharedAxisXIn(false, slideDistance) },
popExitTransition = { materialSharedAxisXOut(false, slideDistance) },
)
}
}
@@ -55,8 +55,9 @@ import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.ExceptionUtils
@@ -95,10 +96,10 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
fun storeCookie(id: String, hash: String, igneous: String) {
EhUtils.signOut()
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX)
if (igneous.isNotBlank()) EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
if (igneous.isNotBlank() && igneous != "mystery") {
EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
}
}
fun login() {
@@ -123,7 +124,7 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
EhEngine.getProfile()
}.onSuccess {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}.onFailure {
EhCookieStore.signOut()
dialogState.awaitPermissionOrCancel(
@@ -160,7 +161,7 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
coroutineScope.launch { snackbarHostState.showSnackbar(noCookies) }
return
}
if (kvs.size < 3) {
if (kvs.size < 2) {
coroutineScope.launch { snackbarHostState.showSnackbar(noCookies) }
return
}
@@ -1,7 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -22,14 +20,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -37,11 +35,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@Composable
fun SelectSiteScreen(navigator: DestinationsNavigator) {
var siteEx by remember { mutableStateOf(true) }
val context = LocalContext.current
BackHandler {
(context as Activity).moveTaskToBack(true)
}
Column(
modifier = Modifier.systemBarsPadding().padding(horizontal = dimensionResource(R.dimen.keyline_margin)).padding(top = dimensionResource(R.dimen.keyline_margin)).fillMaxSize(),
@@ -71,7 +64,7 @@ fun SelectSiteScreen(navigator: DestinationsNavigator) {
// Gallery site was set to ex in sad panda check
if (!siteEx) Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = false
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
modifier = Modifier.fillMaxWidth(),
) {
@@ -1,7 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -61,21 +59,24 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.CookieSignInSceneDestination
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.util.ExceptionUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
@RootNavGraph(start = true)
@Destination
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
@@ -92,10 +93,6 @@ fun SignInScreen(navigator: DestinationsNavigator) {
var signInJob by remember { mutableStateOf<Job?>(null) }
val dialogState = LocalDialogState.current
BackHandler {
(context as Activity).moveTaskToBack(true)
}
fun signIn() {
if (signInJob?.isActive == true) return
if (username.isEmpty()) {
@@ -137,7 +134,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}.onSuccess {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}
}
}
@@ -251,7 +248,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
) {
Text(
@@ -347,7 +344,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.navigate(FinishDestination)
navigator.popNavigate(StartDestination)
},
modifier = Modifier.padding(horizontal = 4.dp),
) {
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -14,8 +13,9 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.SelectSiteScreenDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -50,7 +50,7 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
present = true
coroutineScope.launchIO {
val canEx = withNonCancellableContext { postLogin() }
withUIContext { navigator.navigate(if (canEx) SelectSiteScreenDestination else FinishDestination) }
withUIContext { navigator.popNavigate(if (canEx) SelectSiteScreenDestination else StartDestination) }
}
}
}
@@ -58,11 +58,6 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
}
SideEffect {
EhUtils.signOut()
CookieManager.getInstance().apply {
flush()
removeAllCookies(null)
removeSessionCookies(null)
}
}
WebView(
state = state,
@@ -19,104 +19,27 @@ import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.ui.tools.shouldCrop
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItemScope
@Composable
private fun AsyncThumb(
info: DownloadInfo,
modifier: Modifier = Modifier,
) {
val path = remember(info.gid) { info.downloadDir?.subFile(".thumb") }
var contentScale by remember(info.gid) { mutableStateOf(ContentScale.Fit) }
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
var localReq by remember(info.gid) {
path?.takeIf { it.isFile }?.uri?.let {
context.imageRequest {
data(it.toString())
memoryCacheKey(info.thumbKey)
}
}.let { mutableStateOf(it) }
}
AsyncImage(
model = localReq ?: requestOf(info),
contentDescription = null,
modifier = modifier,
onState = { state ->
if (state is AsyncImagePainter.State.Success) {
if (state.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
path?.let {
coroutineScope.launch {
runCatching {
if (!path.exists() && path.ensureFile()) {
val key = info.thumbKey!!
EhApplication.imageCache.read(key) {
UniFile.fromFile(data.toFile())!!.openFileDescriptor("r").use { src ->
path.openFileDescriptor("w").use { dst ->
src sendTo dst
}
}
}
}
}.onFailure {
it.printStackTrace()
}
}
}
}
if (state is AsyncImagePainter.State.Error) {
path?.let {
coroutineScope.launch {
if (path.exists()) {
path.delete()
localReq = null
}
}
}
}
},
contentScale = contentScale,
)
}
@Composable
fun ReorderableItemScope.DownloadCard(
onClick: () -> Unit,
@@ -124,6 +47,8 @@ fun ReorderableItemScope.DownloadCard(
onLongClick: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
info: DownloadInfo,
modifier: Modifier = Modifier,
) {
@@ -134,8 +59,8 @@ fun ReorderableItemScope.DownloadCard(
) {
Row {
Card(onClick = onThumbClick) {
AsyncThumb(
info = info,
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
)
}
@@ -240,7 +165,7 @@ fun ReorderableItemScope.DownloadCard(
bottom.linkTo(parent.bottom)
},
) {
DragHandle()
DragHandle(onDragStarted = onDragStarted, onDragStopped = onDragStopped)
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
IconButton(onClick = onStop) {
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
@@ -38,8 +38,8 @@ import androidx.compose.ui.util.lerp
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
enum class FabLayoutValue {
@@ -128,18 +128,9 @@ fun FabLayout(
}
val builder by rememberUpdatedState(fabBuilder)
fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
}
}
val secondaryFab by remember {
snapshotFlow { builder }.map { one ->
state.waitCollapse()
buildFab(one)
}
}.collectAsState(remember { buildFab(fabBuilder) })
snapshotFlow { buildFab(builder) }.onEachLatest { state.waitCollapse() }
}.collectAsState(emptyList())
val coroutineScope = rememberCoroutineScope()
if (updatedExpanded && autoCancel) {
Spacer(
@@ -219,4 +210,10 @@ fun FabLayout(
}
}
private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
}
}
const val FAB_ANIMATE_TIME = 300
@@ -151,7 +151,7 @@ fun SearchFilter(
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
@@ -163,7 +163,7 @@ fun SearchFilter(
)
Text(text = stringResource(id = R.string.search_sp_to))
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
@@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text2.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
@@ -44,6 +44,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@@ -72,6 +73,7 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadManager.labelList
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.Download
@@ -99,6 +101,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -110,8 +113,8 @@ import sh.calvin.reorderable.rememberReorderableLazyColumnState
fun DownloadsScreen(navigator: DestinationsNavigator) {
var label by Settings::recentDownloadLabel.observed
var keyword by rememberSaveable { mutableStateOf<String?>(null) }
var filterType by rememberSaveable { mutableStateOf(-1) }
var searchBarOffsetY by remember(label) { mutableStateOf(0) }
var filterType by rememberSaveable { mutableIntStateOf(-1) }
var searchBarOffsetY by remember(label) { mutableIntStateOf(0) }
val searchFieldState = rememberTextFieldState()
val checkedInfoMap = remember { mutableStateMapOf<Long, DownloadInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
@@ -135,7 +138,6 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
} ?: true
}
}
val labelsList = DownloadManager.labelList
val newLabel = stringResource(R.string.new_label_title)
val renameLabel = stringResource(R.string.rename_label_title)
@@ -179,7 +181,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = null
}
DownloadManager.labelList.forEach { (label) ->
labelList.forEach { (label) ->
onSelect(label) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = label
@@ -196,12 +198,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val labelsListState = rememberLazyListState()
val reorderableLabelState = rememberReorderableLazyColumnState(labelsListState) { from, to ->
coroutineScope.launch {
DownloadManager.moveLabel(from.index - 2, to.index - 2)
}
val fromPosition = from.index - 2
val toPosition = to.index - 2
labelList.apply { add(toPosition, removeAt(fromPosition)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
@@ -235,7 +237,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
},
)
}
items(labelsList, key = { it.label }) { (item) ->
itemsIndexed(labelList, key = { _, item -> item.label }) { index, (item) ->
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) {
@@ -277,7 +279,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
IconButton(
onClick = {
coroutineScope.launch {
val new = dialogState.awaitInputText(title = renameLabel, hint = labelsStr) { text ->
val new = dialogState.awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
@@ -292,7 +294,24 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
) {
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
}
DragHandle()
DragHandle(
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = labelList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
coroutineScope.launchIO {
EhDB.updateDownloadLabel(toUpdate)
}
}
fromIndex = -1
}
},
)
}
},
)
@@ -395,13 +414,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
val reorderableState = rememberReorderableLazyColumnState(listState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
val fromItem = list[fromIndex]
val toItem = list[toIndex]
val newList = DownloadManager.moveDownload(fromItem, toItem)
coroutineScope.launchIO {
EhDB.updateDownloadInfo(newList)
}
list.add(toIndex, list.removeAt(fromIndex))
list.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
}
val searchBarConnection = remember {
@@ -413,6 +426,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
}
}
}
var fromIndex by remember { mutableIntStateOf(-1) }
FastScrollLazyColumn(
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
state = listState,
@@ -421,7 +435,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
) {
// Fix the first item's reorder animation
item {}
items(list, key = { it.gid }) { info ->
itemsIndexed(list, key = { _, item -> item.gid }) { index, info ->
ReorderableItem(reorderableState, key = info.gid) {
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked) {
@@ -457,6 +471,22 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
DownloadManager.stopDownload(info.gid)
}
},
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
val direction = (index - fromIndex).sign
if (direction != 0) {
val toItem = list[index - direction]
coroutineScope.launchIO {
val toUpdate = DownloadManager.moveDownload(info, toItem)
EhDB.updateDownloadInfo(toUpdate)
}
}
fromIndex = -1
}
},
info = info,
modifier = Modifier.height(height),
)
@@ -31,11 +31,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.currentRecomposeScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -78,6 +78,7 @@ import com.hippo.ehviewer.ui.showDatePicker
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.util.mapToLongArray
import com.ramcosta.composedestinations.annotation.Destination
@@ -96,7 +97,7 @@ import moe.tarsin.coroutines.runSuspendCatching
fun FavouritesScreen(navigator: DestinationsNavigator) {
// Meta State
var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
var searchBarOffsetY by remember { mutableStateOf(0) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
// Derived State
val keyword = remember(urlBuilder) { urlBuilder.keyword.orEmpty() }
@@ -153,8 +154,8 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}.onFailure {
return@withIOContext LoadResult.Error(it)
}.getOrThrow()
Settings.favCat = r.catArray
Settings.favCount = r.countArray
Settings.favCat = r.catArray.toTypedArray()
Settings.favCount = r.countArray.toIntArray()
Settings.favCloudCount = r.countArray.sum()
urlBuilder.jumpTo = null
LoadResult.Page(r.galleryInfoList, r.prev, r.next)
@@ -221,7 +222,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
var expanded by remember { mutableStateOf(false) }
var hidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, BaseGalleryInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
val selectMode = checkedInfoMap.isNotEmpty()
LockDrawer(selectMode)
SearchBarScreen(
@@ -322,8 +323,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
}.mapLatest { it }
}.collectAsState(hidden)
// Explicitly reallocate fabBuilder lambda to recompose secondary fab
val select = selectMode
FabLayout(
hidden = hideFab,
expanded = expanded || selectMode,
@@ -333,7 +332,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
},
autoCancel = !selectMode,
) {
if (!select) {
if (!selectMode) {
onClick(EhIcons.Default.GoTo) {
coroutineScope.launch {
val date = dialogState.showDatePicker()
@@ -382,22 +381,22 @@ fun FavouritesScreen(navigator: DestinationsNavigator) {
val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
val info = checkedInfoMap.run { toMap().values.also { clear() } }
if (srcCat != dstCat) {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from local to cloud
EhDB.removeLocalFavorites(info)
val galleryList = info.map { it.gid to it.token!! }
runSuspendCatching {
runSuspendCatching {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from local to cloud
val galleryList = info.map { it.gid to it.token!! }
EhEngine.addFavorites(galleryList, dstCat)
}
} else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from cloud to local
EhDB.putLocalFavorites(info)
} else {
// Move from cloud to cloud
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
runSuspendCatching {
EhDB.removeLocalFavorites(info)
} else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from cloud to local
EhDB.putLocalFavorites(info)
} else {
// Move from cloud to cloud
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
}
}.onFailure {
activity.showTip(ExceptionUtils.getReadableString(it))
}
data.refresh()
}
@@ -72,12 +72,13 @@ import androidx.compose.ui.unit.max
import androidx.compose.ui.util.lerp
import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.GalleryDetail
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
@@ -148,7 +149,7 @@ private val MinimumContentPaddingEditText = 88.dp
@Destination
@Composable
fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsNavigator) {
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) {
LockDrawer(true)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val dialogState = LocalDialogState.current
@@ -157,6 +158,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
var commenting by rememberSaveable { mutableStateOf(false) }
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
var userComment by userCommentBackField
var commentId by remember { mutableLongStateOf(-1) }
@@ -173,9 +175,6 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, showAll)
val detail = EhEngine.getGalleryDetail(url)
comments = detail.comments
// Refresh comments of GalleryDetail cache
galleryDetail.comments = comments
}
val copyComment = stringResource(R.string.copy_comment_text)
@@ -193,13 +192,13 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
val focusManager = LocalFocusManager.current
suspend fun Context.sendComment() {
suspend fun sendComment() {
commenting = false
withUIContext { focusManager.clearFocus() }
val url = EhUrl.getGalleryDetailUrl(galleryDetail.gid, galleryDetail.token, 0, false)
runSuspendCatching {
val bbcode = userComment.annotatedString.normalizeSpan().toBBCode()
logcat { bbcode }
logcat("sendComment") { bbcode }
EhEngine.commentGallery(url, bbcode, commentId)
}.onSuccess {
activity.showTip(if (commentId != -1L) editCommentSuccess else commentSuccess)
@@ -247,6 +246,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
}
}
}
val hasSignedIn = remember { EhCookieStore.hasSignedIn() }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -261,7 +261,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
)
},
floatingActionButton = {
if (!commenting) {
if (hasSignedIn && !commenting) {
FloatingActionButton(
onClick = {
if (commentId != -1L) {
@@ -449,7 +449,7 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: DestinationsN
IconButton(
onClick = {
coroutineScope.launchIO {
context.sendComment()
sendComment()
}
},
modifier = Modifier.align(Alignment.CenterVertically).padding(16.dp),
@@ -49,7 +49,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ModalBottomSheetFix
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
@@ -217,7 +217,7 @@ private fun List<GalleryTagGroup>.getArtist(): String? {
@Composable
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) {
LockDrawer(true)
var galleryInfo by rememberSaveable {
var galleryInfo by remember {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
}
@@ -493,7 +493,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
else -> stringResource(R.string.more_comment)
}
fun onNavigateToCommentScene() {
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail))
navigator.navigate(GalleryCommentsScreenDestination(galleryDetail.gid))
}
CrystalCard {
commentsList.take(maxShowCount).forEach { item ->
@@ -955,7 +955,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet && galleryDetail != null) {
ModalBottomSheetFix(
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
windowInsets = WindowInsets(0, 0, 0, 0),
) {
@@ -1136,7 +1136,13 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
contentDescription = null,
)
}
IconButton(onClick = { AppHelper.share(activity, galleryDetailUrl) }) {
IconButton(
onClick = {
AppHelper.share(activity, galleryDetailUrl)
// In case the link is copied to the clipboard
Settings.clipboardTextHashCode = galleryDetailUrl.hashCode()
},
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
@@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.rememberScrollState
@@ -58,6 +58,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -178,7 +179,7 @@ fun ToplistScreen(navigator: DestinationsNavigator) =
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val searchFieldState = rememberTextFieldState()
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
var searchBarOffsetY by remember { mutableStateOf(0) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) }
@@ -223,7 +224,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST }
val ehHint = stringResource(R.string.gallery_list_search_bar_hint_e_hentai)
val exHint = stringResource(R.string.gallery_list_search_bar_hint_exhentai)
val searchBarHint = remember { if (EhUtils.isExHentai) exHint else ehHint }
val searchBarHint by rememberUpdatedState(if (EhUtils.isExHentai) exHint else ehHint)
val suitableTitle = getSuitableTitleForUrlBuilder(urlBuilder)
val data = rememberInVM {
Pager(PagingConfig(25)) {
@@ -406,17 +407,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
val reorderableLazyListState = rememberReorderableLazyColumnState(quickSearchListState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
quickSearchList.apply {
add(toIndex, removeAt(fromIndex))
}
coroutineScope.launchIO {
val range = if (fromIndex < toIndex) fromIndex..toIndex else toIndex..fromIndex
val list = quickSearchList.slice(range)
list.zip(range).forEach { it.first.position = it.second }
EhDB.updateQuickSearch(list)
}
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = quickSearchListState,
@@ -426,7 +420,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
stickyHeader {
HorizontalDivider()
}
items(quickSearchList, key = { it.id!! }) { item ->
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
@@ -468,7 +462,24 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) {
Text(text = item.name)
},
trailingContent = {
DragHandle()
DragHandle(
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = quickSearchList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
coroutineScope.launchIO {
EhDB.updateQuickSearch(toUpdate)
}
}
fromIndex = -1
}
},
)
},
)
}
@@ -5,6 +5,7 @@ import androidx.navigation.NavController
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.GalleryListUrlParser
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
import com.hippo.ehviewer.ui.NavGraphs
import com.hippo.ehviewer.ui.destinations.GalleryDetailScreenDestination
import com.hippo.ehviewer.ui.destinations.GalleryListScreenDestination
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
@@ -29,6 +30,11 @@ fun DestinationsNavigator.navWithUrl(url: String): Boolean {
return true
}
@MainThread
fun DestinationsNavigator.popNavigate(direction: Direction) = navigate(direction) {
popUpTo(NavGraphs.root.route)
}
@MainThread
fun NavController.navWithUrl(url: String): Boolean {
val dest = urlToDestination(url) ?: return false
@@ -49,7 +49,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,12 +62,10 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.core.view.SoftwareKeyboardControllerCompat
import com.hippo.ehviewer.EhApplication.Companion.searchDatabase
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -237,17 +234,9 @@ fun SearchBarScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
if (isAtLeastP) {
// Workaround IME not hiding after performing search
// https://github.com/FooIbar/EhViewer/pull/412
val view = LocalView.current
val keyboardManager = remember { SoftwareKeyboardControllerCompat(view) }
SideEffect {
if (!active) keyboardManager.hide()
}
} else {
if (!isAtLeastP) {
// Workaround unable to exit search field
// https://android.googlesource.com/platform/frameworks/support/+/8f81d46e2c3f37564d0b7515ec9411314645e187
// https://issuetracker.google.com/issues/318968220
Box(Modifier.size(1.dp).focusable())
}
@@ -332,7 +321,7 @@ fun SearchBarScreen(
contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
items(mSuggestionList, key = { it.keyword }) {
items(mSuggestionList, key = { it.keyword.hashCode() * 31 + it.canDelete.hashCode() }) {
ListItem(
headlineContent = { Text(text = it.keyword) },
supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) },
@@ -153,14 +153,18 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.align(Alignment.Center)) {
SegmentedButton(
selected = !enableCronet,
onClick = { enableCronet = false },
onClick = {
enableCronet = false
},
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
Text("OkHttp")
}
SegmentedButton(
selected = enableCronet,
onClick = { enableCronet = true },
onClick = {
enableCronet = true
},
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
enabled = isCronetAvailable,
) {
@@ -44,6 +44,7 @@ import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.AppConfig
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
@@ -82,11 +83,9 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
coroutineScope.launch {
context.runCatching {
contentResolver.takePersistableUriPermission(treeUri, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION)
UniFile.fromTreeUri(context, treeUri)!!.run {
downloadLocationState = this
coroutineScope.launchNonCancellable {
keepNoMediaFileStatus()
}
downloadLocationState = treeUri.asUniFile()
coroutineScope.launchNonCancellable {
keepNoMediaFileStatus()
}
}.onFailure {
launchSnackBar(cannotGetDownloadLocation)
@@ -105,7 +104,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
.setMessage(file.uri.toString())
.setPositiveButton(R.string.pick_new_download_location) { _, _ -> selectDownloadDirLauncher.launch(null) }
.setNeutralButton(R.string.reset_download_location) { _, _ ->
val uniFile = UniFile.fromFile(AppConfig.defaultDownloadDir)
val uniFile = AppConfig.defaultDownloadDir?.asUniFile()
if (uniFile != null) {
downloadLocationState = uniFile
coroutineScope.launchNonCancellable { keepNoMediaFileStatus() }
@@ -160,7 +159,6 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_download_origin_image_summary),
value = Settings::downloadOriginImage,
)
val restoreFailed = stringResource(id = R.string.settings_download_restore_failed)
WorkPreference(
title = stringResource(id = R.string.settings_download_restore_download_items),
summary = stringResource(id = R.string.settings_download_restore_download_items_summary),
@@ -191,25 +189,21 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
}.getOrNull()
}
runCatching {
val result = downloadLocation.listFiles()?.mapNotNull { getRestoreItem(it) }?.apply {
val result = downloadLocation.listFiles().mapNotNull { getRestoreItem(it) }.apply {
runSuspendCatching {
fillGalleryListByApi(this, EhUrl.referer)
}.onFailure {
it.printStackTrace()
}
}
if (result == null) {
launchSnackBar(restoreFailed)
if (result.isEmpty()) {
launchSnackBar(RESTORE_COUNT_MSG(restoreDirCount))
} else {
if (result.isEmpty()) {
launchSnackBar(RESTORE_COUNT_MSG(restoreDirCount))
} else {
val count = result.filterNot { it.title.isNullOrBlank() }.map {
EhDB.putDownloadDirname(it.gid, it.dirname)
DownloadManager.restoreDownload(it.galleryInfo, it.dirname)
}.size
launchSnackBar(RESTORE_COUNT_MSG(count + restoreDirCount))
}
val count = result.filterNot { it.title.isNullOrBlank() }.map {
EhDB.putDownloadDirname(it.gid, it.dirname)
DownloadManager.restoreDownload(it.galleryInfo, it.dirname)
}.size
launchSnackBar(RESTORE_COUNT_MSG(count + restoreDirCount))
}
}.onFailure {
it.printStackTrace()
@@ -233,7 +227,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
return true
}
runSuspendCatching {
val cnt = downloadLocation.listFiles()?.sumOf { clearFile(it).compareTo(false) } ?: 0
val cnt = downloadLocation.listFiles().sumOf { clearFile(it).compareTo(false) }
launchSnackBar(FINAL_CLEAR_REDUNDANCY_MSG(cnt))
}.onFailure {
it.printStackTrace()
@@ -39,14 +39,12 @@ import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.dailycheck.schedHour
import com.hippo.ehviewer.dailycheck.schedMinute
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
import com.hippo.ehviewer.ui.destinations.FilterScreenDestination
import com.hippo.ehviewer.ui.destinations.MyTagsScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.destinations.UConfigScreenDestination
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
@@ -58,6 +56,7 @@ import io.ktor.http.Url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
import moe.tarsin.coroutines.runSuspendCatching
@Destination
@@ -118,7 +117,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
}
setPositiveButton(R.string.settings_eh_sign_out) { _, _ ->
EhUtils.signOut()
navigator.navigate(SignInScreenDestination)
navigator.popNavigate(SignInScreenDestination)
}
}.show()
}
@@ -324,10 +323,9 @@ fun EhScreen(navigator: DestinationsNavigator) {
val pickerTitle = stringResource(id = R.string.settings_eh_request_news_timepicker)
Preference(title = pickerTitle) {
coroutineScope.launch {
val (hour, minute) = dialogState.showTimePicker(pickerTitle, schedHour, schedMinute)
Settings.requestNewsTimerHour = hour
Settings.requestNewsTimerMinute = minute
updateDailyCheckWork(context)
val time = LocalTime.fromSecondOfDay(Settings.requestNewsTime)
val (hour, minute) = dialogState.showTimePicker(pickerTitle, time.hour, time.minute)
Settings.requestNewsTime = LocalTime(hour, minute).toSecondOfDay()
}
}
}
@@ -1,9 +1,11 @@
package com.hippo.ehviewer.ui.settings
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -11,9 +13,13 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
import com.hippo.ehviewer.ui.openBrowser
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.m3.LibraryDefaults
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -21,6 +27,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@Composable
fun LicenseScreen(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
@@ -33,7 +40,15 @@ fun LicenseScreen(navigator: DestinationsNavigator) {
scrollBehavior = scrollBehavior,
)
},
) {
LibrariesContainer(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), contentPadding = it)
) { paddingValues ->
LibrariesContainer(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentPadding = paddingValues,
colors = LibraryDefaults.libraryColors(badgeBackgroundColor = MaterialTheme.colorScheme.tertiary),
padding = LibraryDefaults.libraryPadding(badgeContentPadding = PaddingValues(4.dp)),
onLibraryClick = { library ->
library.library.website?.let { context.openBrowser(it) }
},
)
}
}
@@ -25,23 +25,20 @@ import com.hippo.ehviewer.ui.destinations.AboutScreenDestination
import com.hippo.ehviewer.ui.destinations.AdvancedScreenDestination
import com.hippo.ehviewer.ui.destinations.DownloadScreenDestination
import com.hippo.ehviewer.ui.destinations.EhScreenDestination
import com.hippo.ehviewer.ui.destinations.FinishDestination
import com.hippo.ehviewer.ui.destinations.PrivacyScreenDestination
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@RootNavGraph(start = true)
@Destination
@Composable
fun BaseScreen(navigator: DestinationsNavigator) {
fun SettingsScreen(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.settings)) },
navigationIcon = {
IconButton(onClick = { navigator.navigate(FinishDestination) }) {
IconButton(onClick = { navigator.navigateUp() }) {
Icon(imageVector = Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
},
@@ -14,7 +14,11 @@ import com.hippo.ehviewer.util.isAtLeastU
import sh.calvin.reorderable.ReorderableItemScope
@Composable
fun ReorderableItemScope.DragHandle(modifier: Modifier = Modifier) {
fun ReorderableItemScope.DragHandle(
modifier: Modifier = Modifier,
onDragStarted: () -> Unit = {},
onDragStopped: () -> Unit = {},
) {
val view = LocalView.current
IconButton(
onClick = {},
@@ -30,6 +34,7 @@ fun ReorderableItemScope.DragHandle(modifier: Modifier = Modifier) {
HapticFeedbackConstants.VIRTUAL_KEY
}
view.performHapticFeedback(feedbackConstant)
onDragStarted()
},
onDragStopped = {
val feedbackConstant = if (isAtLeastR) {
@@ -40,6 +45,7 @@ fun ReorderableItemScope.DragHandle(modifier: Modifier = Modifier) {
HapticFeedbackConstants.VIRTUAL_KEY_RELEASE
}
view.performHapticFeedback(feedbackConstant)
onDragStopped()
},
),
) {
@@ -1,46 +0,0 @@
package com.hippo.ehviewer.ui.webview
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.util.setDefaultSettings
@Composable
fun WebViewScreen(url: String, onNavigateUp: () -> Unit) {
val state = rememberWebViewState(url = url)
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = state.pageTitle ?: url) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
}
},
)
},
) { paddingValues ->
WebView(
state = state,
modifier = Modifier.padding(paddingValues).fillMaxSize(),
onCreated = { it.setDefaultSettings() },
)
DisposableEffect(Unit) {
onDispose {
EhCookieStore.flush()
}
}
}
}
@@ -14,5 +14,5 @@ inline fun <T> Collection<T>.mapToLongArray(transform: (T) -> Long): LongArray {
}
inline fun <R : Any> LongArray.mapNotNull(transform: (Long) -> R?): List<R> {
return mapNotNullTo(ArrayList<R>(), transform)
return mapNotNullTo(ArrayList(), transform)
}
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.util
import android.os.ParcelFileDescriptor
import android.system.Int64Ref
import android.system.Os
import com.hippo.unifile.UniFile
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -19,10 +20,10 @@ private fun sendFileTotally(from: FileDescriptor, to: FileDescriptor) {
}
}
infix fun ParcelFileDescriptor.sendTo(fd: FileDescriptor) {
sendFileTotally(fileDescriptor, fd)
}
infix fun ParcelFileDescriptor.sendTo(fd: ParcelFileDescriptor) {
sendFileTotally(fileDescriptor, fd.fileDescriptor)
}
infix fun UniFile.sendTo(file: UniFile) = openFileDescriptor("r").use { src ->
file.openFileDescriptor("w").use { dst -> src sendTo dst }
}
@@ -42,7 +42,7 @@ private val Context.lifecycle: Lifecycle
}
}
private suspend fun <I, O> Context.awaitActivityResult(contract: ActivityResultContract<I, O>, input: I): O {
suspend fun <I, O> Context.awaitActivityResult(contract: ActivityResultContract<I, O>, input: I): O {
val key = "activity_rq#${atomicInteger.getAndIncrement()}"
var launcher: ActivityResultLauncher<I>? = null
var observer: LifecycleEventObserver? = null
@@ -6,11 +6,36 @@ import kotlin.reflect.KMutableProperty0
import kotlin.reflect.KProperty
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)
fun <T> resettableLazy(initializer: () -> T) = ResettableLazy(initializer)
fun <T> delegateLazy(initializer: () -> T) = object : kotlin.Lazy<T> {
override val value: T
get() = initializer()
override fun isInitialized() = true
}
interface Lazy<T> {
var value: T
}
class ResettableLazy<T>(private val initializer: () -> T) {
private var value: T? = null
operator fun getValue(thisRef: Any?, prop: KProperty<*>): T {
return value ?: initializer().apply { value = this }
}
operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) {
check(this.value == value) { "New values aren't accepted to reset this delegated property" }
invalidate()
}
private fun invalidate() {
value = null
}
}
fun <T> lazyMut(init: () -> KMutableProperty0<T>) = object : Lazy<T> {
private val lazy by lazy { init() }
override var value
@@ -15,67 +15,29 @@
*/
package com.hippo.unifile
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import java.io.IOException
import splitties.init.appCtx
internal object Contracts {
fun queryForString(
context: Context,
self: Uri,
column: String,
defaultValue: String?,
): String? {
return runCatching {
context.contentResolver.query(self, arrayOf(column), null, null, null).use {
if (it != null && it.moveToFirst() && !it.isNull(0)) {
it.getString(0)
} else {
defaultValue
}
object Contracts {
fun queryForString(self: Uri, column: String, defaultValue: String?) = runCatching {
appCtx.contentResolver.query(self, arrayOf(column), null, null, null).use {
if (it != null && it.moveToFirst() && !it.isNull(0)) {
it.getString(0)
} else {
defaultValue
}
}.getOrElse {
Utils.throwIfFatal(it)
defaultValue
}
}
}.getOrDefault(defaultValue)
fun queryForInt(context: Context, self: Uri, column: String?, defaultValue: Int): Int {
return queryForLong(context, self, column, defaultValue.toLong()).toInt()
}
fun queryForInt(self: Uri, column: String?, defaultValue: Int) = queryForLong(self, column, defaultValue.toLong()).toInt()
fun queryForLong(context: Context, self: Uri, column: String?, defaultValue: Long): Long {
return runCatching {
context.contentResolver.query(self, arrayOf(column), null, null, null).use {
if (it != null && it.moveToFirst() && !it.isNull(0)) {
it.getLong(0)
} else {
defaultValue
}
fun queryForLong(self: Uri, column: String?, defaultValue: Long) = runCatching {
appCtx.contentResolver.query(self, arrayOf(column), null, null, null).use {
if (it != null && it.moveToFirst() && !it.isNull(0)) {
it.getLong(0)
} else {
defaultValue
}
}.getOrElse {
Utils.throwIfFatal(it)
defaultValue
}
}
fun openFileDescriptor(
context: Context,
uri: Uri?,
mode: String?,
): ParcelFileDescriptor {
return context.contentResolver.openFileDescriptor(uri!!, mode!!)
?: throw IOException("Can't open ParcelFileDescriptor")
}
fun getImageSource(context: Context, uri: Uri?): ImageDecoder.Source {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.createSource(context.contentResolver, uri!!)
} else {
TODO("VERSION.SDK_INT < P")
}
}
}.getOrDefault(defaultValue)
}
@@ -15,124 +15,70 @@
*/
package com.hippo.unifile
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.DocumentsContract
import splitties.init.appCtx
internal object DocumentsContractApi19 {
fun isDocumentUri(context: Context, self: Uri): Boolean {
return DocumentsContract.isDocumentUri(context, self)
}
object DocumentsContractApi19 {
fun getName(context: Context, self: Uri): String? {
return Contracts.queryForString(
context,
self,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
null,
)
}
fun getName(self: Uri) = Contracts.queryForString(self, DocumentsContract.Document.COLUMN_DISPLAY_NAME, null)
private fun getRawType(context: Context, self: Uri): String? {
return Contracts.queryForString(
context,
self,
DocumentsContract.Document.COLUMN_MIME_TYPE,
null,
)
}
private fun getRawType(self: Uri) = Contracts.queryForString(self, DocumentsContract.Document.COLUMN_MIME_TYPE, null)
fun getType(context: Context, self: Uri): String? {
return getRawType(context, self).takeUnless { it == DocumentsContract.Document.MIME_TYPE_DIR }
}
fun getType(self: Uri) = getRawType(self).takeUnless { it == DocumentsContract.Document.MIME_TYPE_DIR }
fun isDirectory(context: Context, self: Uri): Boolean {
return DocumentsContract.Document.MIME_TYPE_DIR == getRawType(context, self)
}
fun isDirectory(self: Uri) = DocumentsContract.Document.MIME_TYPE_DIR == getRawType(self)
fun isFile(context: Context, self: Uri): Boolean {
val type = getRawType(context, self)
fun isFile(self: Uri): Boolean {
val type = getRawType(self)
return !(DocumentsContract.Document.MIME_TYPE_DIR == type || type.isNullOrEmpty())
}
fun lastModified(context: Context, self: Uri): Long {
return Contracts.queryForLong(
context,
self,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
-1L,
)
}
fun lastModified(self: Uri) = Contracts.queryForLong(self, DocumentsContract.Document.COLUMN_LAST_MODIFIED, -1L)
fun length(context: Context, self: Uri): Long {
return Contracts.queryForLong(context, self, DocumentsContract.Document.COLUMN_SIZE, -1L)
}
fun length(self: Uri) = Contracts.queryForLong(self, DocumentsContract.Document.COLUMN_SIZE, -1L)
fun canRead(context: Context, self: Uri): Boolean {
fun canRead(self: Uri): Boolean {
val granted = appCtx.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_READ_URI_PERMISSION) == PackageManager.PERMISSION_GRANTED
// Ignore if grant doesn't allow read
return if (
context.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_READ_URI_PERMISSION)
!= PackageManager.PERMISSION_GRANTED
) {
false
} else {
// Ignore documents without MIME
!getRawType(context, self).isNullOrEmpty()
}
}
fun canWrite(context: Context, self: Uri): Boolean {
// Ignore if grant doesn't allow write
if (context.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
!= PackageManager.PERMISSION_GRANTED
) {
return false
}
val type = getRawType(context, self)
val flags = Contracts.queryForInt(context, self, DocumentsContract.Document.COLUMN_FLAGS, 0)
if (!granted) return false
// Ignore documents without MIME
if (type.isNullOrEmpty()) {
return false
}
return !getRawType(self).isNullOrEmpty()
}
fun canWrite(self: Uri): Boolean {
val granted = appCtx.checkCallingOrSelfUriPermission(self, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PackageManager.PERMISSION_GRANTED
// Ignore if grant doesn't allow write
if (!granted) return false
val type = getRawType(self)
val flags = Contracts.queryForInt(self, DocumentsContract.Document.COLUMN_FLAGS, 0)
// Ignore documents without MIME
if (type.isNullOrEmpty()) return false
// Deletable documents considered writable
if (flags and DocumentsContract.Document.FLAG_SUPPORTS_DELETE != 0) {
return true
}
if (flags and DocumentsContract.Document.FLAG_SUPPORTS_DELETE != 0) return true
val dirSupportCreate = DocumentsContract.Document.MIME_TYPE_DIR == type && flags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE != 0
// Directories that allow create considered writable
if (dirSupportCreate) return true
// Writable normal files considered writable
return if (DocumentsContract.Document.MIME_TYPE_DIR == type && flags and DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE != 0) {
// Directories that allow create considered writable
true
} else {
flags and DocumentsContract.Document.FLAG_SUPPORTS_WRITE != 0
}
return flags and DocumentsContract.Document.FLAG_SUPPORTS_WRITE != 0
}
fun delete(context: Context, self: Uri): Boolean {
return try {
DocumentsContract.deleteDocument(context.contentResolver, self)
} catch (e: Throwable) {
Utils.throwIfFatal(e)
false
}
}
fun delete(self: Uri) = runCatching { DocumentsContract.deleteDocument(appCtx.contentResolver, self) }.getOrDefault(false)
fun exists(context: Context, self: Uri): Boolean {
return runCatching {
context.contentResolver.query(
self,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID),
null,
null,
null,
).use { null != it && it.count > 0 }
}.getOrElse {
Utils.throwIfFatal(it)
false
}
}
fun exists(self: Uri) = runCatching {
appCtx.contentResolver.query(
self,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID),
null,
null,
null,
).use { null != it && it.count > 0 }
}.getOrDefault(false)
}
@@ -18,27 +18,17 @@ package com.hippo.unifile
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import splitties.init.appCtx
internal object DocumentsContractApi21 {
object DocumentsContractApi21 {
private const val PATH_DOCUMENT = "document"
private const val PATH_TREE = "tree"
fun createFile(context: Context, self: Uri, mimeType: String, displayName: String): Uri? {
return try {
DocumentsContract.createDocument(
context.contentResolver,
self,
mimeType,
displayName,
)
} catch (e: Throwable) {
Utils.throwIfFatal(e)
null
}
}
private val resolver = appCtx.contentResolver
fun createFile(self: Uri, mimeType: String, displayName: String) = runCatching {
DocumentsContract.createDocument(resolver, self, mimeType, displayName)
}.getOrNull()
fun createDirectory(context: Context, self: Uri, displayName: String): Uri? {
return createFile(context, self, DocumentsContract.Document.MIME_TYPE_DIR, displayName)
}
fun createDirectory(self: Uri, displayName: String) = createFile(self, DocumentsContract.Document.MIME_TYPE_DIR, displayName)
fun prepareTreeUri(treeUri: Uri?): Uri {
return DocumentsContract.buildDocumentUriUsingTree(
@@ -52,7 +42,7 @@ internal object DocumentsContractApi21 {
if (paths.size >= 4 && PATH_TREE == paths[0] && PATH_DOCUMENT == paths[2]) {
return paths[3]
}
throw IllegalArgumentException("Invalid URI: $documentUri")
error("Invalid URI: $documentUri")
}
fun buildChildUri(uri: Uri, displayName: String): Uri {
@@ -62,43 +52,22 @@ internal object DocumentsContractApi21 {
)
}
fun listFiles(context: Context, self: Uri): Array<Uri> {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
self,
DocumentsContract.getDocumentId(self),
)
val results = ArrayList<Uri>()
runCatching {
context.contentResolver.query(
childrenUri,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID),
null,
null,
null,
).use {
if (null != it) {
while (it.moveToNext()) {
val documentId = it.getString(0)
val documentUri = DocumentsContract.buildDocumentUriUsingTree(
self,
documentId,
)
results.add(documentUri)
}
}
fun listFiles(self: Uri) = sequence {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(self, DocumentsContract.getDocumentId(self))
val id = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
resolver.query(childrenUri, id, null, null, null, null)?.use {
while (it.moveToNext()) {
val documentId = it.getString(0)
val documentUri = DocumentsContract.buildDocumentUriUsingTree(
self,
documentId,
)
yield(documentUri)
}
}.onFailure {
Utils.throwIfFatal(it)
}
return results.toTypedArray<Uri>()
}
fun renameTo(context: Context, self: Uri, displayName: String): Uri? {
return try {
DocumentsContract.renameDocument(context.contentResolver, self, displayName)
} catch (e: Throwable) {
Utils.throwIfFatal(e)
null
}
}
fun renameTo(context: Context, self: Uri, displayName: String) = runCatching {
DocumentsContract.renameDocument(context.contentResolver, self, displayName)
}.getOrNull()
}
@@ -1,37 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.unifile
/**
* An interface for filtering [UniFile] objects based on their names
* or the directory they reside in.
*
* @see UniFile
*
* @see UniFile.listFiles
*/
interface FilenameFilter {
/**
* Indicates if a specific filename matches this filter.
*
* @param dir the directory in which the `filename` was found.
* @param filename the name of the file in `dir` to test.
* @return `true` if the filename matches the filter
* and can be included in the list, `false`
* otherwise.
*/
fun accept(dir: UniFile?, filename: String?): Boolean
}
@@ -15,24 +15,15 @@
*/
package com.hippo.unifile
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
internal object MediaContract {
fun getName(context: Context, self: Uri): String? {
return Contracts.queryForString(context, self, MediaStore.MediaColumns.DISPLAY_NAME, null)
}
object MediaContract {
fun getName(self: Uri) = Contracts.queryForString(self, MediaStore.MediaColumns.DISPLAY_NAME, null)
fun getType(context: Context, self: Uri): String? {
return Contracts.queryForString(context, self, MediaStore.MediaColumns.MIME_TYPE, null)
}
fun getType(self: Uri) = Contracts.queryForString(self, MediaStore.MediaColumns.MIME_TYPE, null)
fun lastModified(context: Context, self: Uri): Long {
return Contracts.queryForLong(context, self, MediaStore.MediaColumns.DATE_MODIFIED, 0)
}
fun lastModified(self: Uri) = Contracts.queryForLong(self, MediaStore.MediaColumns.DATE_MODIFIED, 0)
fun length(context: Context, self: Uri): Long {
return Contracts.queryForLong(context, self, MediaStore.MediaColumns.SIZE, 0)
}
fun length(self: Uri) = Contracts.queryForLong(self, MediaStore.MediaColumns.SIZE, 0)
}
@@ -15,47 +15,27 @@
*/
package com.hippo.unifile
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.IOException
internal class MediaFile(context: Context, override val uri: Uri) : UniFile(null) {
private val mContext: Context
class MediaFile(override val uri: Uri) : UniFile(null) {
override fun createFile(displayName: String) = null
init {
mContext = context.applicationContext
}
override fun createFile(displayName: String): UniFile? {
return null
}
override fun createDirectory(displayName: String): UniFile? {
return null
}
override fun createDirectory(displayName: String) = null
override val name: String?
get() = MediaContract.getName(mContext, uri)
get() = MediaContract.getName(uri)
override val type: String?
get() = MediaContract.getType(mContext, uri)
override val isDirectory: Boolean
get() = false
get() = MediaContract.getType(uri)
override val isDirectory = false
override val isFile: Boolean
get() = DocumentsContractApi19.isFile(mContext, uri)
get() = DocumentsContractApi19.isFile(uri)
override fun lastModified(): Long {
return MediaContract.lastModified(mContext, uri)
}
override fun lastModified() = MediaContract.lastModified(uri)
override fun length(): Long {
return MediaContract.length(mContext, uri)
}
override fun length() = MediaContract.length(uri)
override fun canRead(): Boolean {
return isFile
}
override fun canRead() = isFile
override fun canWrite(): Boolean {
try {
@@ -67,52 +47,19 @@ internal class MediaFile(context: Context, override val uri: Uri) : UniFile(null
return true
}
override fun ensureDir(): Boolean {
return false
}
override fun ensureDir() = false
override fun ensureFile(): Boolean {
return isFile
}
override fun ensureFile() = isFile
override fun subFile(displayName: String): UniFile? {
return null
}
override fun resolve(displayName: String) = error("MediaFile never have a children")
override fun delete(): Boolean {
return false
}
override fun delete() = false
override fun exists(): Boolean {
return isFile
}
override fun exists() = isFile
override fun listFiles(): Array<UniFile>? {
return null
}
override fun listFiles() = emptyList<MediaFile>()
override fun listFiles(filter: FilenameFilter?): Array<UniFile>? {
return null
}
override fun findFirst(filter: (String) -> Boolean) = null
override fun findFile(displayName: String): UniFile? {
return null
}
override fun renameTo(displayName: String): Boolean {
return false
}
override val imageSource: ImageDecoder.Source
get() = Contracts.getImageSource(mContext, uri)
override fun openFileDescriptor(mode: String): ParcelFileDescriptor {
return Contracts.openFileDescriptor(mContext, uri, mode)
}
companion object {
fun isMediaUri(context: Context, uri: Uri): Boolean {
return null != MediaContract.getName(context, uri)
}
}
override fun renameTo(displayName: String) = false
}
+80 -134
View File
@@ -15,181 +15,127 @@
*/
package com.hippo.unifile
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import android.os.ParcelFileDescriptor.open
import android.os.ParcelFileDescriptor.parseMode
import android.webkit.MimeTypeMap
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Locale
internal class RawFile(parent: UniFile?, var mFile: File) : UniFile(parent) {
override fun createFile(displayName: String): UniFile? {
val target = File(mFile, displayName)
return if (target.exists()) {
if (target.isFile) {
RawFile(this, target)
} else {
null
}
} else {
runCatching {
FileOutputStream(target).use {}
RawFile(this, target)
}.getOrElse {
Log.w(TAG, "Failed to createFile $displayName: $it")
null
class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
private val parent = parent as? RawFile
private var cachePresent = false
private val allChildren by lazy {
val current = this
cachePresent = true
logcat { "Directory lookup cache created for $name" }
mutableListOf<RawFile>().apply {
val fs = file.listFiles()?.map { RawFile(current, it) }
if (fs != null) addAll(fs)
}
}
private fun popCacheIfPresent(file: RawFile) = apply {
if (cachePresent) {
synchronized(allChildren) {
allChildren.add(file)
}
}
}
override fun createFile(displayName: String): UniFile? {
val target = RawFile(this, File(file, displayName))
if (target.exists()) {
if (target.isFile) return target
} else {
if (target.ensureFile()) return target
}
return null
}
override fun createDirectory(displayName: String): UniFile? {
val target = File(mFile, displayName)
return if (target.isDirectory || target.mkdirs()) {
RawFile(this, target)
} else {
null
}
val target = RawFile(this, File(file, displayName))
if (target.ensureDir()) return target
return null
}
override val uri: Uri
get() = Uri.fromFile(mFile)
get() = Uri.fromFile(file)
override val name: String
get() = mFile.name
get() = file.name
override val type: String?
get() = if (mFile.isDirectory) {
get() = if (file.isDirectory) {
null
} else {
getTypeForName(mFile.name)
getTypeForName(file.name)
}
override val isDirectory: Boolean
get() = mFile.isDirectory
get() = file.isDirectory
override val isFile: Boolean
get() = mFile.isFile
get() = file.isFile
override fun lastModified(): Long {
return mFile.lastModified()
}
override fun lastModified() = file.lastModified()
override fun length(): Long {
return mFile.length()
}
override fun length() = file.length()
override fun canRead(): Boolean {
return mFile.canRead()
}
override fun canRead() = file.canRead()
override fun canWrite(): Boolean {
return mFile.canWrite()
}
override fun canWrite() = file.canWrite()
override fun ensureDir(): Boolean {
return mFile.isDirectory || mFile.mkdirs()
if (file.isDirectory) return true
if (file.mkdirs()) {
parent?.popCacheIfPresent(this)
return true
}
return false
}
override fun ensureFile(): Boolean {
return if (mFile.exists()) {
mFile.isFile
} else {
runCatching {
FileOutputStream(mFile).use {}
true
}.getOrDefault(false)
}
if (file.exists()) return file.isFile
parent?.ensureDir()
val success = file.createNewFile()
if (success) parent?.popCacheIfPresent(this)
return success
}
override fun subFile(displayName: String): UniFile {
return RawFile(this, File(mFile, displayName))
override fun resolve(displayName: String) = RawFile(this, File(file, displayName))
override fun delete() = file.deleteRecursively()
override fun exists() = file.exists()
override fun listFiles() = synchronized(allChildren) {
allChildren.toList()
}
override fun delete(): Boolean {
deleteContents(mFile)
return mFile.delete()
}
override fun exists(): Boolean {
return mFile.exists()
}
override fun listFiles(): Array<UniFile>? {
val files = mFile.listFiles() ?: return null
return files.map { RawFile(this, it) }.toTypedArray()
}
override fun listFiles(filter: FilenameFilter?): Array<UniFile>? {
if (filter == null) {
return listFiles()
}
val files = mFile.listFiles() ?: return null
val results = ArrayList<UniFile>()
for (file in files) {
if (filter.accept(this, file.name)) {
results.add(RawFile(this, file))
}
}
return results.toTypedArray<UniFile>()
}
override fun findFile(displayName: String): UniFile? {
val child = File(mFile, displayName)
return if (child.exists()) RawFile(this, child) else null
override fun findFirst(filter: (String) -> Boolean) = synchronized(allChildren) {
allChildren.firstOrNull { filter(it.name) }
}
override fun renameTo(displayName: String): Boolean {
val target = File(mFile.parentFile, displayName)
return if (mFile.renameTo(target)) {
mFile = target
true
} else {
false
val target = File(file.parentFile, displayName)
if (file.renameTo(target)) {
file = target
return true
}
return false
}
override val imageSource: ImageDecoder.Source
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.createSource(mFile)
} else {
TODO("VERSION.SDK_INT < P")
}
override fun openFileDescriptor(mode: String): ParcelFileDescriptor {
val md = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(mFile, md)
?: throw IOException("Can't open ParcelFileDescriptor")
}
companion object {
private val TAG = RawFile::class.java.simpleName
private fun getTypeForName(name: String): String {
val lastDot = name.lastIndexOf('.')
if (lastDot >= 0) {
val extension = name.substring(lastDot + 1).lowercase(Locale.getDefault())
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
if (mime != null) {
return mime
}
}
return "application/octet-stream"
}
override fun openFileDescriptor(mode: String): ParcelFileDescriptor = open(file, parseMode(mode))
}
private fun deleteContents(dir: File): Boolean {
val files = dir.listFiles()
var success = true
if (files != null) {
for (file in files) {
if (file.isDirectory) {
success = success and deleteContents(file)
}
if (!file.delete()) {
Log.w(TAG, "Failed to delete $file")
success = false
}
}
}
return success
private fun getTypeForName(name: String): String {
val lastDot = name.lastIndexOf('.')
if (lastDot >= 0) {
val extension = name.substring(lastDot + 1).lowercase(Locale.getDefault())
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
if (mime != null) {
return mime
}
}
return "application/octet-stream"
}
@@ -15,92 +15,44 @@
*/
package com.hippo.unifile
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.ParcelFileDescriptor
internal class SingleDocumentFile(parent: UniFile?, context: Context, override val uri: Uri) :
UniFile(parent) {
private val mContext: Context
class SingleDocumentFile(parent: UniFile?, override val uri: Uri) : UniFile(parent) {
init {
mContext = context.applicationContext
}
override fun createFile(displayName: String) = null
override fun createFile(displayName: String): UniFile? {
return null
}
override fun createDirectory(displayName: String): UniFile? {
return null
}
override fun createDirectory(displayName: String) = null
override val name: String?
get() = DocumentsContractApi19.getName(mContext, uri)
get() = DocumentsContractApi19.getName(uri)
override val type: String?
get() = DocumentsContractApi19.getType(mContext, uri)
get() = DocumentsContractApi19.getType(uri)
override val isDirectory: Boolean
get() = DocumentsContractApi19.isDirectory(mContext, uri)
get() = DocumentsContractApi19.isDirectory(uri)
override val isFile: Boolean
get() = DocumentsContractApi19.isFile(mContext, uri)
get() = DocumentsContractApi19.isFile(uri)
override fun lastModified(): Long {
return DocumentsContractApi19.lastModified(mContext, uri)
}
override fun lastModified() = DocumentsContractApi19.lastModified(uri)
override fun length(): Long {
return DocumentsContractApi19.length(mContext, uri)
}
override fun length() = DocumentsContractApi19.length(uri)
override fun canRead(): Boolean {
return DocumentsContractApi19.canRead(mContext, uri)
}
override fun canRead() = DocumentsContractApi19.canRead(uri)
override fun canWrite(): Boolean {
return DocumentsContractApi19.canWrite(mContext, uri)
}
override fun canWrite() = DocumentsContractApi19.canWrite(uri)
override fun ensureDir(): Boolean {
return isDirectory
}
override fun ensureDir() = isDirectory
override fun ensureFile(): Boolean {
return isFile
}
override fun ensureFile() = isFile
override fun subFile(displayName: String): UniFile? {
return null
}
override fun resolve(displayName: String) = error("SingleDocumentFile never have a children")
override fun delete(): Boolean {
return DocumentsContractApi19.delete(mContext, uri)
}
override fun delete() = DocumentsContractApi19.delete(uri)
override fun exists(): Boolean {
return DocumentsContractApi19.exists(mContext, uri)
}
override fun exists() = DocumentsContractApi19.exists(uri)
override fun listFiles(): Array<UniFile>? {
return null
}
override fun listFiles() = emptyList<SingleDocumentFile>()
override fun listFiles(filter: FilenameFilter?): Array<UniFile>? {
return null
}
override fun findFirst(filter: (String) -> Boolean) = null
override fun findFile(displayName: String): UniFile? {
return null
}
override fun renameTo(displayName: String): Boolean {
return false
}
override val imageSource: ImageDecoder.Source
get() = Contracts.getImageSource(mContext, uri)
override fun openFileDescriptor(mode: String): ParcelFileDescriptor {
return Contracts.openFileDescriptor(mContext, uri, mode)
}
override fun renameTo(displayName: String) = false
}
@@ -15,44 +15,40 @@
*/
package com.hippo.unifile
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import android.webkit.MimeTypeMap
import eu.kanade.tachiyomi.util.system.logcat
import splitties.init.appCtx
internal class TreeDocumentFile : UniFile {
private val mContext: Context
override var uri: Uri
private set
private var mFilename: String? = null
constructor(parent: UniFile?, context: Context, uri: Uri) : super(parent) {
mContext = context.applicationContext
this.uri = uri
class TreeDocumentFile(
parent: UniFile?,
override var uri: Uri,
val filename: String,
) : UniFile(parent) {
private var cachePresent = false
private val allChildren by lazy {
cachePresent = true
logcat { "Directory lookup cache created for $name" }
DocumentsContractApi21.listFiles(uri).mapTo(mutableListOf()) {
val name = getFilenameForUri(it)
TreeDocumentFile(this, it, name)
}
}
private constructor(parent: UniFile, context: Context, uri: Uri, filename: String?) : super(
parent,
) {
mContext = context.applicationContext
this.uri = uri
mFilename = filename
private fun popCacheIfPresent(file: TreeDocumentFile) = apply {
if (cachePresent) {
synchronized(allChildren) {
allChildren.add(file)
}
}
}
constructor(parent: UniFile?, uri: Uri) : this(parent, uri, getFilenameForUri(uri))
override fun createFile(displayName: String): UniFile? {
val child = findFile(displayName)
return if (child != null) {
if (child.isFile) {
child
} else {
Log.w(
TAG,
"Try to create file $displayName, but it is not file",
)
null
}
if (child != null) {
if (child.isFile) return child
} else {
val index = displayName.lastIndexOf('.')
if (index > 0) {
@@ -60,192 +56,113 @@ internal class TreeDocumentFile : UniFile {
val extension = displayName.substring(index + 1)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
if (!mimeType.isNullOrEmpty()) {
val result = DocumentsContractApi21.createFile(mContext, uri, mimeType, name)
return if (result != null) {
TreeDocumentFile(
this,
mContext,
result,
displayName,
)
} else {
null
val result = DocumentsContractApi21.createFile(uri, mimeType, name)
if (result != null) {
val f = TreeDocumentFile(this, result, displayName)
popCacheIfPresent(f)
return f
}
}
}
// Not dot in displayName or dot is the first char or can't get MimeType
val result = DocumentsContractApi21.createFile(
mContext,
uri,
"application/octet-stream",
displayName,
)
if (result != null) {
TreeDocumentFile(
this,
mContext,
result,
displayName,
)
} else {
null
// Not dot in displayName or dot is the first char or can't get MimeType
val result = DocumentsContractApi21.createFile(uri, "application/octet-stream", displayName)
if (result != null) {
val f = TreeDocumentFile(this, result, displayName)
popCacheIfPresent(f)
return f
}
}
}
return null
}
override fun createDirectory(displayName: String): UniFile? {
val child = findFile(displayName)
return if (child != null) {
if (child.isDirectory) {
child
} else {
null
}
if (child != null) {
if (child.isDirectory) return null
} else {
val result = DocumentsContractApi21.createDirectory(mContext, uri, displayName)
val result = DocumentsContractApi21.createDirectory(uri, displayName)
if (result != null) {
TreeDocumentFile(
this,
mContext,
result,
displayName,
)
} else {
null
val d = TreeDocumentFile(this, result, displayName)
popCacheIfPresent(d)
return d
}
}
return null
}
override val name: String?
get() = DocumentsContractApi19.getName(mContext, uri)
get() = DocumentsContractApi19.getName(uri)
override val type: String?
get() = DocumentsContractApi19.getType(mContext, uri)
get() = DocumentsContractApi19.getType(uri)
override val isDirectory: Boolean
get() = DocumentsContractApi19.isDirectory(mContext, uri)
get() = DocumentsContractApi19.isDirectory(uri)
override val isFile: Boolean
get() = DocumentsContractApi19.isFile(mContext, uri)
get() = DocumentsContractApi19.isFile(uri)
override fun lastModified(): Long {
return DocumentsContractApi19.lastModified(mContext, uri)
}
override fun lastModified() = DocumentsContractApi19.lastModified(uri)
override fun length(): Long {
return DocumentsContractApi19.length(mContext, uri)
}
override fun length() = DocumentsContractApi19.length(uri)
override fun canRead(): Boolean {
return DocumentsContractApi19.canRead(mContext, uri)
}
override fun canRead() = DocumentsContractApi19.canRead(uri)
override fun canWrite(): Boolean {
return DocumentsContractApi19.canWrite(mContext, uri)
}
override fun canWrite() = DocumentsContractApi19.canWrite(uri)
override fun ensureDir(): Boolean {
if (isDirectory) {
return true
} else if (isFile) {
return false
}
if (isDirectory) return true
if (isFile) return false
val parent = parentFile
return if (parent != null && parent.ensureDir() && mFilename != null) {
parent.createDirectory(mFilename!!) != null
return if (parent != null && parent.ensureDir()) {
parent.createDirectory(filename) != null
} else {
false
}
}
override fun ensureFile(): Boolean {
if (isFile) {
return true
} else if (isDirectory) {
return false
}
if (isFile) return true
if (isDirectory) return false
val parent = parentFile
return if (parent != null && parent.ensureDir() && mFilename != null) {
parent.createFile(mFilename!!) != null
return if (parent != null && parent.ensureDir()) {
parent.createFile(filename) != null
} else {
false
}
}
override fun subFile(displayName: String): UniFile {
override fun resolve(displayName: String): UniFile {
val childUri = DocumentsContractApi21.buildChildUri(uri, displayName)
return TreeDocumentFile(this, mContext, childUri, displayName)
return TreeDocumentFile(this, childUri, displayName)
}
override fun delete(): Boolean {
return DocumentsContractApi19.delete(mContext, uri)
override fun delete() = DocumentsContractApi19.delete(uri)
override fun exists() = DocumentsContractApi19.exists(uri)
override fun listFiles() = synchronized(allChildren) {
allChildren.toList()
}
override fun exists(): Boolean {
return DocumentsContractApi19.exists(mContext, uri)
}
private fun getFilenameForUri(uri: Uri): String? {
val path = uri.path
if (path != null) {
val index = path.lastIndexOf('/')
if (index >= 0) {
return path.substring(index + 1)
}
}
return null
}
override fun listFiles(): Array<UniFile> {
val result = DocumentsContractApi21.listFiles(mContext, uri)
return result.map { TreeDocumentFile(this, mContext, it, getFilenameForUri(it)) }.toTypedArray()
}
override fun listFiles(filter: FilenameFilter?): Array<UniFile> {
if (filter == null) {
return listFiles()
}
val result = DocumentsContractApi21.listFiles(mContext, uri)
val results = ArrayList<UniFile>()
for (uri in result) {
val name = getFilenameForUri(uri)
if (filter.accept(this, name)) {
results.add(TreeDocumentFile(this, mContext, uri, name))
}
}
return results.toTypedArray<UniFile>()
}
override fun findFile(displayName: String): UniFile? {
val childUri = DocumentsContractApi21.buildChildUri(uri, displayName)
return if (DocumentsContractApi19.exists(mContext, childUri)) {
TreeDocumentFile(
this,
mContext,
childUri,
displayName,
)
} else {
null
}
override fun findFirst(filter: (String) -> Boolean) = synchronized(allChildren) {
allChildren.firstOrNull { filter(it.filename) }
}
override fun renameTo(displayName: String): Boolean {
val result = DocumentsContractApi21.renameTo(mContext, uri, displayName)
return if (result != null) {
val result = DocumentsContractApi21.renameTo(appCtx, uri, displayName)
if (result != null) {
uri = result
true
} else {
false
return true
}
return false
}
}
override val imageSource: ImageDecoder.Source
get() = Contracts.getImageSource(mContext, uri)
override fun openFileDescriptor(mode: String): ParcelFileDescriptor {
return Contracts.openFileDescriptor(mContext, uri, mode)
}
companion object {
private val TAG = TreeDocumentFile::class.java.simpleName
private fun getFilenameForUri(uri: Uri): String {
val path = requireNotNull(uri.path)
val index = path.lastIndexOf('/')
return if (index >= 0) {
path.substring(index + 1)
} else {
return path
}
}
+30 -167
View File
@@ -16,14 +16,13 @@
package com.hippo.unifile
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.annotation.RequiresApi
import android.provider.DocumentsContract
import androidx.core.net.toFile
import androidx.core.provider.DocumentsContractCompat
import java.io.File
import splitties.init.appCtx
/**
* In Android files can be accessed via [java.io.File] and [android.net.Uri].
@@ -173,9 +172,11 @@ abstract class UniFile internal constructor(private val parent: UniFile?) {
/**
* Get child file of this directory, the child might not exist.
*
* @return the child file, `null` if not supported
* @return the child file
*/
abstract fun subFile(displayName: String): UniFile?
abstract fun resolve(displayName: String): UniFile
operator fun div(name: String) = resolve(name)
/**
* Deletes this file.
@@ -196,31 +197,18 @@ abstract class UniFile internal constructor(private val parent: UniFile?) {
*/
abstract fun exists(): Boolean
/**
* Returns an array of files contained in the directory represented by this
* file.
*
* @return an array of files or `null`.
* @see android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
*/
abstract fun listFiles(): Array<UniFile>?
abstract fun listFiles(): List<UniFile>
/**
* Gets a list of the files in the directory represented by this file. This
* list is then filtered through a FilenameFilter and the names of files
* with matching names are returned as an array of strings.
*
* @param filter the filter to match names against, may be `null`.
* @return an array of files or `null`.
*/
abstract fun listFiles(filter: FilenameFilter?): Array<UniFile>?
abstract fun findFirst(filter: (String) -> Boolean): UniFile?
open fun openFileDescriptor(mode: String) = appCtx.contentResolver.openFileDescriptor(uri, mode) ?: error("Can't open ParcelFileDescriptor")
/**
* Test there is a file with the display name in the directory.
*
* @return the file if found it, or `null`.
*/
abstract fun findFile(displayName: String): UniFile?
fun findFile(displayName: String) = resolve(displayName).takeIf { it.exists() }
/**
* Renames this file to `displayName`.
@@ -244,154 +232,29 @@ abstract class UniFile internal constructor(private val parent: UniFile?) {
*/
abstract fun renameTo(displayName: String): Boolean
@get:RequiresApi(Build.VERSION_CODES.P)
abstract val imageSource: ImageDecoder.Source
abstract fun openFileDescriptor(mode: String): ParcelFileDescriptor
companion object {
private var sUriHandlerArray: MutableList<UriHandler>? = null
fun fromFile(file: File) = RawFile(null, file)
/**
* Add a UriHandler to get UniFile from uri
*/
fun addUriHandler(handler: UriHandler) {
if (sUriHandlerArray == null) {
sUriHandlerArray = ArrayList()
}
sUriHandlerArray!!.add(handler)
fun fromSingleUri(singleUri: Uri) = SingleDocumentFile(null, singleUri)
fun fromTreeUri(treeUri: Uri) = TreeDocumentFile(null, DocumentsContractApi21.prepareTreeUri(treeUri))
fun fromMediaUri(mediaUri: Uri) = MediaFile(mediaUri)
fun fromUri(uri: Uri) = when {
isFileUri(uri) -> fromFile(uri.toFile())
isTreeUri(uri) -> fromTreeUri(uri)
isDocumentUri(uri) -> fromSingleUri(uri)
isMediaUri(uri) -> fromMediaUri(uri)
else -> null
}
/**
* Remove the UriHandler added before
*/
fun removeUriHandler(handler: UriHandler) {
if (sUriHandlerArray != null) {
sUriHandlerArray!!.remove(handler)
}
}
fun isFileUri(uri: Uri) = ContentResolver.SCHEME_FILE == uri.scheme
/**
* Create a [UniFile] representing the given [File].
*
* @param file the file to wrap
* @return the [UniFile] representing the given [File].
*/
fun fromFile(file: File?): UniFile? {
return if (file != null) RawFile(null, file) else null
}
fun isDocumentUri(uri: Uri) = DocumentsContract.isDocumentUri(appCtx, uri)
/**
* Create a [UniFile] representing the single document at the
* given [Uri]. This is only useful on devices running
* [android.os.Build.VERSION_CODES.KITKAT] or later, and will return
* `null` when called on earlier platform versions.
*
* @param singleUri the [Intent.getData] from a successful
* [Intent.ACTION_OPEN_DOCUMENT] or
* [Intent.ACTION_CREATE_DOCUMENT] request.
* @return the [UniFile] representing the given [Uri].
*/
fun fromSingleUri(context: Context, singleUri: Uri): UniFile? {
val version = Build.VERSION.SDK_INT
return if (version >= 19) {
SingleDocumentFile(null, context, singleUri)
} else {
null
}
}
fun isTreeUri(uri: Uri) = DocumentsContractCompat.isTreeUri(uri)
/**
* Create a [UniFile] representing the document tree rooted at
* the given [Uri]. This is only useful on devices running
* [Build.VERSION_CODES.LOLLIPOP] or later, and will return
* `null` when called on earlier platform versions.
*
* @param treeUri the [Intent.getData] from a successful
* [Intent.ACTION_OPEN_DOCUMENT_TREE] request.
* @return the [UniFile] representing the given [Uri].
*/
fun fromTreeUri(context: Context, treeUri: Uri): UniFile? {
val version = Build.VERSION.SDK_INT
return if (version >= 21) {
TreeDocumentFile(
null,
context,
DocumentsContractApi21.prepareTreeUri(treeUri),
)
} else {
null
}
}
/**
* Create a [UniFile] representing the media file rooted at
* the given [Uri].
*
* @param mediaUri the media uri to wrap
* @return the [UniFile] representing the given [Uri].
*/
fun fromMediaUri(context: Context, mediaUri: Uri): UniFile {
return MediaFile(context, mediaUri)
}
/**
* Create a [UniFile] representing the given [Uri].
*/
fun fromUri(context: Context, uri: Uri): UniFile? {
// Custom handler
if (sUriHandlerArray != null) {
var i = 0
val size = sUriHandlerArray!!.size
while (i < size) {
val file = sUriHandlerArray!![i].fromUri(context, uri)
if (file != null) {
return file
}
i++
}
}
return if (isFileUri(uri)) {
fromFile(File(uri.path!!))
} else if (isDocumentUri(context, uri)) {
if (isTreeUri(uri)) {
fromTreeUri(context, uri)
} else {
fromSingleUri(context, uri)
}
} else if (MediaFile.isMediaUri(context, uri)) {
MediaFile(context, uri)
} else {
null
}
}
/**
* Test if given Uri is FileUri
*/
fun isFileUri(uri: Uri): Boolean {
return ContentResolver.SCHEME_FILE == uri.scheme
}
/**
* Test if given Uri is backed by a
* [android.provider.DocumentsProvider].
*/
fun isDocumentUri(context: Context, uri: Uri): Boolean {
val version = Build.VERSION.SDK_INT
return if (version >= 19) {
DocumentsContractApi19.isDocumentUri(context, uri)
} else {
false
}
}
/**
* Test if given Uri is TreeUri
*/
fun isTreeUri(uri: Uri): Boolean {
val paths = uri.pathSegments
return ContentResolver.SCHEME_CONTENT == uri.scheme && paths.size >= 2 && "tree" == paths[0]
}
fun isMediaUri(uri: Uri) = null != MediaContract.getName(uri)
}
}
@@ -1,9 +1,12 @@
package com.hippo.unifile
import android.net.Uri
import android.os.ParcelFileDescriptor.AutoCloseInputStream
import android.os.ParcelFileDescriptor.AutoCloseOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import okio.Path
/**
* Use Native IO/NIO directly if possible, unless you need process file content on JVM!
@@ -18,3 +21,11 @@ fun UniFile.openInputStream(): FileInputStream {
fun UniFile.openOutputStream(): FileOutputStream {
return AutoCloseOutputStream(openFileDescriptor("w"))
}
fun File.asUniFile() = UniFile.fromFile(this)
fun Path.asUniFile() = toFile().asUniFile()
fun Uri.asUniFileOrNull() = UniFile.fromUri(this)
fun Uri.asUniFile() = requireNotNull(asUniFileOrNull())
@@ -1,32 +0,0 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.unifile
import android.content.Context
import android.net.Uri
/*
* Created by Hippo on 8/16/2016.
*/
/**
* A UriHandler is to get UniFile from custom uri for extensions
*/
interface UriHandler {
/**
* Create a [UniFile] representing the uri
*/
fun fromUri(context: Context?, uri: Uri?): UniFile?
}
@@ -1,27 +0,0 @@
/*
* Copyright 2022 Tarsin Norbin
*
* This file is part of EhViewer
*
* EhViewer is free software: you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* EhViewer is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with EhViewer.
* If not, see <https://www.gnu.org/licenses/>.
*/
package com.hippo.unifile
internal object Utils {
fun throwIfFatal(t: Throwable) {
// values here derived from https://github.com/ReactiveX/RxJava/issues/748#issuecomment-32471495
when (t) {
is VirtualMachineError, is ThreadDeath, is LinkageError -> throw t
}
}
}
@@ -1,72 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.openBrowser
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.util.withJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Displays all provided libraries in a simple list.
*/
@Composable
fun LibrariesContainer(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
librariesBlock: (Context) -> Libs = { context ->
Libs.Builder().withJson(context, R.raw.aboutlibraries).build()
},
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
itemSpacing: Dp = LibraryDefaults.LibraryItemSpacing,
header: (LazyListScope.() -> Unit)? = null,
onLibraryClick: ((Library) -> Unit)? = null,
) {
val context = LocalContext.current
val libraries = produceState<Libs?>(null) {
value = withContext(Dispatchers.IO) {
librariesBlock(context)
}
}
val libs = libraries.value?.libraries?.sortedBy { it.name.lowercase() }
if (libs != null) {
Libraries(
libraries = libs,
modifier = modifier,
lazyListState = lazyListState,
contentPadding = contentPadding,
showAuthor = showAuthor,
showVersion = showVersion,
showLicenseBadges = showLicenseBadges,
padding = padding,
itemContentPadding = itemContentPadding,
itemSpacing = itemSpacing,
header = header,
onLibraryClick = { library ->
if (onLibraryClick != null) {
onLibraryClick.invoke(library)
} else {
library.website?.let { context.openBrowser(it) }
}
},
)
}
}
@@ -1,266 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose
import android.widget.TextView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.ui.compose.util.author
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
/**
* Displays all provided libraries in a simple list.
*/
@Composable
fun Libraries(
libraries: List<Library>,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
itemSpacing: Dp = LibraryDefaults.LibraryItemSpacing,
header: (LazyListScope.() -> Unit)? = null,
onLibraryClick: ((Library) -> Unit)? = null,
) {
LazyColumn(
modifier,
verticalArrangement = Arrangement.spacedBy(itemSpacing),
state = lazyListState,
contentPadding = contentPadding,
) {
header?.invoke(this)
libraryItems(
libraries,
showAuthor,
showVersion,
showLicenseBadges,
padding,
itemContentPadding,
) {
onLibraryClick?.invoke(it)
}
}
}
internal inline fun LazyListScope.libraryItems(
libraries: List<Library>,
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding,
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
crossinline onLibraryClick: ((Library) -> Unit),
) {
items(libraries) { library ->
Library(
library,
showAuthor,
showVersion,
showLicenseBadges,
padding,
itemContentPadding,
) {
onLibraryClick.invoke(library)
}
}
}
@Composable
internal fun Library(
library: Library,
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
contentPadding: PaddingValues = LibraryDefaults.ContentPadding,
typography: Typography = MaterialTheme.typography,
onClick: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() }.padding(contentPadding)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = library.name,
modifier = Modifier.padding(padding.namePadding).weight(1f),
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val version = library.artifactVersion
if (version != null && showVersion) {
Text(
version,
modifier = Modifier.padding(padding.versionPadding),
style = typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
val author = library.author
if (showAuthor && author.isNotBlank()) {
Text(
text = author,
style = typography.bodyMedium,
)
}
var openDialog by remember { mutableStateOf(false) }
if (openDialog) {
LicenseDialog(library = library) {
openDialog = false
}
}
if (showLicenseBadges && library.licenses.isNotEmpty()) {
Row {
library.licenses.forEach {
SuggestionChip(
onClick = { openDialog = true },
label = { Text(text = it.name) },
)
}
}
}
}
}
@Composable
fun LicenseDialog(
library: Library,
onDismiss: () -> Unit,
) {
val scrollState = rememberScrollState()
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(id = android.R.string.ok))
}
},
text = {
Column(modifier = Modifier.verticalScroll(scrollState)) {
HtmlText(html = library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty())
}
},
)
}
@Composable
fun HtmlText(
html: String,
modifier: Modifier = Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context -> TextView(context) },
update = { it.text = html.parseAsHtml(flags = HtmlCompat.FROM_HTML_MODE_COMPACT) },
)
}
/**
* Contains the default values used by [Library]
*/
object LibraryDefaults {
private val LibraryItemPadding = 16.dp
private val LibraryNamePaddingTop = 4.dp
private val LibraryVersionPaddingStart = 8.dp
private val LibraryBadgePaddingTop = 8.dp
private val LibraryBadgePaddingEnd = 4.dp
internal val LibraryItemSpacing = 0.dp
/**
* The default content padding used by [Library]
*/
val ContentPadding = PaddingValues(LibraryItemPadding)
/**
* Creates a [LibraryPadding] that represents the default paddings used in a [Library]
*
* @param namePadding the padding around the name shown as part of a [Library]
* @param versionPadding the padding around the version shown as part of a [Library]
* @param badgePadding the padding around a badge element shown as part of a [Library]
* @param badgeContentPadding the padding around the content of a badge element shown as part of a [Library]
*/
@Composable
fun libraryPadding(
namePadding: PaddingValues = PaddingValues(top = LibraryNamePaddingTop),
versionPadding: PaddingValues = PaddingValues(start = LibraryVersionPaddingStart),
badgePadding: PaddingValues = PaddingValues(
top = LibraryBadgePaddingTop,
end = LibraryBadgePaddingEnd,
),
badgeContentPadding: PaddingValues = PaddingValues(4.dp),
): LibraryPadding = DefaultLibraryPadding(
namePadding = namePadding,
versionPadding = versionPadding,
badgePadding = badgePadding,
badgeContentPadding = badgeContentPadding,
)
}
/**
* Represents the padding values used in a library.
*/
@Stable
interface LibraryPadding {
/** Represents the padding around the name shown as part of a [Library] */
val namePadding: PaddingValues
/** Represents the padding around the version shown as part of a [Library] */
val versionPadding: PaddingValues
/** Represents the padding around a badge element shown as part of a [Library] */
val badgePadding: PaddingValues
/** Represents the padding around the content of a badge element shown as part of a [Library] */
val badgeContentPadding: PaddingValues
}
/**
* Default [LibraryPadding].
*/
@Immutable
private class DefaultLibraryPadding(
override val namePadding: PaddingValues,
override val versionPadding: PaddingValues,
override val badgePadding: PaddingValues,
override val badgeContentPadding: PaddingValues,
) : LibraryPadding
@@ -1,13 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose.util
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.entity.License
val Library.author: String
get() = developers.takeIf { it.isNotEmpty() }?.map { it.name }?.joinToString(", ") ?: organization?.name ?: ""
val License.htmlReadyLicenseContent: String?
get() = licenseContent?.replace("\n", "<br />")
val License.strippedLicenseContent: String?
get() = licenseContent?.replace("<br />", "\n")?.replace("<br/>", "\n")
@@ -31,7 +31,11 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.filled.Crop
import com.hippo.ehviewer.icons.filled.CropOff
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
@@ -108,6 +112,13 @@ fun BottomReaderBar(onClickSettings: () -> Unit) {
contentDescription = stringResource(R.string.pref_rotation_type),
)
}
var cropBorder by Settings.cropBorder.asMutableState()
IconButton(onClick = { cropBorder = !cropBorder }) {
Icon(
imageVector = if (cropBorder) EhIcons.Default.Crop else EhIcons.Default.CropOff,
contentDescription = stringResource(R.string.pref_crop_borders),
)
}
IconButton(onClick = onClickSettings) {
Icon(
imageVector = Icons.Outlined.Settings,
@@ -30,8 +30,6 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
import android.provider.MediaStore
import android.view.KeyEvent
import android.view.MotionEvent
@@ -48,15 +46,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheetFix
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
@@ -86,6 +82,7 @@ import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.awaitActivityResult
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.getParcelableExtraCompat
import com.hippo.ehviewer.util.getValue
@@ -96,7 +93,7 @@ import com.hippo.ehviewer.util.lazyMut
import com.hippo.ehviewer.util.requestPermission
import com.hippo.ehviewer.util.sendTo
import com.hippo.ehviewer.util.setValue
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
@@ -107,10 +104,10 @@ import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.isNightMode
import java.io.File
import java.io.IOException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
@@ -120,6 +117,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.runSuspendCatching
import splitties.init.appCtx
import splitties.systemservices.clipboardManager
@@ -139,7 +137,6 @@ class ReaderActivity : EhActivity() {
private var mUri: Uri? = null
private var mGalleryInfo: BaseGalleryInfo? = null
private var mPage: Int = 0
private var mCacheFileName: String? = null
private val vm: GalleryModel by viewModels()
private val dialogState = DialogState()
@@ -147,36 +144,7 @@ class ReaderActivity : EhActivity() {
* Whether the menu is currently visible.
*/
var menuVisible by mutableStateOf(false)
private var saveImageToLauncher = registerForActivityResult(
CreateDocument("todo/todo"),
) { uri ->
if (uri != null) {
val filepath = AppConfig.externalTempDir.toString() + File.separator + mCacheFileName
val cachefile = File(filepath)
lifecycleScope.launchIO {
try {
ParcelFileDescriptor.open(cachefile, MODE_READ_ONLY).use { from ->
contentResolver.openFileDescriptor(uri, "w")!!.use {
from sendTo it
}
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
runOnUiThread {
Toast.makeText(
this@ReaderActivity,
getString(R.string.image_saved, uri.path),
Toast.LENGTH_SHORT,
).show()
}
}
cachefile.delete()
}
}
}
var mGalleryProvider by lazyMut { vm::galleryProvider }
private var mGalleryProvider by lazyMut { vm::galleryProvider }
private var mCurrentIndex: Int = 0
private val galleryDetailUrl: String?
@@ -200,42 +168,38 @@ class ReaderActivity : EhActivity() {
if (ACTION_EH == mAction) {
mGalleryInfo?.let { mGalleryProvider = EhPageLoader(it) }
} else if (Intent.ACTION_VIEW == mAction) {
if (isAtLeastP) {
if (mUri != null) {
try {
grantUriPermission(
BuildConfig.APPLICATION_ID,
mUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (e: Exception) {
Toast.makeText(this, R.string.error_reading_failed, Toast.LENGTH_SHORT).show()
}
if (mUri != null) {
try {
grantUriPermission(
BuildConfig.APPLICATION_ID,
mUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (e: Exception) {
Toast.makeText(this, R.string.error_reading_failed, Toast.LENGTH_SHORT).show()
}
mGalleryProvider = ArchivePageLoader(
appCtx,
mUri!!,
) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) {
if (it.isBlank()) {
getString(R.string.passwd_cannot_be_empty)
} else if (invalidator(it)) {
null
} else {
getString(R.string.passwd_wrong)
}
mGalleryProvider = ArchivePageLoader(
appCtx,
mUri!!,
) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) {
if (it.isBlank()) {
getString(R.string.passwd_cannot_be_empty)
} else if (invalidator(it)) {
null
} else {
getString(R.string.passwd_wrong)
}
}.onFailure {
finish()
}.getOrThrow()
}
}
}.onFailure {
finish()
}.getOrThrow()
}
} else {
Toast.makeText(this, "Archives are not supported before Android P", Toast.LENGTH_LONG).show()
}
}
}
@@ -326,7 +290,7 @@ class ReaderActivity : EhActivity() {
dialogState.Intercept()
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val sliderValueFlow = remember { MutableStateFlow(0) }
val sliderValueFlow = remember { MutableStateFlow(-1) }
LaunchedEffect(sliderValueFlow) {
sliderValueFlow.drop(1).debounce(200).collect {
moveToPageIndex(it)
@@ -368,7 +332,18 @@ class ReaderActivity : EhActivity() {
initializeMenu()
}
fun setGallery() {
fun retryPage(index: Int, orgImg: Boolean = false) {
mGalleryProvider?.retryPage(index, orgImg)
}
fun restartGalleryProvider() {
mGalleryProvider?.let {
it.restart()
viewer?.refreshAdapter()
}
}
private fun setGallery() {
if (mGalleryProvider?.isReady != true) return
// Get start page
@@ -408,7 +383,7 @@ class ReaderActivity : EhActivity() {
}
val file = mGalleryProvider!!.save(
page,
UniFile.fromFile(dir)!!,
dir.asUniFile(),
mGalleryProvider!!.getImageFilenameWithExtension(page),
)
if (file == null) {
@@ -466,7 +441,7 @@ class ReaderActivity : EhActivity() {
}
val file = mGalleryProvider!!.save(
page,
UniFile.fromFile(dir)!!,
dir.asUniFile(),
mGalleryProvider!!.getImageFilenameWithExtension(page),
)
if (file == null) {
@@ -534,7 +509,7 @@ class ReaderActivity : EhActivity() {
Toast.makeText(this@ReaderActivity, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show()
return@launchUI
}
if (!mGalleryProvider!!.save(page, UniFile.fromMediaUri(this@ReaderActivity, imageUri))) {
if (!mGalleryProvider!!.save(page, imageUri.asUniFile())) {
try {
resolver.delete(imageUri, null, null)
} catch (e: Exception) {
@@ -571,7 +546,7 @@ class ReaderActivity : EhActivity() {
}
val file = mGalleryProvider!!.save(
page,
UniFile.fromFile(dir)!!,
dir.asUniFile(),
mGalleryProvider!!.getImageFilenameWithExtension(page),
)
if (file == null) {
@@ -583,12 +558,21 @@ class ReaderActivity : EhActivity() {
Toast.makeText(this, R.string.error_cant_save_image, Toast.LENGTH_SHORT).show()
return
}
mCacheFileName = filename
try {
saveImageToLauncher.launch(filename)
} catch (e: Throwable) {
ExceptionUtils.throwIfFatal(e)
Toast.makeText(this, R.string.error_cant_find_activity, Toast.LENGTH_SHORT).show()
val activity = this
lifecycleScope.launchUI {
runSuspendCatching {
val uri = awaitActivityResult(CreateDocument("todo/todo"), filename)
if (uri != null) {
withIOContext {
val f = requireNotNull(AppConfig.externalTempDir).asUniFile() / filename
f sendTo uri.asUniFile()
f.delete()
}
Toast.makeText(activity, getString(R.string.image_saved, uri.path), Toast.LENGTH_SHORT).show()
}
}.onFailure {
Toast.makeText(activity, R.string.error_cant_find_activity, Toast.LENGTH_SHORT).show()
}
}
}
@@ -754,17 +738,11 @@ class ReaderActivity : EhActivity() {
fun onPageLongTap(page: ReaderPage) {
lifecycleScope.launch {
dialogState.dialog {
val coroutineScope = rememberCoroutineScope()
fun dismiss() = it.cancel()
val sheetState = rememberModalBottomSheetState()
ModalBottomSheetFix(
onDismissRequest = { dismiss() },
sheetState = sheetState,
ModalBottomSheet(
onDismissRequest = { it.cancel() },
windowInsets = WindowInsets(0),
) {
ReaderPageSheet(page) {
coroutineScope.launch { sheetState.hide() }
}
ReaderPageSheet(page) { it.cancel() }
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@@ -854,6 +832,10 @@ class ReaderActivity : EhActivity() {
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.cropBorders().changes()
.onEach { restartGalleryProvider() }
.launchIn(lifecycleScope)
ReaderPreferences.readerTheme().changes()
.onEach { theme ->
binding.readerContainer.setBackgroundResource(
@@ -43,10 +43,10 @@ fun ReaderPageSheet(page: ReaderPage, dismiss: () -> Unit) {
Text(text = stringResource(id = text))
}
Item(icon = Icons.Default.Refresh, text = R.string.refresh) {
activity.mGalleryProvider?.retryPage(page.index)
activity.retryPage(page.index)
}
Item(icon = Icons.Default.Refresh, text = R.string.refresh_original) {
activity.mGalleryProvider?.retryPage(page.index, true)
activity.retryPage(page.index, true)
}
Item(icon = Icons.Default.Share, text = R.string.action_share) {
activity.shareImage(page.index)
@@ -19,7 +19,7 @@ abstract class PageLoader {
} else {
(OSUtils.appMaxMemory / 3 * 2).toInt()
},
sizeOf = { _, v -> v.size },
sizeOf = { _, v -> v.size.toInt() },
onEntryRemoved = { _, _, o, _ -> o.recycle() },
)
val mPages by lazy {
@@ -58,7 +58,7 @@ abstract class PageLoader {
preloadPages(pagesAbsent, (index - 10).coerceAtLeast(0) to (mPreloads + index + 10).coerceAtMost(size))
}
fun retryPage(index: Int, orgImg: Boolean = false) {
fun retryPage(index: Int, orgImg: Boolean) {
notifyPageWait(index)
onForceRequest(index, orgImg)
}
@@ -18,8 +18,8 @@ enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val str
companion object {
const val MASK = 0x00000038
fun fromPreference(preference: Int?): OrientationType = values().find { it.flagValue == preference } ?: FREE
fun fromPreference(preference: Int?): OrientationType = entries.find { it.flagValue == preference } ?: FREE
fun fromSpinner(position: Int?) = values().find { value -> value.prefValue == position } ?: DEFAULT
fun fromSpinner(position: Int?) = entries.find { value -> value.prefValue == position } ?: DEFAULT
}
}
@@ -37,6 +37,8 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
init {
addView(binding.root)
context as ReaderActivity
if (isAtLeastO) {
binding.wideColorGamut.isEnabled = Image.isWideColorGamut
if (!Image.isWideColorGamut) {
@@ -47,20 +49,17 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
readerPreferences.wideColorGamut().asHotFlow {
Image.colorSpace = ColorSpace.get(if (Image.isWideColorGamut && it) ColorSpace.Named.DISPLAY_P3 else ColorSpace.Named.SRGB)
val colorMode = if (Image.isWideColorGamut && it) ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT else ActivityInfo.COLOR_MODE_DEFAULT
(context as ReaderActivity).run {
with(context) {
if (window.colorMode != colorMode) {
window.colorMode = colorMode
mGalleryProvider?.restart()
setGallery()
restartGalleryProvider()
}
}
}.launchIn((context as ReaderActivity).lifecycleScope)
}.launchIn(context.lifecycleScope)
} else {
binding.wideColorGamut.isVisible = false
}
context as ReaderActivity
readerPreferences.colorFilter().changes()
.onEach { setColorFilter(it) }
.launchIn(context.lifecycleScope)
@@ -100,7 +100,7 @@ class ReaderReadingModeSettings @JvmOverloads constructor(
binding.pagerPrefsGroup.landscapeZoom.bindToPreference(readerPreferences.landscapeZoom())
binding.pagerPrefsGroup.zoomStart.bindToPreference(readerPreferences.zoomStart(), 1)
// binding.pagerPrefsGroup.cropBorders.bindToPreference(readerPreferences.cropBorders())
binding.pagerPrefsGroup.cropBorders.bindToPreference(readerPreferences.cropBorders())
}
/**
@@ -119,7 +119,7 @@ class ReaderReadingModeSettings @JvmOverloads constructor(
readerPreferences.navigationModeWebtoon()
.asHotFlow { binding.webtoonPrefsGroup.tappingInverted.isVisible = it != 5 }
.launchIn((context as ReaderActivity).lifecycleScope)
// binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(readerPreferences.cropBordersWebtoon())
binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(readerPreferences.cropBorders())
binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(
readerPreferences.webtoonSidePadding(),
R.array.webtoon_side_padding_values,
@@ -22,14 +22,14 @@ enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @D
companion object {
const val MASK = 0x00000007
fun fromPreference(preference: Int?): ReadingModeType = values().find { it.flagValue == preference } ?: DEFAULT
fun fromPreference(preference: Int?): ReadingModeType = entries.find { it.flagValue == preference } ?: DEFAULT
fun isPagerType(preference: Int): Boolean {
val mode = fromPreference(preference)
return mode == LEFT_TO_RIGHT || mode == RIGHT_TO_LEFT || mode == VERTICAL
}
fun fromSpinner(position: Int?) = values().find { value -> value.prefValue == position } ?: DEFAULT
fun fromSpinner(position: Int?) = entries.find { value -> value.prefValue == position } ?: DEFAULT
fun toViewer(preference: Int?, activity: ReaderActivity): BaseViewer {
return when (fromPreference(preference)) {
@@ -42,4 +42,6 @@ interface BaseViewer {
* return true if the event was handled, false otherwise.
*/
fun handleGenericMotionEvent(event: MotionEvent): Boolean
fun refreshAdapter()
}
@@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.GestureDetector
@@ -18,10 +19,13 @@ import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.DrawableImage
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.SCALE_TYPE_CENTER_INSIDE
import com.github.chrisbanes.photoview.PhotoView
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
@@ -120,14 +124,16 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
}
fun setImage(drawable: Drawable, config: Config) {
fun setImage(drawable: Image, config: Config) {
this.config = config
if (drawable is Animatable) {
val image = drawable.innerImage ?: return
if (image is DrawableImage) {
prepareAnimatedImageView()
setAnimatedImage(drawable, config)
} else {
setAnimatedImage(image.drawable, config)
}
if (image is BitmapImage) {
prepareNonAnimatedImageView()
setNonAnimatedImage(drawable, config)
setNonAnimatedImage(image.bitmap, drawable.rect, config)
}
}
@@ -233,7 +239,8 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
private fun setNonAnimatedImage(
image: Drawable,
image: Bitmap,
bounds: Rect,
config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
@@ -254,8 +261,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
},
)
val bitmap = (image as BitmapDrawable).bitmap
setImage(bitmap)
setImage(image, bounds)
isVisible = true
}
@@ -10,11 +10,11 @@ import eu.kanade.tachiyomi.util.lang.invert
abstract class ViewerNavigation {
sealed class NavigationRegion(@StringRes val nameRes: Int, val colorRes: Int) {
object MENU : NavigationRegion(R.string.action_menu, R.color.navigation_menu)
object PREV : NavigationRegion(R.string.nav_zone_prev, R.color.navigation_prev)
object NEXT : NavigationRegion(R.string.nav_zone_next, R.color.navigation_next)
object LEFT : NavigationRegion(R.string.nav_zone_left, R.color.navigation_left)
object RIGHT : NavigationRegion(R.string.nav_zone_right, R.color.navigation_right)
data object MENU : NavigationRegion(R.string.action_menu, R.color.navigation_menu)
data object PREV : NavigationRegion(R.string.nav_zone_prev, R.color.navigation_prev)
data object NEXT : NavigationRegion(R.string.nav_zone_next, R.color.navigation_next)
data object LEFT : NavigationRegion(R.string.nav_zone_left, R.color.navigation_left)
data object RIGHT : NavigationRegion(R.string.nav_zone_right, R.color.navigation_right)
}
data class Region(
@@ -62,9 +62,6 @@ class PagerConfig(
readerPreferences.zoomStart()
.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() })
// readerPreferences.cropBorders()
// .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
readerPreferences.navigateToPan()
.register({ navigateToPan = it })
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more