Compare commits

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

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

* Cleanup

* Cleanup

* Cleanup

---------

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

This reverts commit 0333a8c679.

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

* Make ImageSource sealed

---------

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

* Unify decode with coil

* Unify decode with coil

* Add AnimatedImageDecoderDecoder

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-01-02 06:48:15 +00:00
FooIbar 3bd12a3047 Fix typo 2024-01-01 22:40:27 +08:00
FooIbar c99d8e8275 [Marshmallow] Support crop image borders 2024-01-01 20:18:00 +08:00
FooIbar 4a12e71877 Fix crash in comment screen after leaving the app 2024-01-01 17:36:53 +08:00
GG Bond 3043901b5a Resolve #579, Resolve #581 2024-01-01 15:25:49 +08:00
renovate[bot] 7cc28955d6 Update dependency com.mikepenz.aboutlibraries.plugin to v10.10.0 2023-12-31 13:20:37 +00:00
FooIbar ef44bb4857 Prepare for next iteration 2023-12-31 19:50:55 +08:00
60 changed files with 476 additions and 1041 deletions

No files matched your search

+2 -2
View File
@@ -17,11 +17,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 17
- name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
+2 -2
View File
@@ -15,11 +15,11 @@ jobs:
with:
submodules: 'recursive'
- name: Setup Java 17
- name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
+26 -2
View File
@@ -1,5 +1,29 @@
# 变更日志
## [1.10.3] - 2024-01-15
### 新功能
* [Marshmallow] 支持裁剪边缘、打开压缩包和 GIF
### 改进
* 由于部分设备上的非预期行为 [#578](https://github.com/FooIbar/EhViewer/issues/578) [#596](https://github.com/FooIbar/EhViewer/issues/596),默认禁用预见式返回导航动画,可于 设置-高级 中开启
* 杂项优化
### Bug 修复
* 刷新后页面无响应 [#579](https://github.com/FooIbar/EhViewer/issues/579)
* 浏览中保存图片后点击操作被阻挡 [#581](https://github.com/FooIbar/EhViewer/issues/581)
* 在画廊详情页面切换至后台后点击评论闪退 [#580](https://github.com/FooIbar/EhViewer/issues/580)
* 已下载的画廊中未阅读缓存过的图片解码失败 [#592](https://github.com/FooIbar/EhViewer/issues/592)
* 在收藏页多选并下载会使得下载状态出现问题 [#603](https://github.com/FooIbar/EhViewer/issues/603)
* 未能在不重启情况下切换 Http 引擎 [#610](https://github.com/FooIbar/EhViewer/issues/610)
* 在有进度的画廊阅读界面无法直接回到第一页 [#614](https://github.com/FooIbar/EhViewer/issues/614)
* 请求新闻界面失效 [#605](https://github.com/FooIbar/EhViewer/issues/605)
* 收藏夹为空解析失败 [#630](https://github.com/FooIbar/EhViewer/issues/630)
## [1.10.2] - 2023-12-31
### 新功能
@@ -73,7 +97,7 @@
### 改进
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* [阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
* 优化原图下载重试策略
* 优化小屏设备上的搜索类别布局
* 优化过渡动画
@@ -91,7 +115,7 @@
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* [Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
* 修复已舍弃画廊上传者解析
### 已知问题
+1 -1
View File
@@ -73,7 +73,7 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
| OSS | FOSS dependencies** |
| GMS | Proprietary GMS component |
\* Archives and GIFs are not supported
\* May not support some archives
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
+4 -8
View File
@@ -64,8 +64,8 @@ android {
applicationId = "moe.tarsin.ehviewer"
minSdk = 28
targetSdk = 34
versionCode = 180049
versionName = "1.10.2"
versionCode = 180050
versionName = "1.10.3"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -100,11 +100,6 @@ android {
minSdk = 23
applicationIdSuffix = ".m"
versionNameSuffix = "-M"
externalNativeBuild {
cmake {
targets += "noop"
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
@@ -246,6 +241,7 @@ dependencies {
implementation(libs.diff)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.insetter) // Dead Dependency
@@ -278,7 +274,7 @@ configurations.all {
}
kotlin {
jvmToolchain(17)
jvmToolchain(21)
}
ksp {
-2
View File
@@ -78,5 +78,3 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
target_link_libraries(${PROJECT_NAME} archive_static log jnigraphics)
add_custom_target("noop")
+7 -5
View File
@@ -86,6 +86,8 @@ const char supportExt[10][6] = {
};
static inline int filename_is_playable_file(const char *name) {
if (!name)
return false;
const char *dotptr = strrchr(name, '.');
if (!dotptr++)
return false;
@@ -105,7 +107,7 @@ static inline int compare_entries(const void *a, const void *b) {
static long archive_map_entries_index(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
const char *name = archive_entry_pathname(ctx->entry);
const char *name = archive_entry_pathname_utf8(ctx->entry);
if (filename_is_playable_file(name)) {
entries[count].filename = strdup(name);
entries[count].index = count;
@@ -164,7 +166,7 @@ static void mempool_release_pages(void *addr, size_t size) {
static long archive_list_all_entries(archive_ctx *ctx) {
long count = 0;
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK)
if (filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (filename_is_playable_file(archive_entry_pathname_utf8(ctx->entry)))
count++;
if (!count)
LOGE("%s", archive_error_string(ctx->arc));
@@ -212,7 +214,7 @@ static int archive_alloc_ctx(archive_ctx **ctxptr) {
static int archive_skip_to_index(archive_ctx *ctx, int index) {
while (archive_read_next_header(ctx->arc, &ctx->entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(ctx->entry)))
if (!filename_is_playable_file(archive_entry_pathname_utf8(ctx->entry)))
continue;
if (ctx->next_index++ == index) {
return ctx->next_index - 1;
@@ -431,7 +433,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
archive_alloc_ctx(&ctx);
void *tmpBuf = alloca(4096);
while (archive_read_next_header(ctx->arc, &entry) == ARCHIVE_OK) {
if (!filename_is_playable_file(archive_entry_pathname(entry)))
if (!filename_is_playable_file(archive_entry_pathname_utf8(entry)))
continue;
if (!archive_entry_is_encrypted(entry))
continue;
@@ -456,7 +458,7 @@ Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
ret = archive_get_ctx(&ctx, index);
if (ret)
return NULL;
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname(ctx->entry));
jstring str = (*env)->NewStringUTF(env, archive_entry_pathname_utf8(ctx->entry));
ctx->using = 0;
return str;
}
@@ -1,132 +0,0 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.
*
* ![Snackbar image](https://developer.android.com/images/reference/androidx/compose/material3/snackbar.png)
*
* 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) },
)
}
@@ -29,6 +29,7 @@ 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
@@ -55,6 +56,8 @@ 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
@@ -246,10 +249,15 @@ public fun NavHost(
val zIndices = remember { mutableMapOf<String, Float>() }
if (backStackEntry != null) {
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
val transitionState = remember { SeekableTransitionState(backStackEntry) }
val transition = rememberTransition(transitionState, label = "entry")
LaunchedEffect(backStackEntry) {
transitionState.animateTo(backStackEntry)
val transition = if (enablePredictiveBack) {
LaunchedEffect(backStackEntry) {
transitionState.animateTo(backStackEntry)
}
rememberTransition(transitionState, label = "entry")
} else {
updateTransition(backStackEntry, label = "entry")
}
var inPredictiveBack by remember { mutableStateOf(false) }
@@ -9,7 +9,6 @@ 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
@@ -231,10 +230,10 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
*
* @param bitmap Image source.
*/
fun setImage(bitmap: BitmapDrawable) {
fun setImage(bitmap: Bitmap, rect: Rect) {
reset(true)
srcRect = Rect(bitmap.bounds)
onImageLoaded(bitmap.bitmap)
srcRect = Rect(rect)
onImageLoaded(bitmap)
}
/**
@@ -29,9 +29,10 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.coroutineScope
import coil3.SingletonImageLoader
import coil3.asCoilImage
import coil3.decode.ImageDecoderDecoder
import coil3.fetch.NetworkFetcher
import coil3.gif.AnimatedImageDecoder
import coil3.network.NetworkFetcher
import coil3.request.crossfade
import coil3.serviceLoaderEnabled
import coil3.util.DebugLogger
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhTagDatabase
@@ -52,12 +53,12 @@ import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.delegateLazy
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastS
import com.hippo.ehviewer.util.isCronetAvailable
import com.hippo.ehviewer.util.resettableLazy
import 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
@@ -101,9 +102,7 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
handler?.uncaughtException(t, e)
}
super.onCreate()
if (isAtLeastP) {
System.loadLibrary("ehviewer")
}
System.loadLibrary("ehviewer")
System.loadLibrary("ehviewer_rust")
ReadableTime.initialize(this)
lifecycleScope.launchIO {
@@ -177,18 +176,20 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
galleryDetailCache.evictAll()
// Keep one entry for GalleryCommentsScreen
galleryDetailCache.trimToSize(1)
}
}
override fun newImageLoader(context: Context) = context.imageLoader {
components {
add(NetworkFetcher.Factory(unsafeLazy { ktorClient }))
if (isAtLeastP) {
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
}
serviceLoaderEnabled(false)
add(NetworkFetcher.Factory(delegateLazy { ktorClient }))
add(MergeInterceptor)
add(DownloadThumbInterceptor)
if (isAtLeastP) {
add(AnimatedImageDecoder.Factory(false))
}
}
diskCache(imageCache)
crossfade(300)
@@ -109,6 +109,7 @@ 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_2", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
var downloadScheme by stringOrNullPref("image_scheme", null)
@@ -148,15 +149,15 @@ object Settings : DataStorePreferences(null) {
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
var downloadOriginImage by boolPref("download_origin_image", false)
var enableCronet by boolPref("enable_cronet", true)
var enableCronet by boolPref("enable_cronet", true).observed { updateWhenEnableCronetChanges() }
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
@@ -164,7 +165,6 @@ object Settings : DataStorePreferences(null) {
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastDawnTime by longPref("last_dawn_time", 0)
var lastUpdateTime by longPref("last_update_time", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
@@ -3,6 +3,8 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
@@ -43,6 +45,13 @@ fun updateWhenKeepMediaStatusChanges() {
}
}
fun updateWhenEnableCronetChanges() {
collectScope.launchIO {
ktorClient = ktorClient.apply { close() }
noRedirectKtorClient = noRedirectKtorClient.apply { close() }
}
}
fun updateWhenThemeChanges() {
collectScope.launch {
delay(100) // Avoid recompose being cancelled
@@ -36,7 +36,7 @@ object EhCookieStore : CookiesStorage {
val cookies = load(Url(EhUrl.HOST_E))
cookies.fastForEach {
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
manager.setCookie(EhUrl.HOST_EX, it.toString())
manager.setCookie(EhUrl.HOST_EX, it.copy(maxAge = Int.MAX_VALUE).toString())
}
}
}
@@ -69,7 +69,7 @@ object EhCookieStore : CookiesStorage {
val checkTips = EhUrl.DOMAIN_E in url.host
return cookies?.let {
parseClientCookiesHeader(cookies)
.map { Cookie(it.key, it.value, maxAge = Int.MAX_VALUE) }
.map { Cookie(it.key, it.value) }
.filterTo(mutableListOf()) { it.name != KEY_UTMP_NAME }.apply {
if (checkTips) {
removeAll { it.name == KEY_CONTENT_WARNING }
@@ -50,6 +50,7 @@ import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.dailycheck.showEventNotification
import com.hippo.ehviewer.dailycheck.today
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.StatusCodeException
@@ -68,7 +69,6 @@ import java.nio.channels.FileChannel
import kotlin.math.ceil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonArray
@@ -131,6 +131,12 @@ fun rethrowExactly(code: Int, body: Either<String, ByteBuffer>, e: Throwable): N
if (e is ParseException || e is SerializationException) {
body.onLeft { if ("<" !in it) throw EhException(it) }
when (val message = e.cause?.message) {
"No hits found!" -> throw EhException(R.string.gallery_list_empty_hit)
"No watched tags!" -> throw EhException(R.string.gallery_list_empty_hit_subscription)
"Not logged in!" -> throw NotLoggedInException()
is String -> if (message.startsWith("Your IP address")) throw EhException(message)
}
if (Settings.saveParseErrorBody) body.saveParseError(e)
throw EhException(appCtx.getString(R.string.error_parse_error), e)
}
@@ -217,7 +223,7 @@ object EhEngine {
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsText {
val eventPane = EventPaneParser.parse(this)
if (eventPane != null) {
Settings.lastDawnTime = Clock.System.now().epochSeconds
Settings.lastDawnDays = today
showEventNotification(eventPane)
}
GalleryDetailParser.parse(this)
@@ -1,6 +1,5 @@
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
@@ -22,7 +21,6 @@ object FavoritesParser {
return runSuspendCatching {
unmarshalParsingAs<FavParserResult>(body, ::parseFav)
}.getOrElse {
if (it is RuntimeException && it.message == "Not logged in!") throw NotLoggedInException()
throw ParseException("Parse favorites error", it)
}
}
@@ -1,11 +1,9 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
@@ -14,20 +12,9 @@ import moe.tarsin.coroutines.runSuspendCatching
object GalleryListParser {
suspend fun parse(body: ByteBuffer) = runSuspendCatching {
unmarshalParsingAs<GalleryListResult>(body, ::parseGalleryInfoList)
}.onFailure {
if (it is RuntimeException) {
if (it.message?.startsWith("Your IP address") == true) {
throw EhException(it.message)
}
if (it.message == "No watched tags!") {
throw EhException(R.string.gallery_list_empty_hit_subscription)
}
if (it.message == "No hits found!") {
throw EhException(R.string.gallery_list_empty_hit)
}
}
}.getOrElse {
throw ParseException("Can't parse gallery list", it)
}.getOrThrow().apply {
}.apply {
galleryInfoList.onEach {
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
it.favoriteSlot = LOCAL_FAVORITED
@@ -5,9 +5,9 @@ import android.graphics.drawable.ColorDrawable
import coil3.asCoilImage
import coil3.decode.DecodeResult
import coil3.decode.Decoder
import coil3.network.httpHeaders
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.httpHeaders
import coil3.size.Size
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.CHROME_ACCEPT
@@ -28,7 +28,6 @@ import java.time.Duration
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
@@ -48,18 +47,12 @@ class DailyCheckWork(context: Context, workerParams: WorkerParameters) :
}
}
val schedHour
get() = Settings.requestNewsTimerHour.takeUnless { it == -1 } ?: 0
val schedMinute
get() = Settings.requestNewsTimerMinute.takeUnless { it == -1 } ?: 0
private fun getDailyCheckWorkRequest(): PeriodicWorkRequest {
val now = Clock.System.now()
val timeZone = TimeZone.currentSystemDefault()
val whenToWork = LocalDateTime(
now.toLocalDateTime(timeZone).date,
LocalTime(schedHour, schedMinute),
LocalTime.fromSecondOfDay(Settings.requestNewsTime),
).toInstant(timeZone)
val initialDelay = (whenToWork - now).run { if (isNegative()) plus(1.days) else this }
val constraints = Constraints.Builder()
@@ -77,7 +70,7 @@ fun updateDailyCheckWork(context: Context) {
if (Settings.requestNews) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
getDailyCheckWorkRequest(),
)
} else {
@@ -85,12 +78,13 @@ fun updateDailyCheckWork(context: Context) {
}
}
val today
get() = (Clock.System.now().epochSeconds / 86400).toInt()
suspend fun checkDawn() = runCatching {
val now = Clock.System.now()
val last = Instant.fromEpochSeconds(Settings.lastDawnTime)
if (EhCookieStore.hasSignedIn() && now > last + 1.days) {
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
EhEngine.getNews(true)?.let {
Settings.lastDawnTime = now.epochSeconds
Settings.lastDawnDays = today
showEventNotification(it)
}
}
@@ -54,6 +54,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import splitties.preferences.edit
object DownloadManager : OnSpiderListener {
@@ -83,6 +85,8 @@ object DownloadManager : OnSpiderListener {
private val mutableNotifyFlow = MutableSharedFlow<DownloadInfo>(extraBufferCapacity = 1)
val notifyFlow = mutableNotifyFlow.asSharedFlow()
private val mutex = Mutex()
init {
assertNotMainThread()
map = runAssertingNotMainThread {
@@ -134,35 +138,37 @@ object DownloadManager : OnSpiderListener {
}
private suspend fun ensureDownload() {
if (mCurrentTask != null) {
// Only one download
return
}
mutex.withLock {
if (mCurrentTask != null) {
// Only one download
return
}
// Get download from wait list
if (!mWaitList.isEmpty()) {
val info = mWaitList.removeFirst()
val spider = SpiderQueen.obtainSpiderQueen(info, SpiderQueen.MODE_DOWNLOAD)
mCurrentTask = info
mCurrentSpider = spider
spider.addOnSpiderListener(this)
info.state = DownloadInfo.STATE_DOWNLOAD
info.speed = -1
info.remaining = -1
info.total = -1
info.finished = 0
info.downloaded = 0
info.legacy = -1
// Update in DB
EhDB.putDownloadInfo(info)
// Start speed count
mSpeedReminder.start()
// Notify start downloading
if (mDownloadListener != null) {
mDownloadListener!!.onStart(info)
// Get download from wait list
if (!mWaitList.isEmpty()) {
val info = mWaitList.removeFirst()
val spider = SpiderQueen.obtainSpiderQueen(info, SpiderQueen.MODE_DOWNLOAD)
mCurrentTask = info
mCurrentSpider = spider
spider.addOnSpiderListener(this)
info.state = DownloadInfo.STATE_DOWNLOAD
info.speed = -1
info.remaining = -1
info.total = -1
info.finished = 0
info.downloaded = 0
info.legacy = -1
// Update in DB
EhDB.putDownloadInfo(info)
// Start speed count
mSpeedReminder.start()
// Notify start downloading
if (mDownloadListener != null) {
mDownloadListener!!.onStart(info)
}
// Notify state update
mutableNotifyFlow.emit(info)
}
// Notify state update
mutableNotifyFlow.tryEmit(info)
}
}
@@ -183,7 +189,7 @@ object DownloadManager : OnSpiderListener {
// Update in DB
EhDB.putDownloadInfo(info)
// Notify state update
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Make sure download is running
ensureDownload()
}
@@ -213,7 +219,7 @@ object DownloadManager : OnSpiderListener {
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Make sure download is running
ensureDownload()
@@ -234,7 +240,7 @@ object DownloadManager : OnSpiderListener {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
mWaitList.addAll(updateList)
ensureDownload()
@@ -270,7 +276,7 @@ object DownloadManager : OnSpiderListener {
updateList.onEach {
it.state = DownloadInfo.STATE_WAIT
EhDB.putDownloadInfo(it)
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
mWaitList.addAll(updateList)
ensureDownload()
@@ -329,14 +335,14 @@ object DownloadManager : OnSpiderListener {
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
}
suspend fun stopDownload(gid: Long) {
val info = stopDownloadInternal(gid)
if (info != null) {
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
}
@@ -346,7 +352,7 @@ object DownloadManager : OnSpiderListener {
val info = stopCurrentDownloadInternal()
if (info != null) {
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Ensure download
ensureDownload()
}
@@ -357,7 +363,7 @@ object DownloadManager : OnSpiderListener {
// Update listener
gidList.mapNotNull { mAllInfoMap[it] }.forEach {
mutableNotifyFlow.tryEmit(it)
mutableNotifyFlow.emit(it)
}
// Ensure download
@@ -365,19 +371,22 @@ object DownloadManager : OnSpiderListener {
}
suspend fun stopAllDownload() {
// Stop all in wait list
for (info in mWaitList) {
info.state = DownloadInfo.STATE_NONE
// Update in DB
EhDB.putDownloadInfo(info)
mutex.withLock {
// Stop all in wait list
for (info in mWaitList) {
info.state = DownloadInfo.STATE_NONE
// Update in DB
EhDB.putDownloadInfo(info)
// Notify
mutableNotifyFlow.tryEmit(info)
// Notify
mutableNotifyFlow.emit(info)
}
mWaitList.clear()
// Stop current
val info = stopCurrentDownloadInternal()
info?.let { mutableNotifyFlow.emit(it) }
}
mWaitList.clear()
// Stop current
stopCurrentDownloadInternal()
}
suspend fun deleteDownload(gid: Long, deleteFiles: Boolean = false) {
@@ -398,7 +407,7 @@ object DownloadManager : OnSpiderListener {
if (index >= 0) {
list.remove(info)
// Update listener
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
}
}
@@ -421,7 +430,7 @@ object DownloadManager : OnSpiderListener {
allInfoList.removeAll(list.toSet())
// Update listener
list.forEach { mutableNotifyFlow.tryEmit(it) }
list.forEach { mutableNotifyFlow.emit(it) }
// Ensure download
ensureDownload()
@@ -434,8 +443,7 @@ object DownloadManager : OnSpiderListener {
val list = allInfoList.slice(range)
val limit = allInfoList.size - 1
list.zip(range).forEach { it.first.position = limit - it.second }
val label = allInfoList[fromPosition].label
getInfoListForLabel(label)!!.sortByPositionDescending()
getInfoListForLabel(info.label)!!.sortByPositionDescending()
return list
}
@@ -815,7 +823,9 @@ object DownloadManager : OnSpiderListener {
Log.e(TAG, "Current task is null, but it should not be")
} else {
info.total = mPages
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -844,7 +854,9 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onGetPage(info)
}
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -857,7 +869,9 @@ object DownloadManager : OnSpiderListener {
info.finished = mFinished
info.downloaded = mDownloaded
info.total = mTotal
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
}
@@ -896,7 +910,7 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onFinish(info)
}
mutableNotifyFlow.tryEmit(info)
mutableNotifyFlow.emit(info)
// Start next download
ensureDownload()
}
@@ -982,7 +996,9 @@ object DownloadManager : OnSpiderListener {
if (mDownloadListener != null) {
mDownloadListener!!.onDownload(info)
}
mutableNotifyFlow.tryEmit(info)
launchIO {
mutableNotifyFlow.emit(info)
}
}
mBytesRead = 0
if (!mStop) {
@@ -13,17 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.gallery
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.RequiresApi
import com.hippo.ehviewer.Settings.archivePasswds
import com.hippo.ehviewer.image.ByteBufferSource
import com.hippo.ehviewer.image.Image
import com.hippo.ehviewer.jni.closeArchive
import com.hippo.ehviewer.jni.extractToByteBuffer
@@ -110,7 +107,7 @@ class ArchivePageLoader(context: Context, private val uri: Uri, passwdProvider:
val buffer = extractToByteBuffer(index)
buffer ?: return
check(buffer.isDirect)
val src = object : Image.ByteBufferSource {
val src = object : ByteBufferSource {
override val source: ByteBuffer = buffer
override fun close() {
@@ -19,107 +19,86 @@ package com.hippo.ehviewer.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ColorSpace
import android.graphics.ImageDecoder
import android.graphics.ImageDecoder.ImageInfo
import android.graphics.ImageDecoder.Source
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.graphics.drawable.toDrawable
import coil3.decode.BitmapFactoryDecoder
import coil3.decode.DecodeUtils
import coil3.decode.ImageSource
import coil3.request.Options
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import coil3.BitmapImage
import coil3.DrawableImage
import coil3.Image as CoilImage
import coil3.asCoilImage
import coil3.executeBlocking
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ErrorResult
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.request.colorSpace
import coil3.size.Precision
import coil3.size.Scale
import coil3.size.Size
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.jni.isGif
import com.hippo.ehviewer.jni.mmap
import com.hippo.ehviewer.jni.munmap
import com.hippo.ehviewer.jni.rewriteGifSource
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.util.isAtLeastO
import com.hippo.ehviewer.util.isAtLeastP
import com.hippo.ehviewer.util.isAtLeastQ
import com.hippo.ehviewer.util.isAtLeastU
import com.hippo.unifile.UniFile
import com.hippo.unifile.openInputStream
import java.nio.ByteBuffer
import kotlin.math.min
import okio.FileSystem
import okio.buffer
import okio.source
import splitties.init.appCtx
private val CropAspect = 0.1..100.0
private const val CROP_THRESHOLD = 0.75f
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
class Image private constructor(image: CoilImage, private val src: AutoCloseable) {
val size = image.size
private val intrinsicRect = image.run { Rect(0, 0, width, height) }
val rect: Rect = if (Settings.cropBorder.value && image is BitmapImage) {
val array = detectBorder(image.bitmap)
val r = Rect(array[0], array[1], array[2], array[3])
val minWidth = image.width * CROP_THRESHOLD
val minHeight = image.height * CROP_THRESHOLD
if (r.width() > minWidth && r.height() > minHeight) r else intrinsicRect
} else {
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
}
intrinsicRect
}
val final = if (isAtLeastQ) {
// Upload to Graphical Buffer to accelerate render
bitmap.copy(Bitmap.Config.HARDWARE, false).apply {
bitmap.recycle()
}
} else {
bitmap
}
var innerImage: CoilImage? = if (image is BitmapImage && isAtLeastQ) {
val bitmap = image.bitmap
final.toDrawable(appCtx.resources).apply { bounds = rect }
} else {
drawable.apply { bounds = intrinsicRect }
}
// Upload to Graphical Buffer to accelerate render
bitmap.copy(Bitmap.Config.HARDWARE, false).apply {
bitmap.recycle()
}.asCoilImage()
} else {
image
}
@Synchronized
fun recycle() {
(mObtainedDrawable as? Animatable)?.stop()
(mObtainedDrawable as? BitmapDrawable)?.bitmap?.recycle()
mObtainedDrawable?.callback = null
if (mObtainedDrawable is Animatable) src.close()
mObtainedDrawable = null
when (val image = innerImage ?: return) {
is DrawableImage -> {
(image.drawable as Animatable).stop()
image.drawable.callback = null
src.close()
}
is BitmapImage -> image.bitmap.recycle()
}
innerImage = null
}
companion object {
@RequiresApi(Build.VERSION_CODES.P)
fun calculateSampleSize(info: ImageInfo, targetHeight: Int, targetWeight: Int): Int {
return min(
info.size.width / targetWeight,
info.size.height / targetHeight,
).coerceAtLeast(1)
}
private val imageSearchMaxSize = appCtx.resources.getDimensionPixelOffset(R.dimen.image_search_max_size)
@delegate:RequiresApi(Build.VERSION_CODES.P)
val imageSearchDecoderSampleListener by lazy {
ImageDecoder.OnHeaderDecodedListener { decoder, info, _ ->
decoder.setTargetSampleSize(
calculateSampleSize(info, imageSearchMaxSize, imageSearchMaxSize),
)
}
}
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 3 / 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 3 / 2
private val targetWidth = appCtx.resources.displayMetrics.widthPixels * 2
private val targetHeight = appCtx.resources.displayMetrics.heightPixels * 2
@delegate:RequiresApi(Build.VERSION_CODES.O)
val isWideColorGamut by lazy { appCtx.resources.configuration.isScreenWideColorGamut }
@@ -127,63 +106,60 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
@RequiresApi(Build.VERSION_CODES.O)
lateinit var colorSpace: ColorSpace
suspend fun decode(src: AutoCloseable): Image? {
private suspend fun Either<ByteBufferSource, UniFileSource>.decodeCoil(): CoilImage {
val req = appCtx.imageRequest {
onLeft { data(it.source) }
onRight { data(it.source.uri) }
size(targetWidth, targetHeight)
scale(Scale.FILL)
precision(Precision.INEXACT)
if (isAtLeastO) {
colorSpace(colorSpace)
}
if (Settings.cropBorder.value) {
allowHardware(false)
}
memoryCachePolicy(CachePolicy.DISABLED)
}
return when (val result = appCtx.imageLoader.execute(req)) {
is SuccessResult -> result.image
is ErrorResult -> throw result.throwable
}
}
suspend fun decode(src: ImageSource): Image? {
return runCatching {
when (src) {
is UniFileSource -> {
if (isAtLeastP) {
if (!isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
val buffer = mmap(fd)!!
val source = object : ByteBufferSource {
override val source = buffer
override fun close() {
munmap(buffer)
src.close()
}
if (isAtLeastP && !isAtLeastU) {
src.source.openFileDescriptor("rw").use {
val fd = it.fd
if (isGif(fd)) {
val buffer = mmap(fd)!!
val source = object : ByteBufferSource {
override val source = buffer
override fun close() {
munmap(buffer)
src.close()
}
return decode(source)
}
return decode(source)
}
}
val drawable = decodeDrawable(src.source.asImageSource())
if (drawable !is Animatable) src.close()
Image(drawable, src)
} else {
val options = Options(
appCtx,
size = Size(targetWidth, targetHeight),
scale = Scale.FILL,
allowInexactSize = true,
)
val drawable = ImageSource(
src.source.openInputStream().source().buffer(),
FileSystem.SYSTEM,
).use {
BitmapFactoryDecoder(it, options).decode().image.asDrawable(appCtx.resources)
}
src.close()
Image(drawable, src)
}
val image = src.right().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
}
is ByteBufferSource -> {
if (isAtLeastP) {
if (!isAtLeastU) {
rewriteGifSource(src.source)
}
val source = ImageDecoder.createSource(src.source)
val drawable = decodeDrawable(source)
if (drawable !is Animatable) src.close()
Image(drawable, src)
} else {
TODO("Unsupported")
if (isAtLeastP && !isAtLeastU) {
rewriteGifSource(src.source)
}
val image = src.left().decodeCoil()
if (image is BitmapImage) src.close()
Image(image, src)
}
else -> TODO("Unsupported")
}
}.onFailure {
src.close()
@@ -191,49 +167,30 @@ class Image private constructor(drawable: Drawable, private val src: AutoCloseab
}.getOrNull()
}
@RequiresApi(Build.VERSION_CODES.P)
private fun decodeDrawable(src: Source) = ImageDecoder.decodeDrawable(src) { decoder, info, _ ->
if (!isAtLeastQ || Settings.cropBorder.value) {
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
fun Context.decodeBitmap(uri: Uri): Bitmap {
val req = imageRequest {
memoryCachePolicy(CachePolicy.DISABLED)
data(uri)
size(imageSearchMaxSize)
scale(Scale.FILL)
}
decoder.setTargetColorSpace(colorSpace)
decoder.setTargetSampleSize(calculateSampleSize(info, targetHeight, targetWidth))
}
fun Context.decodeBitmap(uri: Uri): Bitmap? = if (isAtLeastP) {
val src = ImageDecoder.createSource(contentResolver, uri)
ImageDecoder.decodeBitmap(src, imageSearchDecoderSampleListener)
} else {
contentResolver.openFileDescriptor(uri, "r")!!.use {
val options = BitmapFactory.Options()
// Disable these since we straight up compress the bitmap to JPEG
options.inScaled = false
options.inPremultiplied = false
options.inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(it.fileDescriptor, null, options)
options.inJustDecodeBounds = false
options.inSampleSize = DecodeUtils.calculateInSampleSize(
options.outWidth,
options.outHeight,
imageSearchMaxSize,
imageSearchMaxSize,
Scale.FILL,
)
BitmapFactory.decodeFileDescriptor(it.fileDescriptor, null, options)
val image = when (val result = appCtx.imageLoader.executeBlocking(req)) {
is SuccessResult -> result.image
is ErrorResult -> throw result.throwable
}
return (image as BitmapImage).bitmap
}
}
}
interface UniFileSource : AutoCloseable {
val source: UniFile
}
sealed interface ImageSource : AutoCloseable
interface ByteBufferSource : AutoCloseable {
val source: ByteBuffer
}
interface UniFileSource : ImageSource {
val source: UniFile
}
interface ByteBufferSource : ImageSource {
val source: ByteBuffer
}
private external fun detectBorder(bitmap: Bitmap): IntArray
@@ -1,9 +1,5 @@
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.jni
import android.os.Build
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
external fun releaseByteBuffer(buffer: ByteBuffer)
@@ -1,9 +1,5 @@
@file:RequiresApi(Build.VERSION_CODES.P)
package com.hippo.ehviewer.jni
import android.os.Build
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
external fun isGif(fd: Int): Boolean
@@ -24,7 +24,7 @@ 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.image.Image.UniFileSource
import com.hippo.ehviewer.image.UniFileSource
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
@@ -212,7 +212,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
MODE_READ -> mReadReference++
MODE_DOWNLOAD -> mDownloadReference++
}
check(mDownloadReference <= 1) { "mDownloadReference can't more than 0" }
check(mDownloadReference <= 1) { "mDownloadReference can't more than 1" }
}
private fun clearMode(@Mode mode: Int) {
@@ -562,9 +562,11 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}.onFailure {
if (it is CancellationException) {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
if (it.message != FORCE_RETRY) {
updatePageState(index, STATE_FAILED, "Cancelled")
if (mReadReference > 0) {
Log.d(WORKER_DEBUG_TAG, "Download image $index cancelled")
if (it.message != FORCE_RETRY) {
updatePageState(index, STATE_FAILED, "Cancelled")
}
}
throw it
}
@@ -602,19 +604,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
}
val previousPToken: String?
val pToken: String?
val pToken: String
pTokenLock.withLock {
// Non-local return will not release the lock
// https://github.com/Kotlin/kotlinx.coroutines/issues/3985
pToken = getPToken(index)
if (pToken == null) {
updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE)
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
?: return updatePageState(index, STATE_FAILED, PTOKEN_FAILED_MESSAGE).also {
mSpiderInfo.pTokenMap[index] = TOKEN_FAILED
}
previousPToken = getPToken(index - 1)
}
pToken ?: return
delayLock.withLock {
delay(mDownloadDelay - lastRequestTime.elapsedNow())
@@ -689,7 +687,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
}
checkNotNull(targetImageUrl)
repeat(3) { times ->
repeat(2) { times ->
runCatching {
Log.d(WORKER_DEBUG_TAG, "Start download image $index attempt #$times")
val success = withTimeout(downloadTimeout) {
@@ -59,7 +59,6 @@ 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
@@ -341,7 +340,7 @@ class MainActivity : EhActivity() {
LocalDrawerLockHandle provides lockDrawerHandle,
LocalSnackbarHostState provides snackbarState,
) {
Scaffold(snackbarHost = { SnackbarHost(snackbarState) { Snackbar2(it) } }) {
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) {
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
ModalNavigationDrawer(
drawerContent = {
@@ -96,10 +96,10 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
fun storeCookie(id: String, hash: String, igneous: String) {
EhUtils.signOut()
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_MEMBER_ID, id, EhUrl.DOMAIN_EX)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_E)
EhCookieStore.addCookie(EhCookieStore.KEY_IPB_PASS_HASH, hash, EhUrl.DOMAIN_EX)
if (igneous.isNotBlank()) EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
if (igneous.isNotBlank() && igneous != "mystery") {
EhCookieStore.addCookie(EhCookieStore.KEY_IGNEOUS, igneous, EhUrl.DOMAIN_EX)
}
}
fun login() {
@@ -161,7 +161,7 @@ fun CookieSignInScene(navigator: DestinationsNavigator) {
coroutineScope.launch { snackbarHostState.showSnackbar(noCookies) }
return
}
if (kvs.size < 3) {
if (kvs.size < 2) {
coroutineScope.launch { snackbarHostState.showSnackbar(noCookies) }
return
}
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.login
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -59,11 +58,6 @@ fun WebViewSignInScreen(navigator: DestinationsNavigator) {
}
SideEffect {
EhUtils.signOut()
CookieManager.getInstance().apply {
flush()
removeAllCookies(null)
removeSessionCookies(null)
}
}
WebView(
state = state,
@@ -151,7 +151,7 @@ fun SearchFilter(
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.first.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
@@ -163,7 +163,7 @@ fun SearchFilter(
)
Text(text = stringResource(id = R.string.search_sp_to))
OutlinedTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
value = expectedValue.second.takeIf { it > 0 }?.toString().orEmpty(),
onValueChange = {
expectedValue =
@@ -49,7 +49,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,12 +62,10 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.core.view.SoftwareKeyboardControllerCompat
import com.hippo.ehviewer.EhApplication.Companion.searchDatabase
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -237,17 +234,9 @@ fun SearchBarScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
if (isAtLeastP) {
// Workaround IME not hiding after performing search
// https://github.com/FooIbar/EhViewer/pull/412
val view = LocalView.current
val keyboardManager = remember { SoftwareKeyboardControllerCompat(view) }
SideEffect {
if (!active) keyboardManager.hide()
}
} else {
if (!isAtLeastP) {
// Workaround unable to exit search field
// https://android.googlesource.com/platform/frameworks/support/+/8f81d46e2c3f37564d0b7515ec9411314645e187
// https://issuetracker.google.com/issues/318968220
Box(Modifier.size(1.dp).focusable())
}
@@ -36,8 +36,6 @@ 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
@@ -147,11 +145,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
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,
@@ -162,7 +155,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
selected = !enableCronet,
onClick = {
enableCronet = false
reset()
},
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
@@ -172,7 +164,6 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
selected = enableCronet,
onClick = {
enableCronet = true
reset()
},
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
enabled = isCronetAvailable,
@@ -188,6 +179,10 @@ 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,
@@ -39,9 +39,6 @@ import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.dailycheck.schedHour
import com.hippo.ehviewer.dailycheck.schedMinute
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
import com.hippo.ehviewer.ui.destinations.FilterScreenDestination
import com.hippo.ehviewer.ui.destinations.MyTagsScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
@@ -59,6 +56,7 @@ import io.ktor.http.Url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
import moe.tarsin.coroutines.runSuspendCatching
@Destination
@@ -325,10 +323,9 @@ fun EhScreen(navigator: DestinationsNavigator) {
val pickerTitle = stringResource(id = R.string.settings_eh_request_news_timepicker)
Preference(title = pickerTitle) {
coroutineScope.launch {
val (hour, minute) = dialogState.showTimePicker(pickerTitle, schedHour, schedMinute)
Settings.requestNewsTimerHour = hour
Settings.requestNewsTimerMinute = minute
updateDailyCheckWork(context)
val time = LocalTime.fromSecondOfDay(Settings.requestNewsTime)
val (hour, minute) = dialogState.showTimePicker(pickerTitle, time.hour, time.minute)
Settings.requestNewsTime = LocalTime(hour, minute).toSecondOfDay()
}
}
}
@@ -1,9 +1,11 @@
package com.hippo.ehviewer.ui.settings
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -11,9 +13,13 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
import com.hippo.ehviewer.ui.openBrowser
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.m3.LibraryDefaults
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -21,6 +27,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@Composable
fun LicenseScreen(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
@@ -33,7 +40,15 @@ fun LicenseScreen(navigator: DestinationsNavigator) {
scrollBehavior = scrollBehavior,
)
},
) {
LibrariesContainer(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), contentPadding = it)
) { paddingValues ->
LibrariesContainer(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
contentPadding = paddingValues,
colors = LibraryDefaults.libraryColors(badgeBackgroundColor = MaterialTheme.colorScheme.tertiary),
padding = LibraryDefaults.libraryPadding(badgeContentPadding = PaddingValues(4.dp)),
onLibraryClick = { library ->
library.library.website?.let { context.openBrowser(it) }
},
)
}
}
@@ -1,46 +0,0 @@
package com.hippo.ehviewer.ui.webview
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.util.setDefaultSettings
@Composable
fun WebViewScreen(url: String, onNavigateUp: () -> Unit) {
val state = rememberWebViewState(url = url)
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = state.pageTitle ?: url) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
}
},
)
},
) { paddingValues ->
WebView(
state = state,
modifier = Modifier.padding(paddingValues).fillMaxSize(),
onCreated = { it.setDefaultSettings() },
)
DisposableEffect(Unit) {
onDispose {
EhCookieStore.flush()
}
}
}
}
@@ -7,6 +7,12 @@ import kotlin.reflect.KProperty
fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer)
fun <T> resettableLazy(initializer: () -> T) = ResettableLazy(initializer)
fun <T> delegateLazy(initializer: () -> T) = object : kotlin.Lazy<T> {
override val value: T
get() = initializer()
override fun isInitialized() = true
}
interface Lazy<T> {
var value: T
@@ -15,22 +15,19 @@
*/
package com.hippo.unifile
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
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.util.Locale
class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
val parent = parent as? RawFile
private val parent = parent as? RawFile
var cachePresent = false
private var cachePresent = false
private val allChildren by lazy {
val current = this
cachePresent = true
@@ -41,9 +38,12 @@ class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
}
}
private fun popParentCacheIfPresent() = apply {
val present = parent?.cachePresent ?: false
if (present) parent?.allChildren?.add(this)
private fun popCacheIfPresent(file: RawFile) = apply {
if (cachePresent) {
synchronized(allChildren) {
allChildren.add(file)
}
}
}
override fun createFile(displayName: String): UniFile? {
@@ -88,7 +88,7 @@ class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
override fun ensureDir(): Boolean {
if (file.isDirectory) return true
if (file.mkdirs()) {
popParentCacheIfPresent()
parent?.popCacheIfPresent(this)
return true
}
return false
@@ -98,7 +98,7 @@ class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
if (file.exists()) return file.isFile
parent?.ensureDir()
val success = file.createNewFile()
if (success) popParentCacheIfPresent()
if (success) parent?.popCacheIfPresent(this)
return success
}
@@ -108,9 +108,13 @@ class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
override fun exists() = file.exists()
override fun listFiles() = allChildren
override fun listFiles() = synchronized(allChildren) {
allChildren.toList()
}
override fun findFirst(filter: (String) -> Boolean) = allChildren.firstOrNull { filter(it.name) }
override fun findFirst(filter: (String) -> Boolean) = synchronized(allChildren) {
allChildren.firstOrNull { filter(it.name) }
}
override fun renameTo(displayName: String): Boolean {
val target = File(file.parentFile, displayName)
@@ -122,9 +126,6 @@ class RawFile(parent: UniFile?, private var file: File) : UniFile(parent) {
}
override fun openFileDescriptor(mode: String): ParcelFileDescriptor = open(file, parseMode(mode))
@RequiresApi(Build.VERSION_CODES.P)
override fun asImageSource() = ImageDecoder.createSource(file)
}
private fun getTypeForName(name: String): String {
@@ -36,7 +36,11 @@ class TreeDocumentFile(
}
private fun popCacheIfPresent(file: TreeDocumentFile) = apply {
if (cachePresent) allChildren.add(file)
if (cachePresent) {
synchronized(allChildren) {
allChildren.add(file)
}
}
}
constructor(parent: UniFile?, uri: Uri) : this(parent, uri, getFilenameForUri(uri))
@@ -135,9 +139,13 @@ class TreeDocumentFile(
override fun exists() = DocumentsContractApi19.exists(uri)
override fun listFiles() = allChildren
override fun listFiles() = synchronized(allChildren) {
allChildren.toList()
}
override fun findFirst(filter: (String) -> Boolean) = allChildren.firstOrNull { filter(it.filename) }
override fun findFirst(filter: (String) -> Boolean) = synchronized(allChildren) {
allChildren.firstOrNull { filter(it.filename) }
}
override fun renameTo(displayName: String): Boolean {
val result = DocumentsContractApi21.renameTo(appCtx, uri, displayName)
@@ -17,11 +17,8 @@ package com.hippo.unifile
import android.content.ContentResolver
import android.content.Intent
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import androidx.core.provider.DocumentsContractCompat
import java.io.File
@@ -206,9 +203,6 @@ abstract class UniFile internal constructor(private val parent: 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.
*
@@ -1,72 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.ui.openBrowser
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.util.withJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Displays all provided libraries in a simple list.
*/
@Composable
fun LibrariesContainer(
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
librariesBlock: (Context) -> Libs = { context ->
Libs.Builder().withJson(context, R.raw.aboutlibraries).build()
},
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
itemSpacing: Dp = LibraryDefaults.LibraryItemSpacing,
header: (LazyListScope.() -> Unit)? = null,
onLibraryClick: ((Library) -> Unit)? = null,
) {
val context = LocalContext.current
val libraries = produceState<Libs?>(null) {
value = withContext(Dispatchers.IO) {
librariesBlock(context)
}
}
val libs = libraries.value?.libraries?.sortedBy { it.name.lowercase() }
if (libs != null) {
Libraries(
libraries = libs,
modifier = modifier,
lazyListState = lazyListState,
contentPadding = contentPadding,
showAuthor = showAuthor,
showVersion = showVersion,
showLicenseBadges = showLicenseBadges,
padding = padding,
itemContentPadding = itemContentPadding,
itemSpacing = itemSpacing,
header = header,
onLibraryClick = { library ->
if (onLibraryClick != null) {
onLibraryClick.invoke(library)
} else {
library.website?.let { context.openBrowser(it) }
}
},
)
}
}
@@ -1,266 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose
import android.widget.TextView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.ui.compose.util.author
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
/**
* Displays all provided libraries in a simple list.
*/
@Composable
fun Libraries(
libraries: List<Library>,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
itemSpacing: Dp = LibraryDefaults.LibraryItemSpacing,
header: (LazyListScope.() -> Unit)? = null,
onLibraryClick: ((Library) -> Unit)? = null,
) {
LazyColumn(
modifier,
verticalArrangement = Arrangement.spacedBy(itemSpacing),
state = lazyListState,
contentPadding = contentPadding,
) {
header?.invoke(this)
libraryItems(
libraries,
showAuthor,
showVersion,
showLicenseBadges,
padding,
itemContentPadding,
) {
onLibraryClick?.invoke(it)
}
}
}
internal inline fun LazyListScope.libraryItems(
libraries: List<Library>,
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding,
itemContentPadding: PaddingValues = LibraryDefaults.ContentPadding,
crossinline onLibraryClick: ((Library) -> Unit),
) {
items(libraries) { library ->
Library(
library,
showAuthor,
showVersion,
showLicenseBadges,
padding,
itemContentPadding,
) {
onLibraryClick.invoke(library)
}
}
}
@Composable
internal fun Library(
library: Library,
showAuthor: Boolean = true,
showVersion: Boolean = true,
showLicenseBadges: Boolean = true,
padding: LibraryPadding = LibraryDefaults.libraryPadding(),
contentPadding: PaddingValues = LibraryDefaults.ContentPadding,
typography: Typography = MaterialTheme.typography,
onClick: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() }.padding(contentPadding)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = library.name,
modifier = Modifier.padding(padding.namePadding).weight(1f),
style = typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val version = library.artifactVersion
if (version != null && showVersion) {
Text(
version,
modifier = Modifier.padding(padding.versionPadding),
style = typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
val author = library.author
if (showAuthor && author.isNotBlank()) {
Text(
text = author,
style = typography.bodyMedium,
)
}
var openDialog by remember { mutableStateOf(false) }
if (openDialog) {
LicenseDialog(library = library) {
openDialog = false
}
}
if (showLicenseBadges && library.licenses.isNotEmpty()) {
Row {
library.licenses.forEach {
SuggestionChip(
onClick = { openDialog = true },
label = { Text(text = it.name) },
)
}
}
}
}
}
@Composable
fun LicenseDialog(
library: Library,
onDismiss: () -> Unit,
) {
val scrollState = rememberScrollState()
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(id = android.R.string.ok))
}
},
text = {
Column(modifier = Modifier.verticalScroll(scrollState)) {
HtmlText(html = library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty())
}
},
)
}
@Composable
fun HtmlText(
html: String,
modifier: Modifier = Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context -> TextView(context) },
update = { it.text = html.parseAsHtml(flags = HtmlCompat.FROM_HTML_MODE_COMPACT) },
)
}
/**
* Contains the default values used by [Library]
*/
object LibraryDefaults {
private val LibraryItemPadding = 16.dp
private val LibraryNamePaddingTop = 4.dp
private val LibraryVersionPaddingStart = 8.dp
private val LibraryBadgePaddingTop = 8.dp
private val LibraryBadgePaddingEnd = 4.dp
internal val LibraryItemSpacing = 0.dp
/**
* The default content padding used by [Library]
*/
val ContentPadding = PaddingValues(LibraryItemPadding)
/**
* Creates a [LibraryPadding] that represents the default paddings used in a [Library]
*
* @param namePadding the padding around the name shown as part of a [Library]
* @param versionPadding the padding around the version shown as part of a [Library]
* @param badgePadding the padding around a badge element shown as part of a [Library]
* @param badgeContentPadding the padding around the content of a badge element shown as part of a [Library]
*/
@Composable
fun libraryPadding(
namePadding: PaddingValues = PaddingValues(top = LibraryNamePaddingTop),
versionPadding: PaddingValues = PaddingValues(start = LibraryVersionPaddingStart),
badgePadding: PaddingValues = PaddingValues(
top = LibraryBadgePaddingTop,
end = LibraryBadgePaddingEnd,
),
badgeContentPadding: PaddingValues = PaddingValues(4.dp),
): LibraryPadding = DefaultLibraryPadding(
namePadding = namePadding,
versionPadding = versionPadding,
badgePadding = badgePadding,
badgeContentPadding = badgeContentPadding,
)
}
/**
* Represents the padding values used in a library.
*/
@Stable
interface LibraryPadding {
/** Represents the padding around the name shown as part of a [Library] */
val namePadding: PaddingValues
/** Represents the padding around the version shown as part of a [Library] */
val versionPadding: PaddingValues
/** Represents the padding around a badge element shown as part of a [Library] */
val badgePadding: PaddingValues
/** Represents the padding around the content of a badge element shown as part of a [Library] */
val badgeContentPadding: PaddingValues
}
/**
* Default [LibraryPadding].
*/
@Immutable
private class DefaultLibraryPadding(
override val namePadding: PaddingValues,
override val versionPadding: PaddingValues,
override val badgePadding: PaddingValues,
override val badgeContentPadding: PaddingValues,
) : LibraryPadding
@@ -1,13 +0,0 @@
package com.mikepenz.aboutlibraries.ui.compose.util
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.entity.License
val Library.author: String
get() = developers.takeIf { it.isNotEmpty() }?.map { it.name }?.joinToString(", ") ?: organization?.name ?: ""
val License.htmlReadyLicenseContent: String?
get() = licenseContent?.replace("\n", "<br />")
val License.strippedLicenseContent: String?
get() = licenseContent?.replace("<br />", "\n")?.replace("<br/>", "\n")
@@ -47,14 +47,12 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.graphics.BlendMode
@@ -170,42 +168,38 @@ class ReaderActivity : EhActivity() {
if (ACTION_EH == mAction) {
mGalleryInfo?.let { mGalleryProvider = EhPageLoader(it) }
} else if (Intent.ACTION_VIEW == mAction) {
if (isAtLeastP) {
if (mUri != null) {
try {
grantUriPermission(
BuildConfig.APPLICATION_ID,
mUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (e: Exception) {
Toast.makeText(this, R.string.error_reading_failed, Toast.LENGTH_SHORT).show()
}
if (mUri != null) {
try {
grantUriPermission(
BuildConfig.APPLICATION_ID,
mUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
} catch (e: Exception) {
Toast.makeText(this, R.string.error_reading_failed, Toast.LENGTH_SHORT).show()
}
mGalleryProvider = ArchivePageLoader(
appCtx,
mUri!!,
) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) {
if (it.isBlank()) {
getString(R.string.passwd_cannot_be_empty)
} else if (invalidator(it)) {
null
} else {
getString(R.string.passwd_wrong)
}
mGalleryProvider = ArchivePageLoader(
appCtx,
mUri!!,
) { invalidator ->
runCatching {
dialogState.awaitInputText(
title = getString(R.string.archive_need_passwd),
hint = getString(R.string.archive_passwd),
) {
if (it.isBlank()) {
getString(R.string.passwd_cannot_be_empty)
} else if (invalidator(it)) {
null
} else {
getString(R.string.passwd_wrong)
}
}.onFailure {
finish()
}.getOrThrow()
}
}
}.onFailure {
finish()
}.getOrThrow()
}
} else {
Toast.makeText(this, "Archives are not supported before Android P", Toast.LENGTH_LONG).show()
}
}
}
@@ -296,7 +290,7 @@ class ReaderActivity : EhActivity() {
dialogState.Intercept()
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val sliderValueFlow = remember { MutableStateFlow(0) }
val sliderValueFlow = remember { MutableStateFlow(-1) }
LaunchedEffect(sliderValueFlow) {
sliderValueFlow.drop(1).debounce(200).collect {
moveToPageIndex(it)
@@ -744,17 +738,11 @@ class ReaderActivity : EhActivity() {
fun onPageLongTap(page: ReaderPage) {
lifecycleScope.launch {
dialogState.dialog {
val coroutineScope = rememberCoroutineScope()
fun dismiss() = it.cancel()
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dismiss() },
sheetState = sheetState,
onDismissRequest = { it.cancel() },
windowInsets = WindowInsets(0),
) {
ReaderPageSheet(page) {
coroutineScope.launch { sheetState.hide() }
}
ReaderPageSheet(page) { it.cancel() }
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@@ -844,6 +832,10 @@ class ReaderActivity : EhActivity() {
.onEach { setGallery() }
.launchIn(lifecycleScope)
ReaderPreferences.cropBorders().changes()
.onEach { restartGalleryProvider() }
.launchIn(lifecycleScope)
ReaderPreferences.readerTheme().changes()
.onEach { theme ->
binding.readerContainer.setBackgroundResource(
@@ -19,7 +19,7 @@ abstract class PageLoader {
} else {
(OSUtils.appMaxMemory / 3 * 2).toInt()
},
sizeOf = { _, v -> v.size },
sizeOf = { _, v -> v.size.toInt() },
onEntryRemoved = { _, _, o, _ -> o.recycle() },
)
val mPages by lazy {
@@ -37,6 +37,8 @@ class ReaderColorFilterSettings @JvmOverloads constructor(context: Context, attr
init {
addView(binding.root)
context as ReaderActivity
if (isAtLeastO) {
binding.wideColorGamut.isEnabled = Image.isWideColorGamut
if (!Image.isWideColorGamut) {
@@ -44,28 +46,20 @@ 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
with(activity) {
with(context) {
if (window.colorMode != colorMode) {
window.colorMode = colorMode
restartGalleryProvider()
}
}
}.launchIn(activity.lifecycleScope)
}.launchIn(context.lifecycleScope)
} else {
binding.wideColorGamut.isVisible = false
}
context as ReaderActivity
readerPreferences.colorFilter().changes()
.onEach { setColorFilter(it) }
.launchIn(context.lifecycleScope)
@@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Animatable
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.GestureDetector
@@ -18,10 +19,13 @@ import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.postDelayed
import androidx.core.view.isVisible
import coil3.BitmapImage
import coil3.DrawableImage
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.EASE_OUT_QUAD
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView.Companion.SCALE_TYPE_CENTER_INSIDE
import com.github.chrisbanes.photoview.PhotoView
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
import eu.kanade.tachiyomi.util.system.animatorDurationScale
@@ -120,14 +124,16 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
}
fun setImage(drawable: Drawable, config: Config) {
fun setImage(drawable: Image, config: Config) {
this.config = config
if (drawable is Animatable) {
val image = drawable.innerImage ?: return
if (image is DrawableImage) {
prepareAnimatedImageView()
setAnimatedImage(drawable, config)
} else {
setAnimatedImage(image.drawable, config)
}
if (image is BitmapImage) {
prepareNonAnimatedImageView()
setNonAnimatedImage(drawable, config)
setNonAnimatedImage(image.bitmap, drawable.rect, config)
}
}
@@ -233,7 +239,8 @@ open class ReaderPageImageView @JvmOverloads constructor(
}
private fun setNonAnimatedImage(
image: Drawable,
image: Bitmap,
bounds: Rect,
config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
@@ -254,7 +261,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
},
)
setImage(image as BitmapDrawable)
setImage(image, bounds)
isVisible = true
}
@@ -2,11 +2,11 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import androidx.core.view.isVisible
import coil3.BitmapImage
import com.hippo.ehviewer.databinding.ReaderErrorBinding
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
@@ -97,7 +97,7 @@ class PagerPageHolder(
setDownloading()
}
Page.State.READY -> {
page.image?.mObtainedDrawable?.let { setImage(it) }
page.image?.let { setImage(it) }
cancelProgressJob()
}
Page.State.ERROR -> {
@@ -148,12 +148,12 @@ class PagerPageHolder(
/**
* Called when the page is ready.
*/
private fun setImage(drawable: Drawable) {
private fun setImage(image: Image) {
progressIndicator.setProgress(0)
progressIndicator.hide()
errorLayout?.root?.isVisible = false
setImage(
drawable,
image,
Config(
zoomDuration = viewer.config.doubleTapAnimDuration,
minimumScaleType = viewer.config.imageScaleType,
@@ -162,7 +162,7 @@ class PagerPageHolder(
landscapeZoom = viewer.config.landscapeZoom,
),
)
if (drawable !is Animatable) {
if (image.innerImage is BitmapImage) {
pageBackground = background
}
}
@@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.content.res.Resources
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
@@ -10,6 +9,7 @@ import android.widget.FrameLayout
import androidx.core.view.isVisible
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.ehviewer.databinding.ReaderErrorBinding
import com.hippo.ehviewer.image.Image
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
@@ -145,7 +145,7 @@ class WebtoonPageHolder(
setDownloading()
}
Page.State.READY -> {
page?.image?.mObtainedDrawable?.let { setImage(it) }
page?.image?.let { setImage(it) }
cancelProgressJob()
}
Page.State.ERROR -> {
@@ -194,7 +194,7 @@ class WebtoonPageHolder(
/**
* Called when the page is ready.
*/
private fun setImage(drawable: Drawable) {
private fun setImage(drawable: Image) {
progressIndicator.setProgress(0)
progressIndicator.hide()
removeErrorLayout()
@@ -4,6 +4,7 @@ import arrow.core.memoize
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class NamedMutex<T> {
val innerMutexGetter = { _: T -> Mutex() }.memoize()
@@ -18,12 +19,5 @@ suspend inline fun <T, K> NamedMutex<K>.withLock(key: K, owner: Any? = null, act
innerMutexGetter(key)
}
mutex.run {
lock(owner)
try {
return action()
} finally {
unlock(owner)
}
}
return mutex.withLock(owner, action)
}
@@ -508,6 +508,7 @@
<string name="list_tile_thumb_size">详情模式下缩略图大小</string>
<string name="account_name">账户</string>
<string name="preload_thumb_aggressively">激进地预载缩略图</string>
<string name="predictive_nav_anim">[实验性] 预见式返回导航动画</string>
<string name="auto_updates">自动检查更新</string>
<string name="update_frequency_never">从不</string>
<string name="update_frequency_daily">每天</string>
+2 -1
View File
@@ -568,9 +568,10 @@
<string name="archive_need_passwd">Archive need password</string>
<string name="passwd_wrong">Password Wrong</string>
<string name="passwd_cannot_be_empty">Password can\'t be empty</string>
<string name="list_tile_thumb_size">Thumb size in thumb mode</string>
<string name="list_tile_thumb_size">Thumb size in detail mode</string>
<string name="account_name">Account</string>
<string name="preload_thumb_aggressively">Preload thumbs aggressively</string>
<string name="predictive_nav_anim">[Experimental] Predictive back navigation animations</string>
<string name="user_agent">User Agent</string>
<string name="auto_updates">Automatically check for updates</string>
<string name="update_frequency_never">Never</string>
+21 -24
View File
@@ -18,7 +18,7 @@ use log::LevelFilter;
use serde::Serialize;
use std::ffi::c_void;
use std::io::Cursor;
use std::ptr::{slice_from_raw_parts, slice_from_raw_parts_mut};
use std::ptr::slice_from_raw_parts_mut;
use std::str::from_utf8_unchecked;
use tl::{Bytes, Node, NodeHandle, Parser, VDom};
@@ -62,39 +62,36 @@ where
handle.get(parser)
}
fn parse_marshal_inplace<F, R>(env: &mut JNIEnv, str: JByteBuffer, limit: jint, mut f: F) -> i32
fn deref_direct_bytebuffer<'a>(env: &JNIEnv, buffer: &JByteBuffer) -> &'a mut [u8] {
let ptr = env.get_direct_buffer_address(buffer).unwrap();
let cap = env.get_direct_buffer_capacity(buffer).unwrap();
unsafe { &mut *slice_from_raw_parts_mut(ptr, cap) }
}
fn throw_msg(env: &mut JNIEnv, msg: &str) -> i32 {
env.throw_new("java/lang/RuntimeException", msg).ok();
0
}
fn parse_marshal_inplace<F, R>(env: &mut JNIEnv, str: JByteBuffer, limit: jint, f: F) -> i32
where
F: FnMut(&VDom, &Parser, &str) -> Result<R, &'static str>,
F: Fn(&VDom, &str) -> Result<R, &'static str>,
R: Serialize,
{
let ptr = env.get_direct_buffer_address(&str).unwrap();
let html = unsafe {
let buff = slice_from_raw_parts(ptr, limit as usize);
from_utf8_unchecked(&*buff)
};
let mut f = || {
let buffer = deref_direct_bytebuffer(env, &str);
let html = unsafe { from_utf8_unchecked(&buffer[..limit as usize]) };
let f = || {
let dom = tl::parse(html, tl::ParserOptions::default()).map_err(|_| html)?;
let parser = dom.parser();
f(&dom, parser, html)
f(&dom, html)
};
match f() {
// Nothing to marshal
Err(err) => {
env.throw_new("java/lang/RuntimeException", err).ok();
0
}
Err(err) => throw_msg(env, err),
Ok(value) => {
let mut cursor = unsafe {
let slice = slice_from_raw_parts_mut(ptr, 0x80000);
Cursor::new(&mut *slice)
};
let mut cursor = Cursor::new(buffer);
match serde_cbor::to_writer(&mut cursor, &value) {
Ok(_) => cursor.position() as i32,
Err(err) => {
env.throw_new("java/lang/RuntimeException", format!("{}", err))
.ok();
0
}
Err(err) => throw_msg(env, &format!("{}", err)),
}
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ fn parse_fav(dom: &VDom, parser: &Parser, html: &str) -> Result<FavResult, &'sta
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.client.parser.FavoritesParserKt")]
pub fn parseFav(mut env: JNIEnv, _class: JClass, input: JByteBuffer, limit: jint) -> jint {
parse_marshal_inplace(&mut env, input, limit, |dom, parser, html| {
parse_fav(dom, parser, html)
parse_marshal_inplace(&mut env, input, limit, |dom, html| {
parse_fav(dom, dom.parser(), html)
})
}
+2 -2
View File
@@ -33,7 +33,7 @@ fn parse_limit(dom: &VDom, parser: &Parser) -> Option<Limits> {
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.client.parser.HomeParserKt")]
pub fn parseLimit(mut env: JNIEnv, _class: JClass, input: JByteBuffer, limit: jint) -> jint {
parse_marshal_inplace(&mut env, input, limit, |dom, parser, _| {
parse_limit(dom, parser).ok_or("Can't parse Limit")
parse_marshal_inplace(&mut env, input, limit, |dom, _| {
parse_limit(dom, dom.parser()).ok_or("Can't parse Limit")
})
}
+2 -5
View File
@@ -194,9 +194,6 @@ pub fn parse_info_list(
parser: &Parser,
str: &str,
) -> Result<GalleryListResult, &'static str> {
if !str.contains('<') {
return Err("No content!");
}
if str.contains("<p>You do not have any watched tags") {
return Err("No watched tags!");
}
@@ -241,7 +238,7 @@ pub fn parse_info_list(
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.client.parser.GalleryListParserKt")]
pub fn parseGalleryInfoList(mut env: JNIEnv, _: JClass, buffer: JByteBuffer, limit: jint) -> jint {
parse_marshal_inplace(&mut env, buffer, limit, |dom, parser, str| {
parse_info_list(dom, parser, str)
parse_marshal_inplace(&mut env, buffer, limit, |dom, str| {
parse_info_list(dom, dom.parser(), str)
})
}
+2 -2
View File
@@ -44,7 +44,7 @@ fn parse_torrent_list(dom: &VDom, parser: &Parser) -> Result<Vec<Torrent>, &'sta
#[allow(non_snake_case)]
#[jni_fn("com.hippo.ehviewer.client.parser.TorrentParserKt")]
pub fn parseTorrent(mut env: JNIEnv, _class: JClass, buffer: JByteBuffer, limit: jint) -> jint {
parse_marshal_inplace(&mut env, buffer, limit, |dom, parser, _html| {
parse_torrent_list(dom, parser)
parse_marshal_inplace(&mut env, buffer, limit, |dom, _| {
parse_torrent_list(dom, dom.parser())
})
}
+1 -1
View File
@@ -72,7 +72,7 @@
| OSS | FOSS 依赖项** |
| GMS | 专有 GMS 组件 |
\* 不支持压缩包和 GIF
\* 可能不支持某些压缩包
** 在不支持 [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine) 的设备上回退到 OkHttp
+1 -1
View File
@@ -72,7 +72,7 @@
| OSS | FOSS 依賴項** |
| GMS | 專有 GMS 元件 |
\* 不支援壓縮包和 GIF
\* 可能不支援某些壓縮包
** 在不支援 [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine) 的裝置上回退到 OkHttp
+13 -12
View File
@@ -1,7 +1,7 @@
[versions]
aboutlibraries = "10.9.2"
agp = "8.2.0"
android-tools-r8 = "8.3.16-dev"
aboutlibraries = "10.10.0"
agp = "8.2.1"
android-tools-r8 = "8.3.31"
androidx-activity = "1.8.2"
androidx-appcompat = "1.6.1"
androidx-biometric = "1.2.0-alpha05"
@@ -9,20 +9,20 @@ androidx-browser = "1.7.0"
androidx-constraintlayout-compose = "1.1.0-alpha13"
androidx-core = "1.12.0"
androidx-core-splashscreen = "1.1.0-alpha02"
androidx-lifecycle = "2.7.0-rc02"
androidx-lifecycle = "2.7.0"
androidx-navigation = "2.7.6"
androidx-paging = "3.2.1"
androidx-recyclerview = "1.3.2"
androidx-room = "2.6.1"
androidx-work = "2.9.0"
arrow = "1.2.1"
coil = "3.0.0-alpha01"
compose-bom = "2023.12.00-alpha04"
compose-compiler = "1.5.6-dev-k2.0.0-Beta1-06a03be2b42"
compose-destinations = "1.9.56"
google-android-material = "1.12.0-alpha02"
kotlin = "2.0.0-Beta1"
kotlinx-coroutines = "1.7.3"
coil = "3.0.0-alpha02"
compose-bom = "2024.01.00-alpha01"
compose-compiler = "1.5.8-dev-k2.0.0-Beta2-99ed868a0f8"
compose-destinations = "1.9.60"
google-android-material = "1.12.0-alpha03"
kotlin = "2.0.0-Beta2"
kotlinx-coroutines = "1.8.0-RC2"
kotlinx-datetime = "0.5.0"
kotlinx-serialization = "1.6.2"
ksp = "2.0.0-Beta2-1.0.16"
@@ -34,6 +34,7 @@ splitties = "3.0.0"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" }
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
@@ -137,4 +138,4 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref =
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
rustAndroidPlugin = { id = "org.mozilla.rust-android-gradle.rust-android", version = "0.9.3" }
spotless = { id = "com.diffplug.spotless", version = "6.23.3" }
spotless = { id = "com.diffplug.spotless", version = "6.24.0" }
+1 -1
View File
@@ -14,7 +14,7 @@ dependencyResolutionManagement {
mavenCentral()
maven("https://jitpack.io")
maven("https://androidx.dev/storage/compose-compiler/repository/")
maven("https://androidx.dev/snapshots/builds/11260879/artifacts/repository")
maven("https://androidx.dev/snapshots/builds/11311962/artifacts/repository")
}
}