Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37660ef697 | ||
|
|
9821b3aaab | ||
|
|
1f5331af5a | ||
|
|
3ea6c82a6d | ||
|
|
ba53d01fa1 | ||
|
|
df8ae8d11a | ||
|
|
c817726ab3 | ||
|
|
9eb606810f | ||
|
|
fbb030ec8e | ||
|
|
28afebefb1 | ||
|
|
da2610657c | ||
|
|
6e3471ba1b | ||
|
|
5a9ab6c4eb | ||
|
|
1fe553d039 | ||
|
|
42e0b0aeab | ||
|
|
9e0ea32296 | ||
|
|
d3f5274b7e | ||
|
|
faff0733e9 | ||
|
|
7b32cde7dd | ||
|
|
648d1d10b3 | ||
|
|
52727100a0 | ||
|
|
8a2dbb99f0 | ||
|
|
d880b30ea9 | ||
|
|
0e2ca4e150 | ||
|
|
e0eef82ed8 | ||
|
|
09839086a2 | ||
|
|
3ca3ab7273 | ||
|
|
8386bf9c69 | ||
|
|
99b8ee1f78 | ||
|
|
0333a8c679 | ||
|
|
e0bb09e902 | ||
|
|
77677cead3 | ||
|
|
d4b2ff7de9 | ||
|
|
b81bae09d1 | ||
|
|
846e66a56c | ||
|
|
5cec635e52 | ||
|
|
8d0dc88125 | ||
|
|
51b58bb797 | ||
|
|
f79aa2350c | ||
|
|
719dddeab9 | ||
|
|
c1522568fd | ||
|
|
3ad6b9106b | ||
|
|
549387ac91 | ||
|
|
7d2f985ffa | ||
|
|
7e8b0a5800 | ||
|
|
69252ae8a4 | ||
|
|
eeed56b211 | ||
|
|
8aaad071ea | ||
|
|
7ef67e1bf9 | ||
|
|
1c15d9a956 | ||
|
|
70082090a1 | ||
|
|
dbc1a32496 | ||
|
|
e8c9ee5dd0 | ||
|
|
5c3657736b | ||
|
|
5191408d06 | ||
|
|
347ce6854d | ||
|
|
f86dec9b2e | ||
|
|
c74b128b1a | ||
|
|
8ee28a4b4f | ||
|
|
3b07b6997d | ||
|
|
f59023ed03 | ||
|
|
3fc13e61c3 | ||
|
|
ae22f448a8 | ||
|
|
450c15d51e | ||
|
|
d9e5bb4399 | ||
|
|
369904f659 | ||
|
|
96302b2344 | ||
|
|
eeffb12e3b | ||
|
|
57c12f7df0 |
No files matched your search
@@ -18,5 +18,4 @@ jobs:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: '1'
|
||||
include-any-issue-labels: 'duplicate, wontfix'
|
||||
process-only: 'issues'
|
||||
+37
-2
@@ -1,6 +1,41 @@
|
||||
# 变更日志
|
||||
|
||||
## 1.10.0
|
||||
## [1.10.2] - 2023-12-31
|
||||
|
||||
### 新功能
|
||||
|
||||
* [阅读器] 支持裁剪图片黑色/白色边缘
|
||||
|
||||
### 改进
|
||||
|
||||
* 切换 HTTP 引擎无需重启
|
||||
* 优化过渡动画
|
||||
* 杂项性能优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复搜索建议中出现关键词相同的条目时崩溃 [#533](https://github.com/FooIbar/EhViewer/issues/533) [#555](https://github.com/FooIbar/EhViewer/issues/555)
|
||||
* 修复重命名下载标签时不显示原标签 [#534](https://github.com/FooIbar/EhViewer/issues/534)
|
||||
* 修复调整下载项目顺序时卡顿 [#552](https://github.com/FooIbar/EhViewer/issues/552)
|
||||
* 修复新增或修改评论后黑屏 [#554](https://github.com/FooIbar/EhViewer/issues/554)
|
||||
* 修复剪贴板读取bug [#566](https://github.com/FooIbar/EhViewer/issues/566)
|
||||
* 修复画廊信息对话框无法通过返回按钮/手势关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
|
||||
|
||||
|
||||
## [1.10.1] - 2023-12-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 由于 Apache HttpClient 5 在 Android 10 以下无法使用,后备 HTTP 引擎恢复为 OkHttp [#514](https://github.com/FooIbar/EhViewer/issues/514)
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复获取 pToken 失败导致无限加载 [#508](https://github.com/FooIbar/EhViewer/issues/508)
|
||||
* 修复阅读页快速滑动滑块导致下载延时过多累计 [#512](https://github.com/FooIbar/EhViewer/issues/512)
|
||||
* 修复 Android 9 以下的设备上无法退出搜索栏 [#513](https://github.com/FooIbar/EhViewer/issues/513)
|
||||
|
||||
|
||||
## [1.10.0] - 2023-12-14 [YANKED]
|
||||
|
||||
### 重大变更
|
||||
|
||||
@@ -24,7 +59,7 @@
|
||||
* 修复 CI 版本不显示变更日志
|
||||
|
||||
|
||||
## 1.9.0
|
||||
## [1.9.0] - 2023-12-01
|
||||
|
||||
### 重大变更
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
|
||||
\* Archives and GIFs are not supported
|
||||
|
||||
** Fall back to Apache5 on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
@@ -95,6 +95,7 @@ Here is the libraries
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [MDC-Android](https://github.com/material-components/material-components-android)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [OkHttp](https://square.github.io/okhttp/)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
|
||||
@@ -64,8 +64,8 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 180047
|
||||
versionName = "1.10.0"
|
||||
versionCode = 180049
|
||||
versionName = "1.10.2"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -158,6 +158,7 @@ android {
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/**"
|
||||
excludes += "/okhttp3/**"
|
||||
excludes += "/kotlin/**"
|
||||
excludes += "**.txt"
|
||||
excludes += "**.bin"
|
||||
@@ -232,9 +233,14 @@ dependencies {
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
implementation(libs.material)
|
||||
implementation(libs.material.motion.core)
|
||||
|
||||
implementation(libs.bundles.splitties)
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.bundles.okhttp)
|
||||
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation(libs.diff)
|
||||
|
||||
Vendored
-8
@@ -14,14 +14,6 @@
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
# Apache5 Http Client
|
||||
-dontwarn org.ietf.jgss.GSSContext
|
||||
-dontwarn org.ietf.jgss.GSSCredential
|
||||
-dontwarn org.ietf.jgss.GSSException
|
||||
-dontwarn org.ietf.jgss.GSSManager
|
||||
-dontwarn org.ietf.jgss.GSSName
|
||||
-dontwarn org.ietf.jgss.Oid
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
|
||||
-allowaccessmodification
|
||||
|
||||
@@ -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"
|
||||
@@ -139,7 +134,6 @@
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<activity android:name=".ui.WebViewActivity" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
||||
@@ -76,7 +76,7 @@ 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_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics)
|
||||
|
||||
add_custom_target("noop")
|
||||
@@ -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.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* 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 {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/snackbar/overview" class="external" target="_blank">Material Design snackbar</a>.
|
||||
*
|
||||
* Snackbars provide brief messages about app processes at the bottom of the screen.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* Snackbars inform users of a process that an app has performed or will perform. They appear
|
||||
* temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience,
|
||||
* and they don’t require user input to disappear.
|
||||
*
|
||||
* A Snackbar can contain a single action. "Dismiss" or "cancel" actions are optional.
|
||||
*
|
||||
* Snackbars with an action should not timeout or self-dismiss until the user performs another
|
||||
* action. Here, moving the keyboard focus indicator to navigate through interactive elements in a
|
||||
* page is not considered an action.
|
||||
*
|
||||
* This version of snackbar is designed to work with [SnackbarData] provided by the
|
||||
* [SnackbarHost], which is usually used inside of the [Scaffold].
|
||||
*
|
||||
* This components provides only the visuals of the Snackbar. If you need to show a Snackbar
|
||||
* with defaults on the screen, use [SnackbarHostState.showSnackbar]:
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
|
||||
*
|
||||
* If you want to customize appearance of the Snackbar, you can pass your own version as a child
|
||||
* of the [SnackbarHost] to the [Scaffold]:
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.ScaffoldWithCustomSnackbar
|
||||
*
|
||||
* When a [SnackbarData.visuals] sets the Snackbar's duration as [SnackbarDuration.Indefinite], it's
|
||||
* recommended to display an additional close affordance action.
|
||||
* See [SnackbarVisuals.withDismissAction]:
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.ScaffoldWithIndefiniteSnackbar
|
||||
*
|
||||
* @param snackbarData data about the current snackbar showing via [SnackbarHostState]
|
||||
* @param modifier the [Modifier] to be applied to this snackbar
|
||||
* @param actionOnNewLine whether or not action should be put on a separate line. Recommended for
|
||||
* action with long action text.
|
||||
* @param shape defines the shape of this snackbar's container
|
||||
* @param containerColor the color used for the background of this snackbar. Use [Color.Transparent]
|
||||
* to have no color.
|
||||
* @param contentColor the preferred color for content inside this snackbar
|
||||
* @param actionColor the color of the snackbar's action
|
||||
* @param actionContentColor the preferred content color for the optional action inside this
|
||||
* snackbar. See [SnackbarVisuals.actionLabel].
|
||||
* @param dismissActionContentColor the preferred content color for the optional dismiss action
|
||||
* inside this snackbar. See [SnackbarVisuals.withDismissAction].
|
||||
*/
|
||||
@Composable
|
||||
fun Snackbar2(
|
||||
snackbarData: SnackbarData,
|
||||
modifier: Modifier = Modifier,
|
||||
actionOnNewLine: Boolean = false,
|
||||
shape: Shape = SnackbarDefaults.shape,
|
||||
containerColor: Color = SnackbarDefaults.color,
|
||||
contentColor: Color = SnackbarDefaults.contentColor,
|
||||
actionColor: Color = SnackbarDefaults.actionColor,
|
||||
actionContentColor: Color = SnackbarDefaults.actionContentColor,
|
||||
dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor,
|
||||
) {
|
||||
val actionLabel = snackbarData.visuals.actionLabel
|
||||
val actionComposable: (@Composable () -> Unit)? = if (actionLabel != null) {
|
||||
@Composable {
|
||||
TextButton(
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = actionColor),
|
||||
onClick = { snackbarData.performAction() },
|
||||
content = { Text(actionLabel) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val dismissActionComposable: (@Composable () -> Unit)? =
|
||||
if (snackbarData.visuals.withDismissAction) {
|
||||
@Composable {
|
||||
IconButton(
|
||||
onClick = { snackbarData.dismiss() },
|
||||
content = {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = null,
|
||||
// Workaround for regression of https://android-review.googlesource.com/c/platform/frameworks/support/+/2881006
|
||||
tint = dismissActionContentColor,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Snackbar(
|
||||
modifier = modifier.padding(12.dp),
|
||||
action = actionComposable,
|
||||
dismissAction = dismissActionComposable,
|
||||
actionOnNewLine = actionOnNewLine,
|
||||
shape = shape,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
actionContentColor = actionContentColor,
|
||||
dismissActionContentColor = dismissActionContentColor,
|
||||
content = { Text(snackbarData.visuals.message) },
|
||||
)
|
||||
}
|
||||
@@ -17,19 +17,18 @@
|
||||
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
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -39,8 +38,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
|
||||
@@ -54,9 +55,6 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.createGraph
|
||||
import androidx.navigation.get
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import kotlin.math.roundToLong
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -189,6 +187,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 +246,35 @@ public fun NavHost(
|
||||
val zIndices = remember { mutableMapOf<String, Float>() }
|
||||
|
||||
if (backStackEntry != null) {
|
||||
val transitionState = remember { SeekableTransitionState(backStackEntry) }
|
||||
val transition = rememberTransition(transitionState, label = "entry")
|
||||
LaunchedEffect(backStackEntry) {
|
||||
transitionState.animateTo(backStackEntry)
|
||||
}
|
||||
|
||||
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 +288,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 +307,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 +329,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) {
|
||||
|
||||
+10
-7
@@ -9,6 +9,7 @@ import android.graphics.Paint
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.GestureDetector
|
||||
@@ -145,7 +146,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,9 +231,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
*
|
||||
* @param bitmap Image source.
|
||||
*/
|
||||
fun setImage(bitmap: Bitmap) {
|
||||
fun setImage(bitmap: BitmapDrawable) {
|
||||
reset(true)
|
||||
onImageLoaded(bitmap)
|
||||
srcRect = Rect(bitmap.bounds)
|
||||
onImageLoaded(bitmap.bitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +269,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
}
|
||||
sWidth = 0
|
||||
sHeight = 0
|
||||
sRegion.setEmpty()
|
||||
srcRect.setEmpty()
|
||||
isReady = false
|
||||
isImageLoaded = false
|
||||
bitmap = null
|
||||
@@ -760,6 +762,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 +911,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 +919,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
|
||||
@@ -29,13 +30,14 @@ import androidx.lifecycle.coroutineScope
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.asCoilImage
|
||||
import coil3.decode.ImageDecoderDecoder
|
||||
import coil3.fetch.NetworkFetcher
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import 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
|
||||
@@ -51,15 +53,24 @@ import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isCronetAvailable
|
||||
import com.hippo.ehviewer.util.resettableLazy
|
||||
import com.hippo.ehviewer.util.unsafeLazy
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.apache5.Apache5
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.AsyncDns
|
||||
import okhttp3.android.AndroidAsyncDns
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import splitties.arch.room.roomDb
|
||||
import splitties.init.appCtx
|
||||
@@ -105,6 +116,22 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
launchIO {
|
||||
DownloadManager.isIdle
|
||||
}
|
||||
launchIO {
|
||||
runSuspendCatching {
|
||||
val files = mutableListOf<File>()
|
||||
AppConfig.externalCrashDir?.listFiles()?.let { files.addAll(it) }
|
||||
AppConfig.externalParseErrorDir?.listFiles()?.let { files.addAll(it) }
|
||||
files.sortByDescending { it.lastModified() }
|
||||
files.forEachIndexed { index, file ->
|
||||
ensureActive()
|
||||
if (index > 9) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
launchIO {
|
||||
cleanupDownload()
|
||||
}
|
||||
@@ -154,13 +181,14 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader() = imageLoader {
|
||||
override fun newImageLoader(context: Context) = context.imageLoader {
|
||||
components {
|
||||
installKtorHttpUriFetcher()
|
||||
add(NetworkFetcher.Factory(unsafeLazy { ktorClient }))
|
||||
if (isAtLeastP) {
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
}
|
||||
add(MergeInterceptor)
|
||||
add(DownloadThumbInterceptor)
|
||||
}
|
||||
diskCache(imageCache)
|
||||
crossfade(300)
|
||||
@@ -170,7 +198,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ktorClient by lazy {
|
||||
var ktorClient by resettableLazy {
|
||||
if (Settings.enableCronet && isCronetAvailable) {
|
||||
HttpClient(CronetEngine) {
|
||||
install(HttpCookies) {
|
||||
@@ -178,7 +206,15 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpClient(Apache5) {
|
||||
HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
if (isAtLeastQ) {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
}
|
||||
addInterceptor(UncaughtExceptionInterceptor())
|
||||
}
|
||||
}
|
||||
install(HttpCookies) {
|
||||
storage = EhCookieStore
|
||||
}
|
||||
@@ -186,7 +222,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,8 @@ 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 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,7 +144,7 @@ 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -65,16 +65,18 @@ object EhCookieStore : CookiesStorage {
|
||||
override fun close() = Unit
|
||||
|
||||
fun load(url: Url): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString()) ?: return emptyList()
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
val checkTips = EhUrl.DOMAIN_E in url.host
|
||||
return parseClientCookiesHeader(cookies)
|
||||
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
|
||||
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
|
||||
if (checkTips) {
|
||||
removeAll { it.name == KEY_CONTENT_WARNING }
|
||||
add(sTipsCookie)
|
||||
return cookies?.let {
|
||||
parseClientCookiesHeader(cookies)
|
||||
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
|
||||
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
|
||||
if (checkTips) {
|
||||
removeAll { it.name == KEY_CONTENT_WARNING }
|
||||
add(sTipsCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: if (checkTips) listOf(sTipsCookie) else emptyList()
|
||||
}
|
||||
|
||||
override suspend fun get(requestUrl: Url) = load(requestUrl)
|
||||
|
||||
@@ -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
|
||||
@@ -52,12 +53,10 @@ import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.forms.append
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.statement.request
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.utils.io.core.writeFully
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
@@ -79,8 +78,6 @@ import moe.tarsin.coroutines.runSuspendCatching
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
|
||||
const val TAG = "EhEngine"
|
||||
|
||||
// https://ehwiki.org/wiki/API#Basics
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
@@ -145,7 +142,6 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend String.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
val body = response.bodyAsText()
|
||||
runSuspendCatching {
|
||||
block(body)
|
||||
@@ -155,7 +151,6 @@ suspend inline fun <T> HttpStatement.fetchUsingAsText(crossinline block: suspend
|
||||
}
|
||||
|
||||
suspend inline fun <T> HttpStatement.fetchUsingAsByteBuffer(crossinline block: suspend ByteBuffer.() -> T) = execute { response ->
|
||||
logcat(tag = TAG) { response.request.url.toString() }
|
||||
httpContentPool.useInstance { buffer ->
|
||||
with(response.bodyAsChannel()) { while (!isClosedForRead) readAvailable(buffer) }
|
||||
buffer.flip()
|
||||
@@ -260,13 +255,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 = "") {
|
||||
@@ -325,7 +325,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()
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.client
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.forms.FormBuilder
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
@@ -67,11 +68,12 @@ suspend inline fun ehRequest(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
verbose: Boolean = true,
|
||||
builder: HttpRequestBuilder.() -> Unit = {},
|
||||
) = ktorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
}.also { if (verbose) logcat("EhRequest") { url } }
|
||||
|
||||
suspend inline fun noRedirectEhRequest(
|
||||
url: String,
|
||||
@@ -81,7 +83,7 @@ suspend inline fun noRedirectEhRequest(
|
||||
) = noRedirectKtorClient.prepareRequest(url) {
|
||||
applyEhConfig(referer, origin)
|
||||
apply(builder)
|
||||
}
|
||||
}.also { logcat("EhRequest") { url } }
|
||||
|
||||
inline fun HttpRequestBuilder.formBody(builder: ParametersBuilder.() -> Unit) {
|
||||
method = HttpMethod.Post
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.hippo.ehviewer.client.exception
|
||||
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class CloudflareBypassException : EhException(R.string.cloudflare_bypass_failed)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -3,30 +3,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,6 +1,5 @@
|
||||
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
|
||||
@@ -9,11 +8,12 @@ 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)
|
||||
unmarshalParsingAs<GalleryListResult>(body, ::parseGalleryInfoList)
|
||||
}.onFailure {
|
||||
if (it is RuntimeException) {
|
||||
if (it.message?.startsWith("Your IP address") == true) {
|
||||
@@ -39,10 +39,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()
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,31 @@ import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.httpHeaders
|
||||
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).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)
|
||||
}
|
||||
@@ -46,14 +46,14 @@ 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 splitties.preferences.edit
|
||||
|
||||
object DownloadManager : OnSpiderListener {
|
||||
@@ -595,17 +595,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) {
|
||||
@@ -1017,16 +1006,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 +1025,4 @@ var downloadLocation: UniFile
|
||||
}
|
||||
}
|
||||
|
||||
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation.subFile(it) }
|
||||
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
|
||||
@@ -169,11 +169,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,14 @@ 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 androidx.core.graphics.drawable.toDrawable
|
||||
import coil3.decode.BitmapFactoryDecoder
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.decode.ImageSource
|
||||
@@ -37,11 +39,13 @@ import coil3.request.Options
|
||||
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.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.openInputStream
|
||||
@@ -52,11 +56,39 @@ 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 val CropAspect = 0.1..100.0
|
||||
|
||||
class Image private constructor(drawable: Drawable, private val src: AutoCloseable) {
|
||||
val size = drawable.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
|
||||
private val intrinsicRect = drawable.run { Rect(0, 0, intrinsicWidth, intrinsicHeight) }
|
||||
|
||||
var mObtainedDrawable: Drawable? = if (drawable is Animatable) {
|
||||
// Cannot crop animated image's border
|
||||
drawable
|
||||
} else {
|
||||
if (Settings.cropBorder.value) {
|
||||
val bitmap = (drawable as BitmapDrawable).bitmap
|
||||
val array = detectBorder(bitmap)
|
||||
val rect = with(bitmap) {
|
||||
val r = Rect(array[0], array[1], array[2], array[3])
|
||||
val aspectAfterCrop = r.height().toFloat() / r.width()
|
||||
if (aspectAfterCrop in CropAspect) r else intrinsicRect
|
||||
}
|
||||
|
||||
val final = if (isAtLeastQ) {
|
||||
// Upload to Graphical Buffer to accelerate render
|
||||
bitmap.copy(Bitmap.Config.HARDWARE, false).apply {
|
||||
bitmap.recycle()
|
||||
}
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
|
||||
final.toDrawable(appCtx.resources).apply { bounds = rect }
|
||||
} else {
|
||||
drawable.apply { bounds = intrinsicRect }
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
@@ -116,7 +148,7 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
}
|
||||
}
|
||||
}
|
||||
val drawable = decodeDrawable(src.source.imageSource)
|
||||
val drawable = decodeDrawable(src.source.asImageSource())
|
||||
if (drawable !is Animatable) src.close()
|
||||
Image(drawable, src)
|
||||
} else {
|
||||
@@ -161,8 +193,7 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
|
||||
@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
|
||||
if (!isAtLeastQ || Settings.cropBorder.value) {
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
}
|
||||
decoder.setTargetColorSpace(colorSpace)
|
||||
@@ -204,3 +235,5 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
|
||||
val source: ByteBuffer
|
||||
}
|
||||
}
|
||||
|
||||
private external fun detectBorder(bitmap: Bitmap): IntArray
|
||||
@@ -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.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
|
||||
}
|
||||
@@ -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? {
|
||||
@@ -384,16 +381,16 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
galleryInfo.gid,
|
||||
galleryInfo.token!!,
|
||||
)
|
||||
return try {
|
||||
return runSuspendCatching {
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
GalleryMultiPageViewerPTokenParser.parse(this).forEachIndexed { index, s ->
|
||||
spiderInfo.pTokenMap[index] = s
|
||||
}
|
||||
spiderInfo.pTokenMap[index]
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
return null
|
||||
}.getOrElse {
|
||||
it.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,13 +413,13 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
previewIndex,
|
||||
false,
|
||||
)
|
||||
return try {
|
||||
return runSuspendCatching {
|
||||
ehRequest(url, referer).fetchUsingAsText {
|
||||
readPreviews(this, previewIndex, spiderInfo)
|
||||
spiderInfo.pTokenMap[index]
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ExceptionUtils.throwIfFatal(e)
|
||||
}.getOrElse {
|
||||
it.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -522,7 +519,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
private val mDownloadDelay = Settings.downloadDelay.milliseconds
|
||||
private val downloadTimeout = Settings.downloadTimeout.seconds
|
||||
private val delayLock = Mutex()
|
||||
private var delayedTime = TimeSource.Monotonic.markNow()
|
||||
private var lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
private var isDownloadMode = false
|
||||
|
||||
fun cancelDecode(index: Int) {
|
||||
@@ -556,7 +553,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
val state = mPageStateArray[index]
|
||||
if (!force && state == STATE_FINISHED) return
|
||||
val currentJob = mFetcherJobMap[index]
|
||||
if (force) currentJob?.cancel()
|
||||
if (force) currentJob?.cancel(CancellationException(FORCE_RETRY))
|
||||
if (currentJob?.isActive != true) {
|
||||
mFetcherJobMap[index] = launch {
|
||||
runCatching {
|
||||
@@ -566,7 +563,9 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}.onFailure {
|
||||
if (it is CancellationException) {
|
||||
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
|
||||
updatePageState(index, STATE_FAILED, "Cancelled")
|
||||
if (it.message != FORCE_RETRY) {
|
||||
updatePageState(index, STATE_FAILED, "Cancelled")
|
||||
}
|
||||
throw it
|
||||
}
|
||||
updatePageState(index, STATE_FAILED, ExceptionUtils.getReadableString(it))
|
||||
@@ -603,28 +602,23 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
}
|
||||
}
|
||||
val previousPToken: String?
|
||||
val pToken: String
|
||||
val pToken: String?
|
||||
|
||||
pTokenLock.withLock {
|
||||
pToken = getPToken(index) ?: return updatePageState(
|
||||
index,
|
||||
STATE_FAILED,
|
||||
PTOKEN_FAILED_MESSAGE,
|
||||
).also {
|
||||
// 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
|
||||
}
|
||||
previousPToken = getPToken(index - 1)
|
||||
}
|
||||
pToken ?: return
|
||||
|
||||
delayLock.withLock {
|
||||
val new = delayedTime + mDownloadDelay
|
||||
val duration = new.elapsedNow()
|
||||
if (duration.isNegative()) {
|
||||
delayedTime = new
|
||||
delay(-duration)
|
||||
} else {
|
||||
delayedTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
delay(mDownloadDelay - lastRequestTime.elapsedNow())
|
||||
lastRequestTime = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
|
||||
var skipHathKey: String? = null
|
||||
@@ -694,7 +688,6 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
|
||||
referer = null
|
||||
}
|
||||
checkNotNull(targetImageUrl)
|
||||
Log.d(WORKER_DEBUG_TAG, targetImageUrl)
|
||||
|
||||
repeat(3) { times ->
|
||||
runCatching {
|
||||
@@ -782,6 +775,7 @@ private val PTOKEN_FAILED_MESSAGE = appCtx.getString(R.string.error_get_ptoken_e
|
||||
private val ERROR_TIMEOUT = appCtx.getString(R.string.error_timeout)
|
||||
private val DECODE_ERROR = appCtx.getString(R.string.error_decoding_failed)
|
||||
private val URL_509_PATTERN = Regex("\\.org/.+/509s?\\.gif")
|
||||
private const val FORCE_RETRY = "Force retry"
|
||||
private const val WORKER_DEBUG_TAG = "SpiderQueenWorker"
|
||||
|
||||
private fun check509(url: String) {
|
||||
|
||||
@@ -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,17 +52,20 @@ 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
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.SideDrawer
|
||||
import androidx.compose.material3.Snackbar2
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
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 +74,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 +92,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 +109,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 +131,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 +157,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 +226,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 ->
|
||||
@@ -275,7 +294,7 @@ class MainActivity : EhActivity() {
|
||||
LaunchedEffect(Unit) {
|
||||
if (connectivityManager.isActiveNetworkMetered) {
|
||||
if (isAtLeastQ) {
|
||||
val ret = snackbarState.showSnackbar(warning, settings)
|
||||
val ret = snackbarState.showSnackbar(warning, settings, true)
|
||||
if (ret == SnackbarResult.ActionPerformed) {
|
||||
val panelIntent = Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
|
||||
startActivity(panelIntent)
|
||||
@@ -289,7 +308,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
|
||||
@@ -304,13 +323,13 @@ class MainActivity : EhActivity() {
|
||||
launch = { navController.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
}
|
||||
launch?.let {
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction)
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
|
||||
if (ret == SnackbarResult.ActionPerformed) it()
|
||||
}
|
||||
}
|
||||
Settings.clipboardTextHashCode = hashCode
|
||||
}
|
||||
onPauseOrDispose { }
|
||||
onPauseOrDispose { job.cancel() }
|
||||
}
|
||||
val currentDestination by navController.currentDestinationAsState()
|
||||
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
|
||||
@@ -322,7 +341,7 @@ class MainActivity : EhActivity() {
|
||||
LocalDrawerLockHandle provides lockDrawerHandle,
|
||||
LocalSnackbarHostState provides snackbarState,
|
||||
) {
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarState) { Snackbar2(it) } }) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
@@ -357,19 +376,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 +400,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 +427,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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.hippo.ehviewer.ui.webview.WebViewScreen
|
||||
|
||||
class WebViewActivity : EhActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val url = intent.extras?.getString(KEY_URL) ?: return
|
||||
setMD3Content {
|
||||
WebViewScreen(url = url) { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_URL = "url"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
@@ -259,7 +256,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.tourist_mode))
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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),
|
||||
) {
|
||||
@@ -356,7 +353,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.tourist_mode))
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -14,8 +14,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 +51,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -39,7 +38,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -51,17 +49,13 @@ import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.CloudflareBypassException
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.SadAndroid
|
||||
import com.hippo.ehviewer.ui.WebViewActivity
|
||||
import com.hippo.ehviewer.ui.screen.collectDetailSizeAsState
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -81,7 +75,6 @@ fun GalleryList(
|
||||
onRefresh: suspend CoroutineScope.() -> Unit,
|
||||
onLoading: suspend CoroutineScope.() -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
|
||||
@@ -175,7 +168,6 @@ fun GalleryList(
|
||||
}
|
||||
}
|
||||
|
||||
val dialogState = LocalDialogState.current
|
||||
when (val state = data.loadState.refresh) {
|
||||
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -200,18 +192,6 @@ fun GalleryList(
|
||||
|
||||
is LoadState.Error -> {
|
||||
Surface {
|
||||
LaunchedEffect(state) {
|
||||
if (state.error.cause is CloudflareBypassException) {
|
||||
dialogState.awaitPermissionOrCancel(title = R.string.cloudflare_bypass_failed) {
|
||||
Text(text = stringResource(id = R.string.open_in_webview))
|
||||
}
|
||||
context.startActivity(
|
||||
Intent(context, WebViewActivity::class.java).apply {
|
||||
putExtra(WebViewActivity.KEY_URL, EhUrl.host)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ErrorTip(
|
||||
modifier = Modifier.widthIn(max = 228.dp)
|
||||
.clip(ShapeDefaults.Small).clickable { data.retry() },
|
||||
|
||||
@@ -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,9 +138,9 @@ 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)
|
||||
val labelsStr = stringResource(R.string.download_labels)
|
||||
val labelEmpty = stringResource(R.string.label_text_is_empty)
|
||||
val defaultInvalid = stringResource(R.string.label_text_is_invalid)
|
||||
@@ -178,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
|
||||
@@ -195,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,
|
||||
@@ -234,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) {
|
||||
@@ -276,7 +279,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
val new = dialogState.awaitInputText(title = newLabel, hint = labelsStr) { text ->
|
||||
val new = dialogState.awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
|
||||
when {
|
||||
text.isBlank() -> labelEmpty
|
||||
text == defaultName -> defaultInvalid
|
||||
@@ -291,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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -394,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 {
|
||||
@@ -412,6 +426,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(),
|
||||
state = listState,
|
||||
@@ -420,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) {
|
||||
@@ -456,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)
|
||||
}
|
||||
@@ -275,6 +275,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
galleryInfo?.let { info -> EhDB.putHistoryInfo(info.findBaseInfo()) }
|
||||
getDetailError = ExceptionUtils.getReadableString(it)
|
||||
}.getOrNull()
|
||||
galleryDetail?.let {
|
||||
@@ -492,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 ->
|
||||
@@ -954,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),
|
||||
) {
|
||||
@@ -1135,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
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -77,6 +79,7 @@ import com.hippo.ehviewer.ui.LocalNavDrawerState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -233,15 +236,21 @@ fun SearchBarScreen(
|
||||
Modifier
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
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 {
|
||||
// Workaround unable to exit search field
|
||||
// https://android.googlesource.com/platform/frameworks/support/+/8f81d46e2c3f37564d0b7515ec9411314645e187
|
||||
Box(Modifier.size(1.dp).focusable())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Placeholder, fill immutable SearchBar padding
|
||||
@@ -289,8 +298,8 @@ fun SearchBarScreen(
|
||||
}
|
||||
active = it
|
||||
},
|
||||
title = title?.let { { Text(it, overflow = TextOverflow.Ellipsis) } },
|
||||
placeholder = searchFieldHint?.let { { Text(it) } },
|
||||
title = title.ifNotNullThen { Text(title!!, overflow = TextOverflow.Ellipsis) },
|
||||
placeholder = searchFieldHint.ifNotNullThen { Text(searchFieldHint!!) },
|
||||
leadingIcon = {
|
||||
if (active) {
|
||||
IconButton(onClick = { hideSearchView() }) {
|
||||
@@ -323,7 +332,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!!) },
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -142,9 +144,14 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
var enableCronet by Settings::enableCronet.observed
|
||||
Preference(
|
||||
title = stringResource(id = R.string.settings_advanced_http_engine),
|
||||
summary = if (enableCronet) "Cronet" else "Apache 5",
|
||||
summary = if (enableCronet) "Cronet" else "OkHttp",
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
fun reset() {
|
||||
ktorClient = ktorClient.apply { close() }
|
||||
noRedirectKtorClient = noRedirectKtorClient.apply { close() }
|
||||
}
|
||||
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
title = R.string.settings_advanced_http_engine,
|
||||
showCancelButton = false,
|
||||
@@ -153,14 +160,20 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.align(Alignment.Center)) {
|
||||
SegmentedButton(
|
||||
selected = !enableCronet,
|
||||
onClick = { enableCronet = false },
|
||||
onClick = {
|
||||
enableCronet = false
|
||||
reset()
|
||||
},
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
) {
|
||||
Text("Apache 5")
|
||||
Text("OkHttp")
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = enableCronet,
|
||||
onClick = { enableCronet = true },
|
||||
onClick = {
|
||||
enableCronet = true
|
||||
reset()
|
||||
},
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
enabled = isCronetAvailable,
|
||||
) {
|
||||
@@ -175,10 +188,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.preload_thumb_aggressively),
|
||||
value = Settings::preloadThumbAggressively,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.predictive_nav_anim),
|
||||
value = Settings.predictiveNavAnim::value,
|
||||
)
|
||||
IntSliderPreference(
|
||||
maxValue = 5,
|
||||
minValue = 1,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,6 +47,7 @@ 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
|
||||
@@ -84,12 +85,12 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
val touristMode = stringResource(id = R.string.settings_eh_identity_cookies_tourist)
|
||||
val guestMode = stringResource(id = R.string.settings_eh_identity_cookies_guest)
|
||||
val copiedToClipboard = stringResource(id = R.string.copied_to_clipboard)
|
||||
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
|
||||
Preference(
|
||||
title = stringResource(id = R.string.account_name),
|
||||
summary = Settings.displayName ?: touristMode,
|
||||
summary = Settings.displayName ?: guestMode,
|
||||
) {
|
||||
val eCookies = EhCookieStore.load(Url(EhUrl.HOST_E))
|
||||
val exCookies = EhCookieStore.load(Url(EhUrl.HOST_EX))
|
||||
@@ -114,11 +115,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) launchSnackBar(copiedToClipboard)
|
||||
}
|
||||
} else {
|
||||
setMessage(touristMode)
|
||||
setMessage(guestMode)
|
||||
}
|
||||
setPositiveButton(R.string.settings_eh_sign_out) { _, _ ->
|
||||
EhUtils.signOut()
|
||||
navigator.navigate(SignInScreenDestination)
|
||||
navigator.popNavigate(SignInScreenDestination)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
+2
-5
@@ -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()
|
||||
},
|
||||
),
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -78,5 +78,9 @@ object Crash {
|
||||
it.printStackTrace()
|
||||
file.delete()
|
||||
}
|
||||
if (t is OutOfMemoryError) {
|
||||
val dumpFile = File(dir, "hprof-$nowString.hprof")
|
||||
Debug.dumpHprofData(dumpFile.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@ import kotlin.reflect.KMutableProperty0
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)
|
||||
fun <T> resettableLazy(initializer: () -> T) = ResettableLazy(initializer)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -19,177 +19,122 @@ 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 androidx.annotation.RequiresApi
|
||||
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) {
|
||||
val parent = parent as? RawFile
|
||||
|
||||
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 popParentCacheIfPresent() = apply {
|
||||
val present = parent?.cachePresent ?: false
|
||||
if (present) parent?.allChildren?.add(this)
|
||||
}
|
||||
|
||||
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()) {
|
||||
popParentCacheIfPresent()
|
||||
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) popParentCacheIfPresent()
|
||||
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(): Boolean {
|
||||
deleteContents(mFile)
|
||||
return mFile.delete()
|
||||
}
|
||||
override fun delete() = file.deleteRecursively()
|
||||
|
||||
override fun exists(): Boolean {
|
||||
return mFile.exists()
|
||||
}
|
||||
override fun exists() = file.exists()
|
||||
|
||||
override fun listFiles(): Array<UniFile>? {
|
||||
val files = mFile.listFiles() ?: return null
|
||||
return files.map { RawFile(this, it) }.toTypedArray()
|
||||
}
|
||||
override fun listFiles() = allChildren
|
||||
|
||||
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) = 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 = open(file, parseMode(mode))
|
||||
|
||||
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"
|
||||
}
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
override fun asImageSource() = ImageDecoder.createSource(file)
|
||||
}
|
||||
|
||||
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,36 @@
|
||||
*/
|
||||
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) 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 +52,109 @@ 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(): Boolean {
|
||||
return DocumentsContractApi19.exists(mContext, uri)
|
||||
}
|
||||
override fun exists() = DocumentsContractApi19.exists(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() = allChildren
|
||||
|
||||
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) = 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
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,16 @@
|
||||
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 android.provider.DocumentsContract
|
||||
import androidx.annotation.RequiresApi
|
||||
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 +175,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 +200,21 @@ 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")
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
open fun asImageSource() = ImageDecoder.createSource(appCtx.contentResolver, uri)
|
||||
|
||||
/**
|
||||
* 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 +238,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
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.network.interceptor
|
||||
|
||||
import java.io.IOException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Catches any uncaught exceptions from later in the chain and rethrows as a non-fatal
|
||||
* IOException to avoid catastrophic failure.
|
||||
*
|
||||
* This should be the first interceptor in the client.
|
||||
*
|
||||
* See [Interceptor](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-interceptor/index.html)
|
||||
*/
|
||||
class UncaughtExceptionInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) {
|
||||
throw e
|
||||
} else {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,12 +46,14 @@ 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.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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
|
||||
@@ -84,6 +84,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
|
||||
@@ -94,7 +95,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
|
||||
@@ -105,17 +106,20 @@ 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
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
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
|
||||
|
||||
@@ -135,7 +139,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()
|
||||
|
||||
@@ -143,36 +146,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?
|
||||
@@ -322,6 +296,12 @@ class ReaderActivity : EhActivity() {
|
||||
dialogState.Intercept()
|
||||
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
val sliderValueFlow = remember { MutableStateFlow(0) }
|
||||
LaunchedEffect(sliderValueFlow) {
|
||||
sliderValueFlow.drop(1).debounce(200).collect {
|
||||
moveToPageIndex(it)
|
||||
}
|
||||
}
|
||||
ReaderAppBars(
|
||||
visible = menuVisible,
|
||||
isRtl = isRtl,
|
||||
@@ -330,7 +310,8 @@ class ReaderActivity : EhActivity() {
|
||||
totalPages = totalPage,
|
||||
onSliderValueChange = {
|
||||
isScrollingThroughPages = true
|
||||
moveToPageIndex(it)
|
||||
sliderValueFlow.value = it
|
||||
currentPage = it + 1
|
||||
},
|
||||
onClickSettings = { readerSettingSheetDialog?.show() },
|
||||
)
|
||||
@@ -357,7 +338,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
|
||||
@@ -397,7 +389,7 @@ class ReaderActivity : EhActivity() {
|
||||
}
|
||||
val file = mGalleryProvider!!.save(
|
||||
page,
|
||||
UniFile.fromFile(dir)!!,
|
||||
dir.asUniFile(),
|
||||
mGalleryProvider!!.getImageFilenameWithExtension(page),
|
||||
)
|
||||
if (file == null) {
|
||||
@@ -455,7 +447,7 @@ class ReaderActivity : EhActivity() {
|
||||
}
|
||||
val file = mGalleryProvider!!.save(
|
||||
page,
|
||||
UniFile.fromFile(dir)!!,
|
||||
dir.asUniFile(),
|
||||
mGalleryProvider!!.getImageFilenameWithExtension(page),
|
||||
)
|
||||
if (file == null) {
|
||||
@@ -523,7 +515,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) {
|
||||
@@ -560,7 +552,7 @@ class ReaderActivity : EhActivity() {
|
||||
}
|
||||
val file = mGalleryProvider!!.save(
|
||||
page,
|
||||
UniFile.fromFile(dir)!!,
|
||||
dir.asUniFile(),
|
||||
mGalleryProvider!!.getImageFilenameWithExtension(page),
|
||||
)
|
||||
if (file == null) {
|
||||
@@ -572,12 +564,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,7 +747,7 @@ class ReaderActivity : EhActivity() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun dismiss() = it.cancel()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheetFix(
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dismiss() },
|
||||
sheetState = sheetState,
|
||||
windowInsets = WindowInsets(0),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -44,17 +44,22 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
|
||||
}
|
||||
binding.wideColorGamut.bindToPreference(readerPreferences.wideColorGamut())
|
||||
|
||||
val activity = context as ReaderActivity
|
||||
|
||||
readerPreferences.cropBorders().asHotFlow {
|
||||
activity.restartGalleryProvider()
|
||||
}.launchIn(activity.lifecycleScope)
|
||||
|
||||
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(activity) {
|
||||
if (window.colorMode != colorMode) {
|
||||
window.colorMode = colorMode
|
||||
mGalleryProvider?.restart()
|
||||
setGallery()
|
||||
restartGalleryProvider()
|
||||
}
|
||||
}
|
||||
}.launchIn((context as ReaderActivity).lifecycleScope)
|
||||
}.launchIn(activity.lifecycleScope)
|
||||
} else {
|
||||
binding.wideColorGamut.isVisible = false
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
}
|
||||
@@ -254,8 +254,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
},
|
||||
)
|
||||
|
||||
val bitmap = (image as BitmapDrawable).bitmap
|
||||
setImage(bitmap)
|
||||
setImage(image as BitmapDrawable)
|
||||
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 })
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ class PagerPageHolder(
|
||||
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
errorLayout?.actionRetry?.viewer = viewer
|
||||
errorLayout?.actionRetry?.setOnClickListener {
|
||||
viewer.activity.mGalleryProvider?.retryPage(page.index)
|
||||
viewer.activity.retryPage(page.index)
|
||||
}
|
||||
}
|
||||
page.errorMsg?.let { errorLayout!!.errorMessage.text = it }
|
||||
|
||||
@@ -276,11 +276,15 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
moveToNext()
|
||||
}
|
||||
|
||||
override fun refreshAdapter() {
|
||||
refreshAdapter(pager.currentItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the adapter in order to recreate all the views. Used when a image configuration is
|
||||
* changed.
|
||||
*/
|
||||
private fun refreshAdapter(currentItem: Int = pager.currentItem) {
|
||||
private fun refreshAdapter(currentItem: Int) {
|
||||
adapter.refresh()
|
||||
pager.adapter = adapter
|
||||
pager.setCurrentItem(currentItem, false)
|
||||
|
||||
@@ -38,9 +38,6 @@ class WebtoonConfig(
|
||||
val theme = readerPreferences.readerTheme().get()
|
||||
|
||||
init {
|
||||
// readerPreferences.cropBordersWebtoon()
|
||||
// .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
readerPreferences.webtoonSidePadding()
|
||||
.register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() })
|
||||
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ class WebtoonPageHolder(
|
||||
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), frame, true)
|
||||
errorLayout?.root?.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, defaultHeight)
|
||||
errorLayout?.actionRetry?.setOnClickListener {
|
||||
viewer.activity.mGalleryProvider?.retryPage(page!!.index)
|
||||
viewer.activity.retryPage(page!!.index)
|
||||
}
|
||||
}
|
||||
page?.errorMsg?.let { errorLayout!!.errorMessage.text = it }
|
||||
|
||||
@@ -259,11 +259,15 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
||||
return false
|
||||
}
|
||||
|
||||
override fun refreshAdapter() {
|
||||
refreshAdapter(layoutManager.findLastEndVisibleItemPosition())
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies adapter of changes around the current page to trigger a relayout in the recycler.
|
||||
* Used when an image configuration is changed.
|
||||
*/
|
||||
private fun refreshAdapter(position: Int = layoutManager.findLastEndVisibleItemPosition()) {
|
||||
private fun refreshAdapter(position: Int) {
|
||||
val positionStart = maxOf(0, position - 3)
|
||||
val positionEnd = minOf(position + 3, adapter.itemCount - 1)
|
||||
adapter.refresh()
|
||||
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user