Parcourir la source

refactor: 移除 Hutool 依赖,改用 JCA 与纯 Kotlin 实现

- 加密/摘要/编码层改用 javax.crypto/java.security,新增 help/crypto/CryptoUtils.kt,移除 hutool-crypto 依赖
- Base64→kotlin.io.encoding、UUID→kotlin.uuid(含 nameUUIDFromBytes v3 兼容)、java.util.regex→kotlin.text.Regex、日期部分迁移 kotlinx-datetime
- 新增 CryptoCompatibilityTest 验证算法与旧实现逐字节一致
HapeLee il y a 2 mois
Parent
commit
7ca13c1c8c
79 fichiers modifiés avec 914 ajouts et 556 suppressions
  1. 1 1
      AGENTS.md
  2. 1 1
      CLAUDE.md
  3. 1 1
      app/build.gradle.kts
  4. 0 11
      app/proguard-rules.pro
  5. 0 2
      app/src/androidTest/java/io/legado/app/AndroidJsTest.kt
  6. 3 2
      app/src/main/java/io/legado/app/constant/AppConst.kt
  7. 7 9
      app/src/main/java/io/legado/app/constant/AppPattern.kt
  8. 1 2
      app/src/main/java/io/legado/app/data/entities/BaseSource.kt
  9. 4 4
      app/src/main/java/io/legado/app/data/entities/BookChapter.kt
  10. 3 3
      app/src/main/java/io/legado/app/data/entities/HighlightRule.kt
  11. 1 2
      app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt
  12. 2 2
      app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt
  13. 4 3
      app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt
  14. 4 4
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  15. 4 4
      app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt
  16. 11 9
      app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt
  17. 2 2
      app/src/main/java/io/legado/app/di/appModule.kt
  18. 2 2
      app/src/main/java/io/legado/app/domain/model/manga/MangaReaderSessionModels.kt
  19. 2 2
      app/src/main/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParser.kt
  20. 13 10
      app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt
  21. 2 2
      app/src/main/java/io/legado/app/domain/usecase/IdentifyBookCharactersUseCase.kt
  22. 2 2
      app/src/main/java/io/legado/app/domain/usecase/SaveBookContentProcessUseCase.kt
  23. 2 2
      app/src/main/java/io/legado/app/domain/usecase/SaveMarkingUseCase.kt
  24. 11 11
      app/src/main/java/io/legado/app/help/JsEncodeUtils.kt
  25. 21 22
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  26. 10 6
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  27. 22 28
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  28. 3 5
      app/src/main/java/io/legado/app/help/book/ContentHelp.kt
  29. 10 11
      app/src/main/java/io/legado/app/help/book/ContentProcessor.kt
  30. 6 6
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  31. 4 4
      app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt
  32. 77 28
      app/src/main/java/io/legado/app/help/crypto/AsymmetricCrypto.kt
  33. 57 0
      app/src/main/java/io/legado/app/help/crypto/CryptoUtils.kt
  34. 50 6
      app/src/main/java/io/legado/app/help/crypto/Sign.kt
  35. 83 21
      app/src/main/java/io/legado/app/help/crypto/SymmetricCryptoAndroid.kt
  36. 3 3
      app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt
  37. 2 2
      app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsFileSynthesizer.kt
  38. 2 2
      app/src/main/java/io/legado/app/help/readaloud/playback/VolcengineCloudTtsProvider.kt
  39. 4 3
      app/src/main/java/io/legado/app/help/storage/BackupAES.kt
  40. 6 9
      app/src/main/java/io/legado/app/help/storage/ImportOldData.kt
  41. 3 3
      app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt
  42. 52 3
      app/src/main/java/io/legado/app/lib/webdav/WebDav.kt
  43. 17 20
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt
  44. 53 61
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt
  45. 45 27
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  46. 4 4
      app/src/main/java/io/legado/app/model/analyzeRule/CustomUrl.kt
  47. 8 9
      app/src/main/java/io/legado/app/model/localBook/LocalBook.kt
  48. 19 24
      app/src/main/java/io/legado/app/model/localBook/TextFile.kt
  49. 8 8
      app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt
  50. 4 6
      app/src/main/java/io/legado/app/service/ExportBookService.kt
  51. 3 3
      app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt
  52. 6 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt
  53. 2 2
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt
  54. 3 3
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt
  55. 3 3
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailViewModel.kt
  56. 2 2
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt
  57. 2 2
      app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarImage.kt
  58. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt
  59. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  60. 4 4
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  61. 25 24
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  62. 8 4
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  63. 8 5
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt
  64. 2 2
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt
  65. 4 7
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  66. 5 9
      app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt
  67. 2 2
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt
  68. 2 2
      app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/replace/ReplaceEditRoute.kt
  70. 2 3
      app/src/main/java/io/legado/app/utils/FileUtils.kt
  71. 11 13
      app/src/main/java/io/legado/app/utils/HtmlFormatter.kt
  72. 4 12
      app/src/main/java/io/legado/app/utils/MD5Utils.kt
  73. 9 3
      app/src/main/java/io/legado/app/utils/NetworkUtils.kt
  74. 28 6
      app/src/main/java/io/legado/app/utils/StringExtensions.kt
  75. 7 16
      app/src/main/java/io/legado/app/utils/StringUtils.kt
  76. 11 0
      app/src/main/java/io/legado/app/utils/UuidExtensions.kt
  77. 96 0
      app/src/test/java/io/legado/app/help/crypto/CryptoCompatibilityTest.kt
  78. 1 1
      docs/dev/js.md
  79. 2 5
      gradle/libs.versions.toml

+ 1 - 1
AGENTS.md

@@ -376,7 +376,7 @@ Book sources, RSS sources, and HTTP TTS use JavaScript rules. `initRhino()` in `
 ## Important Constraints
 
 - **Do not update jsoup** beyond 1.16.2 — a breaking change in newer versions (see [jsoup#2017](https://github.com/jhy/jsoup/pull/2017)) affects `AnalyzeByJSoup.kt` and the JsoupXpath library
-- **Do not update hutool** beyond 5.8.22 — pinned in `libs.versions.toml:42`
+- Hutool dependency removed — crypto/编码/日期工具已替换为 JCA (`javax.crypto`/`java.security`) 与 `java.time`,新增内部工具在 `help/crypto/CryptoUtils.kt`
 - Package name discrepancy: code namespace is `io.legado.app` but `applicationId` is `io.legato.kazusa`
 - Min SDK 26, target SDK 37, compile SDK 37
 - Release builds enable R8 minification + resource shrinking; `noR8` variant disables both for crash debugging

+ 1 - 1
CLAUDE.md

@@ -368,7 +368,7 @@ Book sources, RSS sources, and HTTP TTS use JavaScript rules. `initRhino()` in `
 ## Important Constraints
 
 - **Do not update jsoup** beyond 1.16.2 — a breaking change in newer versions (see [jsoup#2017](https://github.com/jhy/jsoup/pull/2017)) affects `AnalyzeByJSoup.kt` and the JsoupXpath library
-- **Do not update hutool** beyond 5.8.22 — pinned in `libs.versions.toml:42`
+- Hutool dependency removed — crypto/编码/日期工具已替换为 JCA (`javax.crypto`/`java.security`) 与 `java.time`,新增内部工具在 `help/crypto/CryptoUtils.kt`
 - Package name discrepancy: code namespace is `io.legado.app` but `applicationId` is `io.legato.kazusa`
 - Min SDK 26, target SDK 37, compile SDK 37
 - Release builds enable R8 minification + resource shrinking; `noR8` variant disables both for crash debugging

+ 1 - 1
app/build.gradle.kts

@@ -255,7 +255,6 @@ dependencies {
     implementation(libs.markwon.ext.tables)
     implementation(libs.markwon.html)
     implementation(libs.quick.chinese.transfer.core)
-    implementation(libs.hutool.crypto)
     //noinspection GradleDependency
     implementation(platform(libs.firebase.bom))
     implementation(libs.firebase.analytics)
@@ -278,6 +277,7 @@ dependencies {
     implementation(libs.telephoto.zoomable.image.coil3)
     implementation(libs.accompanist.webview)
     implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.kotlinx.datetime)
     implementation(libs.androidx.lifecycle.viewmodel.compose)
     implementation(libs.androidx.lifecycle.viewmodel.navigation3)
     implementation(libs.androidx.compose.animation)

+ 0 - 11
app/proguard-rules.pro

@@ -75,17 +75,6 @@
 -keep class io.legado.app.data.repository.OpenAIResponse{*;}
 -keep class io.legado.app.data.repository.OpenAIChoice{*;}
 -keep class io.legado.app.data.repository.OpenAIMessage{*;}
-# hutool-core hutool-crypto
--keep class
-!cn.hutool.core.util.RuntimeUtil,
-!cn.hutool.core.util.ClassLoaderUtil,
-!cn.hutool.core.util.ReflectUtil,
-!cn.hutool.core.util.SerializeUtil,
-!cn.hutool.core.util.ClassUtil,
-cn.hutool.core.codec.**,
-cn.hutool.core.util.**{*;}
--keep class cn.hutool.crypto.**{*;}
--dontwarn cn.hutool.**
 # 缓存 Cookie
 -keep class **.help.http.CookieStore{*;}
 -keep class **.help.CacheManager{*;}

+ 0 - 2
app/src/androidTest/java/io/legado/app/AndroidJsTest.kt

@@ -1,6 +1,5 @@
 package io.legado.app
 
-import cn.hutool.core.lang.JarClassLoader
 import com.script.ScriptBindings
 import com.script.rhino.RhinoScriptEngine
 import dalvik.system.DexClassLoader
@@ -54,7 +53,6 @@ class AndroidJsTest {
     fun testPackages1() {
         URLClassLoader.getSystemClassLoader()
         DefiningClassLoader.getSystemClassLoader()
-        JarClassLoader.getSystemClassLoader()
         DexClassLoader.getSystemClassLoader()
         @Language("js")
         val js = """

+ 3 - 2
app/src/main/java/io/legado/app/constant/AppConst.kt

@@ -4,8 +4,9 @@ import android.annotation.SuppressLint
 import android.content.pm.PackageManager
 import android.provider.Settings
 import androidx.annotation.Keep
-import cn.hutool.crypto.digest.DigestUtil
 import io.legado.app.BuildConfig
+import io.legado.app.help.crypto.digest
+import io.legado.app.help.crypto.toHexString
 import io.legado.app.help.update.AppVariant
 import org.apache.commons.lang3.time.FastDateFormat
 import splitties.init.appCtx
@@ -81,7 +82,7 @@ object AppConst {
     private val sha256Signature: String by lazy {
         val packageInfo =
             appCtx.packageManager.getPackageInfo(appCtx.packageName, PackageManager.GET_SIGNATURES)
-        DigestUtil.sha256Hex(packageInfo.signatures!![0].toByteArray()).uppercase()
+        digest("SHA-256", packageInfo.signatures!![0].toByteArray()).toHexString().uppercase()
     }
 
     private val isOfficial = sha256Signature == OFFICIAL_SIGNATURE

+ 7 - 9
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -1,17 +1,15 @@
 package io.legado.app.constant
 
-import java.util.regex.Pattern
-
 @Suppress("RegExpRedundantEscape", "unused")
 object AppPattern {
-    val JS_PATTERN: Pattern =
-        Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
-    val WebJS_PATTERN: Pattern =
-        Pattern.compile("@webjs:([\\w\\W]{5,})", Pattern.CASE_INSENSITIVE)
-    val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
+    val JS_PATTERN: Regex =
+        Regex("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", RegexOption.IGNORE_CASE)
+    val WebJS_PATTERN: Regex =
+        Regex("@webjs:([\\w\\W]{5,})", RegexOption.IGNORE_CASE)
+    val EXP_PATTERN: Regex = Regex("\\{\\{([\\w\\W]*?)\\}\\}")
 
     //匹配格式化后的图片格式
-    val imgPattern: Pattern = Pattern.compile("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
+    val imgPattern: Regex = Regex("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
 
     //匹配自定义html格式字符串
     val useHtmlRegex = Regex("<usehtml>.*?</usehtml>", RegexOption.DOT_MATCHES_ALL) //.包含换行
@@ -39,7 +37,7 @@ object AppPattern {
     val fileNameRegex = Regex("[\\\\/:*?\"<>|.]")
     val fileNameRegex2 = Regex("[\\\\/:*?\"<>|]")
     val splitGroupRegex = Regex("[,;,;]")
-    val titleNumPattern: Pattern = Pattern.compile("(第)(.+?)(章)")
+    val titleNumPattern: Regex = Regex("(第)(.+?)(章)")
 
     //书源调试信息中的各种符号
     val debugMessageSymbolRegex = Regex("[⇒◇┌└≡]")

+ 1 - 2
app/src/main/java/io/legado/app/data/entities/BaseSource.kt

@@ -1,7 +1,6 @@
 package io.legado.app.data.entities
 
 import android.webkit.JavascriptInterface
-import cn.hutool.crypto.symmetric.AES
 import com.script.ScriptBindings
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
@@ -170,7 +169,7 @@ interface BaseSource : JsExtensions {
         try {
             val key = AppConst.androidId.encodeToByteArray(0, 16)
             val cache = CacheManager.get("userInfo_${getKey()}") ?: return null
-            return AES(key).decryptStr(cache)
+            return SymmetricCryptoAndroid("AES", key).decryptStr(cache)
         } catch (e: Exception) {
             AppLog.put("获取登陆信息出错", e)
             return null

+ 4 - 4
app/src/main/java/io/legado/app/data/entities/BookChapter.kt

@@ -147,13 +147,13 @@ data class BookChapter(
     fun getAbsoluteURL(): String {
         //二级目录解析的卷链接为空 返回目录页的链接
         if (url.startsWith(title) && isVolume) return baseUrl
-        val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
-        val urlBefore = if (urlMatcher.find()) url.substring(0, urlMatcher.start()) else url
+        val urlMatch = AnalyzeUrl.paramPattern.find(url)
+        val urlBefore = if (urlMatch != null) url.substring(0, urlMatch.range.first) else url
         val urlAbsoluteBefore = NetworkUtils.getAbsoluteURL(baseUrl, urlBefore)
-        return if (urlBefore.length == url.length) {
+        return if (urlMatch == null) {
             urlAbsoluteBefore
         } else {
-            "$urlAbsoluteBefore," + url.substring(urlMatcher.end())
+            "$urlAbsoluteBefore," + url.substring(urlMatch.range.last + 1)
         }
     }
 

+ 3 - 3
app/src/main/java/io/legado/app/data/entities/HighlightRule.kt

@@ -2,12 +2,12 @@ package io.legado.app.data.entities
 
 import androidx.room.Entity
 import androidx.room.PrimaryKey
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 @Entity(tableName = "highlightRules")
 data class HighlightRule(
     @PrimaryKey
-    var id: String = UUID.randomUUID().toString(),
+    var id: String = Uuid.random().toString(),
     var name: String = "",
     var pattern: String = "",
     var sampleText: String = "",
@@ -97,7 +97,7 @@ data class HighlightRule(
     }
 
     fun copyWithNewId(): HighlightRule {
-        return copy(id = UUID.randomUUID().toString())
+        return copy(id = Uuid.random().toString())
     }
 
     companion object {

+ 1 - 2
app/src/main/java/io/legado/app/data/entities/ReplaceRule.kt

@@ -13,7 +13,6 @@ import io.legado.app.exception.NoStackTraceException
 import kotlinx.parcelize.IgnoredOnParcel
 import kotlinx.parcelize.Parcelize
 import splitties.init.appCtx
-import java.util.regex.Pattern
 import java.util.regex.PatternSyntaxException
 
 @Parcelize
@@ -92,7 +91,7 @@ data class ReplaceRule(
         //判断正则表达式是否正确
         if (isRegex) {
             try {
-                Pattern.compile(pattern)
+                Regex(pattern)
             } catch (ex: PatternSyntaxException) {
                 AppLog.put("正则语法错误或不支持:${ex.localizedMessage}", ex)
                 return false

+ 2 - 2
app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt

@@ -9,7 +9,7 @@ import io.legado.app.domain.model.AiMessagePartJson
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class AiChatRepository(
     private val aiChatDao: AiChatDao
@@ -131,5 +131,5 @@ class AiChatRepository(
         aiChatDao.deleteConversation(conversationId)
     }
 
-    private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
+    private fun newId(prefix: String): String = "${prefix}_${Uuid.random().toString().replace("-", "")}"
 }

+ 4 - 3
app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt

@@ -21,11 +21,12 @@ import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.utils.GSON
+import io.legado.app.utils.nameUuidFromBytes
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class AiProfileRepository(
     private val aiProfileDao: AiProfileDao
@@ -425,10 +426,10 @@ class AiProfileRepository(
         const val DEFAULT_SUMMARY_PRESET_ID = "default_summarize_chapter"
         const val DEFAULT_CHAT_PRESET_ID = "default_chat"
 
-        fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
+        fun newId(prefix: String): String = "${prefix}_${Uuid.random().toString().replace("-", "")}"
 
         fun stableModelId(providerId: String, modelId: String): String {
-            val uuid = UUID.nameUUIDFromBytes("$providerId:$modelId".toByteArray())
+            val uuid = nameUuidFromBytes("$providerId:$modelId".toByteArray())
                 .toString()
                 .replace("-", "")
             return "model_$uuid"

+ 4 - 4
app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt

@@ -26,7 +26,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class AiToolRepository(
     private val bookDao: BookDao,
@@ -536,7 +536,7 @@ class AiToolRepository(
         if (content.isBlank()) return """{"error":"content is required"}"""
         val now = System.currentTimeMillis()
         val event = BookCharacterEvent(
-            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: Uuid.random()
                 .toString(),
             bookUrl = book.bookUrl,
             characterId = character.id,
@@ -614,7 +614,7 @@ class AiToolRepository(
         if (content.isBlank()) return """{"error":"content is required"}"""
         val now = System.currentTimeMillis()
         val entry = BookKnowledgeEntry(
-            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: Uuid.random()
                 .toString(),
             bookUrl = book.bookUrl,
             type = type,
@@ -650,7 +650,7 @@ class AiToolRepository(
         if (summary.isBlank()) return """{"error":"summary is required"}"""
         val now = System.currentTimeMillis()
         val node = BookOutlineNode(
-            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: Uuid.random()
                 .toString(),
             bookUrl = book.bookUrl,
             parentId = args.string("parentId")?.trim()?.takeIf { it.isNotBlank() },

+ 4 - 4
app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt

@@ -31,7 +31,7 @@ import java.io.FileOutputStream
 import java.nio.file.StandardCopyOption
 import java.security.DigestInputStream
 import java.security.MessageDigest
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class CoverAlbumRepository(
     private val context: Context,
@@ -98,7 +98,7 @@ class CoverAlbumRepository(
     override suspend fun createAlbum(name: String): String = withContext(Dispatchers.IO) {
         operationMutex.withLock {
             val album = CoverAlbum(
-                id = UUID.randomUUID().toString(),
+                id = Uuid.random().toString(),
                 name = name.trim(),
                 lightImages = emptyList(),
                 darkImages = emptyList(),
@@ -114,7 +114,7 @@ class CoverAlbumRepository(
         darkImages: List<CoverAlbumImageInput>,
     ): String = withContext(Dispatchers.IO) {
         operationMutex.withLock {
-            val albumId = UUID.randomUUID().toString()
+            val albumId = Uuid.random().toString()
             val importedLightImages = copyImages(albumId, lightImages)
             val importedDarkImages = copyImages(albumId, darkImages)
             val album = CoverAlbum(
@@ -313,7 +313,7 @@ class CoverAlbumRepository(
                     .lowercase()
                     .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
                     ?: "img"
-                val tempFile = File(albumDir, ".${UUID.randomUUID()}.tmp")
+                val tempFile = File(albumDir, ".${Uuid.random()}.tmp")
                 val digest = MessageDigest.getInstance("SHA-256")
                 input.openStream().use { source ->
                     DigestInputStream(source, digest).use { digestInput ->

+ 11 - 9
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -1,8 +1,6 @@
 package io.legado.app.data.repository
 
 import androidx.room.withTransaction
-import cn.hutool.core.date.DatePattern
-import cn.hutool.core.date.DateUtil
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.entities.readRecord.ReadRecord
@@ -17,7 +15,9 @@ import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
-import java.util.Date
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
 import kotlin.math.max
 import kotlin.math.min
 
@@ -28,6 +28,9 @@ class ReadRecordRepository(
 ) {
     private fun getCurrentDeviceId(): String = ""
 
+    private fun Long.toDateString(): String =
+        Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
+
     val readRecordEnabled: Flow<Boolean> =
         localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
 
@@ -115,7 +118,7 @@ class ReadRecordRepository(
 
     fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow<List<ReadRecordTimelineDay>> {
         return getBookSessions(bookName, bookAuthor).map { sessions ->
-            sessions.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
+            sessions.groupBy { it.startTime.toDateString() }
                 .toSortedMap(compareByDescending { it })
                 .map { (date, daySessions) ->
                     ReadRecordTimelineDay(
@@ -189,8 +192,7 @@ class ReadRecordRepository(
 
             val segmentDuration = normalizedSession.endTime - normalizedSession.startTime
             dao.insertSession(normalizedSession)
-            val dateString =
-                DateUtil.format(Date(normalizedSession.startTime), DatePattern.NORM_DATE_PATTERN)
+            val dateString = normalizedSession.startTime.toDateString()
             updateReadRecordDetail(normalizedSession, segmentDuration, normalizedSession.words, dateString)
             updateReadRecord(normalizedSession, segmentDuration)
         }
@@ -272,7 +274,7 @@ class ReadRecordRepository(
                 .filter {
                     it.bookName == detail.bookName &&
                         it.bookAuthor == detail.bookAuthor &&
-                        DateUtil.format(Date(it.startTime), DatePattern.NORM_DATE_PATTERN) == detail.date
+                        it.startTime.toDateString() == detail.date
                 }
                 .mapTo(linkedSetOf()) { it.deviceId }
                 .apply { addAll(dao.getReadRecordsByName(detail.bookName, detail.bookAuthor).map { it.deviceId }) }
@@ -306,7 +308,7 @@ class ReadRecordRepository(
                 session.endTime,
                 session.words,
             )
-            val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
+            val dateString = session.startTime.toDateString()
             affectedDevices.forEach { deviceId ->
                     val record = ReadRecord(
                         deviceId = deviceId,
@@ -623,7 +625,7 @@ class ReadRecordRepository(
             dao.allDetail.forEach { detail ->
                 val sessions = dao.getSessionsByBook(detail.deviceId, detail.bookName, detail.bookAuthor)
                     .filter {
-                        DateUtil.format(Date(it.startTime), DatePattern.NORM_DATE_PATTERN) == detail.date
+                        it.startTime.toDateString() == detail.date
                     }
                 if (sessions.isEmpty()) return@forEach
                 dao.insertDetail(detail.copy(

+ 2 - 2
app/src/main/java/io/legado/app/di/appModule.kt

@@ -317,7 +317,7 @@ import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
 import kotlinx.coroutines.Dispatchers
-import java.time.Clock
+import kotlinx.datetime.Clock
 
 val appModule = module {
 
@@ -430,7 +430,7 @@ val appModule = module {
     singleOf(::CoverAlbumUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::GetReadingProgressUseCase)
-    single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
+    single { HomeDashboardUseCase(get(), Clock.System) }
     singleOf(::RemoveBookGroupAssignmentUseCase)
     singleOf(::UpdateBooksGroupUseCase)
     singleOf(::UploadReadingProgressUseCase)

+ 2 - 2
app/src/main/java/io/legado/app/domain/model/manga/MangaReaderSessionModels.kt

@@ -1,11 +1,11 @@
 package io.legado.app.domain.model.manga
 
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 @JvmInline
 value class MangaSessionId(val value: String) {
     companion object {
-        fun create(): MangaSessionId = MangaSessionId(UUID.randomUUID().toString())
+        fun create(): MangaSessionId = MangaSessionId(Uuid.random().toString())
     }
 }
 

+ 2 - 2
app/src/main/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParser.kt

@@ -10,7 +10,7 @@ import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 internal class BookshelfAutoGroupPlanParser {
 
@@ -47,7 +47,7 @@ internal class BookshelfAutoGroupPlanParser {
                 .orEmpty()
             if (books.isNotEmpty()) {
                 groups += BookshelfAutoGroupPlanGroup(
-                    key = UUID.randomUUID().toString(),
+                    key = Uuid.random().toString(),
                     name = name,
                     description = groupObject.stringOrNull("description")?.trim().orEmpty()
                         .take(MAX_DESCRIPTION_CHARS),

+ 13 - 10
app/src/main/java/io/legado/app/domain/usecase/HomeDashboardUseCase.kt

@@ -12,9 +12,12 @@ import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flow
-import java.time.Clock
-import java.time.Duration
-import java.time.ZonedDateTime
+import kotlinx.datetime.Clock
+import kotlinx.datetime.DatePeriod
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.atStartOfDayIn
+import kotlinx.datetime.plus
+import kotlinx.datetime.toLocalDateTime
 
 class HomeDashboardUseCase(
     private val gateway: HomeDashboardGateway,
@@ -68,14 +71,14 @@ class HomeDashboardUseCase(
 
     private fun currentDate(): Flow<String> = flow {
         while (true) {
-            val now = ZonedDateTime.now(clock)
-            emit(now.toLocalDate().toString())
-            val nextDay = now.toLocalDate()
-                .plusDays(1)
-                .atStartOfDay(clock.zone)
+            val now = clock.now()
+            val zone = TimeZone.currentSystemDefault()
+            emit(now.toLocalDateTime(zone).date.toString())
+            val nextDay = now.toLocalDateTime(zone).date
+                .plus(DatePeriod(days = 1))
+                .atStartOfDayIn(zone)
             delay(
-                Duration.between(now, nextDay)
-                    .toMillis()
+                (nextDay - now).inWholeMilliseconds
                     .coerceAtLeast(MIN_DATE_REFRESH_DELAY_MILLIS)
             )
         }

+ 2 - 2
app/src/main/java/io/legado/app/domain/usecase/IdentifyBookCharactersUseCase.kt

@@ -18,7 +18,7 @@ import io.legado.app.utils.fromJsonArray
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flow
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class IdentifyBookCharactersUseCase(
     private val aiProfileGateway: AiProfileGateway,
@@ -194,7 +194,7 @@ class IdentifyBookCharactersUseCase(
             val existing = bookKnowledgeGateway.getCharacterProfile(bookUrl, candidate.name)
             bookKnowledgeGateway.upsertCharacterProfile(
                 BookCharacterProfile(
-                    id = existing?.id ?: UUID.randomUUID().toString(),
+                    id = existing?.id ?: Uuid.random().toString(),
                     bookUrl = bookUrl,
                     name = candidate.name,
                     aliasesJson = GSON.toJson(

+ 2 - 2
app/src/main/java/io/legado/app/domain/usecase/SaveBookContentProcessUseCase.kt

@@ -9,7 +9,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class SaveBookContentProcessUseCase(
     private val bookContentProcessGateway: BookContentProcessGateway,
@@ -49,7 +49,7 @@ class SaveBookContentProcessUseCase(
             }
             val now = System.currentTimeMillis()
             val process = BookContentProcess(
-                id = UUID.randomUUID().toString(),
+                id = Uuid.random().toString(),
                 bookUrl = bookUrl,
                 chapterIndex = chapterIndex,
                 kind = kind,

+ 2 - 2
app/src/main/java/io/legado/app/domain/usecase/SaveMarkingUseCase.kt

@@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 /**
  * 用户划线/高亮笔记的落库(book_marks 表)。与书签、AI 正文处理完全独立:
@@ -93,7 +93,7 @@ class SaveMarkingUseCase(
                 return@withContext updated
             }
             val mark = BookMarking(
-                id = UUID.randomUUID().toString(),
+                id = Uuid.random().toString(),
                 bookUrl = bookUrl,
                 bookName = bookName,
                 bookAuthor = bookAuthor,

+ 11 - 11
app/src/main/java/io/legado/app/help/JsEncodeUtils.kt

@@ -2,12 +2,12 @@ package io.legado.app.help
 
 import android.util.Base64
 import android.webkit.JavascriptInterface
-import cn.hutool.crypto.digest.DigestUtil
-import cn.hutool.crypto.digest.HMac
-import cn.hutool.crypto.symmetric.SymmetricCrypto
 import io.legado.app.help.crypto.AsymmetricCrypto
 import io.legado.app.help.crypto.Sign
 import io.legado.app.help.crypto.SymmetricCryptoAndroid
+import io.legado.app.help.crypto.digest
+import io.legado.app.help.crypto.hmac
+import io.legado.app.help.crypto.toHexString
 import io.legado.app.utils.MD5Utils
 
 
@@ -47,7 +47,7 @@ interface JsEncodeUtils {
         transformation: String,
         key: ByteArray?,
         iv: ByteArray?
-    ): SymmetricCrypto {
+    ): SymmetricCryptoAndroid {
         val symmetricCrypto = SymmetricCryptoAndroid(transformation, key)
         return if (iv != null && iv.isNotEmpty()) symmetricCrypto.setIv(iv) else symmetricCrypto
     }
@@ -56,7 +56,7 @@ interface JsEncodeUtils {
     fun createSymmetricCrypto(
         transformation: String,
         key: ByteArray
-    ): SymmetricCrypto {
+    ): SymmetricCryptoAndroid {
         return createSymmetricCrypto(transformation, key, null)
     }
 
@@ -64,7 +64,7 @@ interface JsEncodeUtils {
     fun createSymmetricCrypto(
         transformation: String,
         key: String
-    ): SymmetricCrypto {
+    ): SymmetricCryptoAndroid {
         return createSymmetricCrypto(transformation, key, null)
     }
 
@@ -73,7 +73,7 @@ interface JsEncodeUtils {
         transformation: String,
         key: String,
         iv: String?
-    ): SymmetricCrypto {
+    ): SymmetricCryptoAndroid {
         return createSymmetricCrypto(
             transformation, key.encodeToByteArray(), iv?.encodeToByteArray()
         )
@@ -467,7 +467,7 @@ interface JsEncodeUtils {
         data: String,
         algorithm: String,
     ): String {
-        return DigestUtil.digester(algorithm).digestHex(data)
+        return digest(algorithm, data.toByteArray()).toHexString()
     }
 
     /**
@@ -482,7 +482,7 @@ interface JsEncodeUtils {
         data: String,
         algorithm: String,
     ): String {
-        return Base64.encodeToString(DigestUtil.digester(algorithm).digest(data), Base64.NO_WRAP)
+        return Base64.encodeToString(digest(algorithm, data.toByteArray()), Base64.NO_WRAP)
     }
 
     /**
@@ -500,7 +500,7 @@ interface JsEncodeUtils {
         algorithm: String,
         key: String
     ): String {
-        return HMac(algorithm, key.toByteArray()).digestHex(data)
+        return hmac(algorithm, key.toByteArray(), data.toByteArray()).toHexString()
     }
 
     /**
@@ -519,7 +519,7 @@ interface JsEncodeUtils {
         key: String
     ): String {
         return Base64.encodeToString(
-            HMac(algorithm, key.toByteArray()).digest(data),
+            hmac(algorithm, key.toByteArray(), data.toByteArray()),
             Base64.NO_WRAP
         )
     }

+ 21 - 22
app/src/main/java/io/legado/app/help/JsExtensions.kt

@@ -4,8 +4,6 @@ import android.webkit.JavascriptInterface
 import android.webkit.WebSettings
 import androidx.annotation.Keep
 import androidx.core.net.toUri
-import cn.hutool.core.codec.Base64
-import cn.hutool.core.util.HexUtil
 import com.script.rhino.rhinoContext
 import com.script.rhino.rhinoContextOrNull
 import io.legado.app.constant.AppConst
@@ -18,6 +16,10 @@ import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.crypto.base64ToByteArray
+import io.legado.app.help.crypto.digest
+import io.legado.app.help.crypto.hexToByteArray
+import io.legado.app.help.crypto.toHexString
 import io.legado.app.help.http.BackstageWebView
 import io.legado.app.help.http.CookieManager.cookieJarHeader
 import io.legado.app.help.http.CookieStore
@@ -68,16 +70,15 @@ import java.io.ByteArrayOutputStream
 import java.io.File
 import java.net.URLEncoder
 import java.nio.charset.Charset
-import java.security.MessageDigest
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Locale
 import java.util.SimpleTimeZone
-import java.util.UUID
 import java.util.zip.ZipEntry
 import java.util.zip.ZipInputStream
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.uuid.Uuid
 
 /**
  * js扩展类, 在js中通过java变量调用
@@ -484,7 +485,7 @@ interface JsExtensions : JsEncodeUtils {
         )
         val file = File(path)
         file.createFileReplace()
-        HexUtil.decodeHex(content).let {
+        content.hexToByteArray().let {
             if (it.isNotEmpty()) {
                 file.writeBytes(it)
             }
@@ -599,12 +600,12 @@ interface JsExtensions : JsEncodeUtils {
      */
     @JavascriptInterface
     fun base64Decode(str: String?): String {
-        return Base64.decodeStr(str)
+        return str?.let { String(it.base64ToByteArray(), Charsets.UTF_8) } ?: ""
     }
 
     @JavascriptInterface
     fun base64Decode(str: String?, charset: String): String {
-        return Base64.decodeStr(str, charset(charset))
+        return str?.let { String(it.base64ToByteArray(), charset(charset)) } ?: ""
     }
 
     @JavascriptInterface
@@ -638,19 +639,19 @@ interface JsExtensions : JsEncodeUtils {
 
     /* HexString 解码为字节数组 */
     fun hexDecodeToByteArray(hex: String): ByteArray? {
-        return HexUtil.decodeHex(hex)
+        return hex.hexToByteArray()
     }
 
     /* hexString 解码为utf8String*/
     @JavascriptInterface
     fun hexDecodeToString(hex: String): String? {
-        return HexUtil.decodeHexStr(hex)
+        return String(hex.hexToByteArray(), Charsets.UTF_8)
     }
 
     /* utf8 编码为hexString */
     @JavascriptInterface
     fun hexEncodeToString(utf8: String): String? {
-        return HexUtil.encodeHexStr(utf8)
+        return utf8.toByteArray().toHexString()
     }
 
     /**
@@ -904,7 +905,7 @@ interface JsExtensions : JsEncodeUtils {
         val bytes = if (url.isAbsUrl()) {
             AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray()
         } else {
-            HexUtil.decodeHex(url)
+            url.hexToByteArray()
         }
         val bos = ByteArrayOutputStream()
         ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
@@ -932,7 +933,7 @@ interface JsExtensions : JsEncodeUtils {
         val bytes = if (url.isAbsUrl()) {
             AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray()
         } else {
-            HexUtil.decodeHex(url)
+            url.hexToByteArray()
         }
 
         return ByteArrayInputStream(bytes).use {
@@ -950,7 +951,7 @@ interface JsExtensions : JsEncodeUtils {
         val bytes = if (url.isAbsUrl()) {
             AnalyzeUrl(url, source = getSource(), coroutineContext = context).getByteArray()
         } else {
-            HexUtil.decodeHex(url)
+            url.hexToByteArray()
         }
 
         return ByteArrayInputStream(bytes).use {
@@ -978,7 +979,6 @@ interface JsExtensions : JsEncodeUtils {
      * @param data 支持url,本地文件,base64,ByteArray,自动判断,自动缓存
      * @param useCache 可选开关缓存,不传入该值默认开启缓存
      */
-    @OptIn(ExperimentalStdlibApi::class)
     fun queryTTF(data: Any?, useCache: Boolean): QueryTTF? {
         try {
             var key: String? = null
@@ -986,8 +986,7 @@ interface JsExtensions : JsEncodeUtils {
             when (data) {
                 is String -> {
                     if (useCache) {
-                        key = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
-                            .toHexString()
+                        key = digest("SHA-256", data.toByteArray()).toHexString()
                         qTTF = AppCacheManager.getQueryTTF(key)
                         if (qTTF != null) return qTTF
                     }
@@ -1006,7 +1005,7 @@ interface JsExtensions : JsEncodeUtils {
 
                 is ByteArray -> {
                     if (useCache) {
-                        key = MessageDigest.getInstance("SHA-256").digest(data).toHexString()
+                        key = digest("SHA-256", data).toHexString()
                         qTTF = AppCacheManager.getQueryTTF(key)
                         if (qTTF != null) return qTTF
                     }
@@ -1085,10 +1084,10 @@ interface JsExtensions : JsEncodeUtils {
     @JavascriptInterface
     fun toNumChapter(s: String?): String? {
         s ?: return null
-        val matcher = AppPattern.titleNumPattern.matcher(s)
-        if (matcher.find()) {
-            val intStr = StringUtils.stringToInt(matcher.group(2))
-            return "${matcher.group(1)}${intStr}${matcher.group(3)}"
+        val match = AppPattern.titleNumPattern.find(s)
+        if (match != null) {
+            val intStr = StringUtils.stringToInt(match.groupValues[2])
+            return "${match.groupValues[1]}${intStr}${match.groupValues[3]}"
         }
         return s
     }
@@ -1146,7 +1145,7 @@ interface JsExtensions : JsEncodeUtils {
      */
     @JavascriptInterface
     fun randomUUID(): String {
-        return UUID.randomUUID().toString()
+        return Uuid.random().toString()
     }
 
     @JavascriptInterface

+ 10 - 6
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -37,9 +37,12 @@ import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 import java.io.File
-import java.time.LocalDate
-import java.time.temporal.ChronoUnit
 import java.util.concurrent.ConcurrentHashMap
+import kotlinx.datetime.Clock
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.daysUntil
+import kotlinx.datetime.todayIn
 import kotlin.math.max
 import kotlin.math.min
 
@@ -691,11 +694,12 @@ fun Book.getExportFileName(
 // 根据当前日期计算章节总数
 fun Book.simulatedTotalChapterNum(): Int {
     return if (readSimulating()) {
-        val currentDate = LocalDate.now()
-        val daysPassed = if (config.startDate != null) {
+        val currentDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
+        val startDateStr = config.startDate
+        val daysPassed = if (startDateStr != null) {
             try {
-                val startDate = LocalDate.parse(config.startDate)
-                ChronoUnit.DAYS.between(startDate, currentDate).toInt() + 1
+                val startDate = LocalDate.parse(startDateStr)
+                startDate.daysUntil(currentDate) + 1
             } catch (e: Exception) {
                 println("解析起始日期失败: ${config.startDate}, 错误: ${e.message}")
                 1 // 解析失败时返回默认值1

+ 22 - 28
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -54,7 +54,6 @@ import java.io.FileOutputStream
 import java.io.IOException
 import java.io.InputStream
 import java.util.concurrent.ConcurrentHashMap
-import java.util.regex.Pattern
 import java.util.zip.ZipFile
 import kotlin.math.abs
 import kotlin.math.max
@@ -147,15 +146,14 @@ object BookHelp {
         val imgNames = hashSetOf<String>()
         //获取需要保留章节的图片信息
         chapterList.forEach {
-            val content = getContent(book, it)
-            if (content != null) {
-                val matcher = AppPattern.imgPattern.matcher(content)
-                while (matcher.find()) {
-                    val src = matcher.group(1) ?: continue
-                    val mSrc = NetworkUtils.getAbsoluteURL(it.url, src)
-                    imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}")
+                val content = getContent(book, it)
+                if (content != null) {
+                    for (m in AppPattern.imgPattern.findAll(content)) {
+                        val src = m.groupValues[1].takeIf { it.isNotEmpty() } ?: continue
+                        val mSrc = NetworkUtils.getAbsoluteURL(it.url, src)
+                        imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}")
+                    }
                 }
-            }
         }
         downloadDir.getFile(
             cacheFolderName,
@@ -280,9 +278,8 @@ object BookHelp {
 
     fun flowImages(bookChapter: BookChapter, content: String): Flow<String> {
         return flow {
-            val matcher = AppPattern.imgPattern.matcher(content)
-            while (matcher.find()) {
-                val src = matcher.group(1) ?: continue
+            for (m in AppPattern.imgPattern.findAll(content)) {
+                val src = m.groupValues[1].takeIf { it.isNotEmpty() } ?: continue
                 val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src)
                 emit(mSrc)
             }
@@ -291,9 +288,8 @@ object BookHelp {
 
     fun countImagesInContent(bookChapter: BookChapter, content: String): Int {
         var count = 0
-        val matcher = AppPattern.imgPattern.matcher(content)
-        while (matcher.find()) {
-            if (matcher.group(1) != null) count++
+        for (m in AppPattern.imgPattern.findAll(content)) {
+            if (m.groupValues[1].isNotEmpty()) count++
         }
         return count
     }
@@ -612,9 +608,8 @@ object BookHelp {
             return
         }
         getContent(book, bookChapter)?.let { content ->
-            val matcher = AppPattern.imgPattern.matcher(content)
-            while (matcher.find()) {
-                val src = matcher.group(1) ?: continue
+            for (m in AppPattern.imgPattern.findAll(content)) {
+                val src = m.groupValues[1].takeIf { it.isNotEmpty() } ?: continue
                 action(NetworkUtils.getAbsoluteURL(bookChapter.url, src))
             }
         }
@@ -639,11 +634,11 @@ object BookHelp {
         buffer: StringBuilder,
         action: (String) -> Unit
     ) {
-        val matcher = AppPattern.imgPattern.matcher(buffer)
         var lastEnd = 0
-        while (matcher.find()) {
-            action(matcher.group(1) ?: continue)
-            lastEnd = matcher.end()
+        for (m in AppPattern.imgPattern.findAll(buffer)) {
+            val src = m.groupValues[1].takeIf { it.isNotEmpty() } ?: continue
+            action(src)
+            lastEnd = m.range.last + 1
         }
         if (lastEnd > 0) {
             buffer.delete(0, lastEnd)
@@ -894,14 +889,14 @@ object BookHelp {
     }
 
     private val chapterNamePattern1 by lazy {
-        Pattern.compile(
+        Regex(
             ".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]"
         )
     }
 
     @Suppress("RegExpSimplifiable")
     private val chapterNamePattern2 by lazy {
-        Pattern.compile(
+        Regex(
             "^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])"
         )
     }
@@ -915,10 +910,9 @@ object BookHelp {
         val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "")
         return StringUtils.stringToInt(
             (
-                    chapterNamePattern1.matcher(chapterName1).takeIf { it.find() }
-                        ?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() }
-                    )?.group(1)
-                ?: "-1"
+                    chapterNamePattern1.find(chapterName1)?.groups?.get(1)?.value
+                        ?: chapterNamePattern2.find(chapterName1)?.groups?.get(1)?.value
+                    ) ?: "-1"
         )
     }
 

+ 3 - 5
app/src/main/java/io/legado/app/help/book/ContentHelp.kt

@@ -1,6 +1,5 @@
 package io.legado.app.help.book
 
-import java.util.regex.Pattern
 import kotlin.math.max
 import kotlin.math.min
 
@@ -480,18 +479,17 @@ object ContentHelp {
     private fun makeDict(str: String): List<String> {
 
         // 引号中间不包含任何标点
-        val patten = Pattern.compile(
+        val patten = Regex(
             """
           (?<=["'”“])([^
           \p{P}]{1,$WORD_MAX_LENGTH})(?=["'”“])
           """.trimIndent()
         )
         //Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])");
-        val matcher = patten.matcher(str)
         val cache: MutableList<String> = ArrayList()
         val dict: MutableList<String> = ArrayList()
-        while (matcher.find()) {
-            val word = matcher.group()
+        for (m in patten.findAll(str)) {
+            val word = m.value
             if (cache.contains(word)) {
                 if (!dict.contains(word)) dict.add(word)
             } else cache.add(word)

+ 10 - 11
app/src/main/java/io/legado/app/help/book/ContentProcessor.kt

@@ -27,7 +27,6 @@ import kotlinx.coroutines.CancellationException
 import splitties.init.appCtx
 import java.lang.ref.WeakReference
 import java.util.concurrent.CopyOnWriteArrayList
-import java.util.regex.Pattern
 
 class ContentProcessor private constructor(
     private val bookName: String,
@@ -113,25 +112,25 @@ class ContentProcessor private constructor(
             //去除重复标题
             val fileName = chapter.getFileName("nr")
             if (!removeSameTitleCache.contains(fileName)) try {
-                val name = Pattern.quote(book.name)
+                val name = Regex.escape(book.name)
                 var title = chapter.title.escapeRegex().replace(spaceRegex, "\\\\s*")
-                var matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
-                    .matcher(mContent)
-                if (matcher.find()) {
-                    mContent = mContent.substring(matcher.end())
+                var match = Regex("^(\\s|\\p{P}|${name})*${title}(\\s)*")
+                    .find(mContent)
+                if (match != null) {
+                    mContent = mContent.substring(match.range.last + 1)
                     sameTitleRemoved = true
                 } else if (useReplace && book.getUseReplaceRule(AppConfig.replaceEnableDefault)) {
-                    title = Pattern.quote(
+                    title = Regex.escape(
                         chapter.getDisplayTitle(
                             contentReplaceRules,
                             chineseConvert = false,
                             chineseConverterType = AppConfig.chineseConverterType,
                         )
                     )
-                    matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
-                        .matcher(mContent)
-                    if (matcher.find()) {
-                        mContent = mContent.substring(matcher.end())
+                    match = Regex("^(\\s|\\p{P}|${name})*${title}(\\s)*")
+                        .find(mContent)
+                    if (match != null) {
+                        mContent = mContent.substring(match.range.last + 1)
                         sameTitleRemoved = true
                     }
                 }

+ 6 - 6
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -10,7 +10,7 @@ import io.legado.app.utils.GSON
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileOutputStream
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 /**
  * 轻量级主题导入导出系统
@@ -171,14 +171,14 @@ object ThemeImportExport {
                         val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
                         val folder = File(baseDir, key)
                         folder.mkdirs()
-                        File(folder, "theme_asset_${UUID.randomUUID()}.jpg")
+                        File(folder, "theme_asset_${Uuid.random()}.jpg")
                     }
 
                     key.contains("ContainerBackgroundImage") -> {
                         val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
                         val folder = File(baseDir, "container_background/$key")
                         folder.mkdirs()
-                        File(folder, "theme_asset_${UUID.randomUUID()}.png")
+                        File(folder, "theme_asset_${Uuid.random()}.png")
                     }
 
                     key.startsWith("navIcon") -> {
@@ -186,21 +186,21 @@ object ThemeImportExport {
                         folder.mkdirs()
                         File(
                             folder,
-                            "theme_${key.removePrefix("navIcon").lowercase()}_${UUID.randomUUID()}.png",
+                            "theme_${key.removePrefix("navIcon").lowercase()}_${Uuid.random()}.png",
                         )
                     }
 
                     key == "appFontPath" -> {
                         val folder = File(appCtx.filesDir, "fonts")
                         folder.mkdirs()
-                        File(folder, "theme_font_${UUID.randomUUID()}.ttf")
+                        File(folder, "theme_font_${Uuid.random()}.ttf")
                     }
 
                     key.startsWith("coverDefaultImage") -> {
                         val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
                         val folder = File(baseDir, "covers")
                         folder.mkdirs()
-                        File(folder, "${key}_${UUID.randomUUID()}.jpg")
+                        File(folder, "${key}_${Uuid.random()}.jpg")
                     }
 
                     else -> null

+ 4 - 4
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -25,7 +25,7 @@ import java.io.FileOutputStream
 import java.io.InputStream
 import java.io.OutputStream
 import java.net.URLEncoder
-import java.util.UUID
+import kotlin.uuid.Uuid
 import java.util.zip.ZipEntry
 import java.util.zip.ZipInputStream
 import java.util.zip.ZipOutputStream
@@ -111,7 +111,7 @@ class ThemePackageManager(
 
     suspend fun importPackage(uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
         runSuspendCatching {
-            val tempRoot = File(context.cacheDir, "theme_import/${UUID.randomUUID()}")
+            val tempRoot = File(context.cacheDir, "theme_import/${Uuid.random()}")
             val importedAlbumIds = mutableListOf<String>()
             val copiedAssets = mutableListOf<File>()
             try {
@@ -355,7 +355,7 @@ class ThemePackageManager(
     ): SavedTheme {
         savedThemesRoot.mkdirs()
         val targetRoot = savedThemeDir(name)
-        val tempRoot = File(savedThemesRoot, "${targetRoot.name}_${UUID.randomUUID()}.tmp")
+        val tempRoot = File(savedThemesRoot, "${targetRoot.name}_${Uuid.random()}.tmp")
         tempRoot.mkdirs()
         try {
             val assetEntries = copyAssetsToFolder(tempRoot, data)
@@ -767,7 +767,7 @@ class ThemePackageManager(
             }.apply { mkdirs() }
             val target = File(
                 targetDir,
-                "theme_${key.substringAfterLast('.')}_${UUID.randomUUID()}.$extension",
+                "theme_${key.substringAfterLast('.')}_${Uuid.random()}.$extension",
             )
             source.copyTo(target)
             copiedFiles += target

+ 77 - 28
app/src/main/java/io/legado/app/help/crypto/AsymmetricCrypto.kt

@@ -1,21 +1,37 @@
 package io.legado.app.help.crypto
 
 import androidx.annotation.Keep
-import cn.hutool.crypto.KeyUtil
-import cn.hutool.crypto.asymmetric.KeyType
-import io.legado.app.utils.EncoderUtils
 import java.io.InputStream
-import cn.hutool.crypto.asymmetric.AsymmetricCrypto as HutoolAsymmetricCrypto
+import java.security.Key
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.PrivateKey
+import java.security.PublicKey
+import java.security.interfaces.RSAKey
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
+import javax.crypto.Cipher
 
 @Keep
 @Suppress("unused")
-class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
+class AsymmetricCrypto(private val algorithm: String) {
+
+    private val keyAlgorithm = algorithm.substringBefore('/').let { name ->
+        val separator = name.lowercase().lastIndexOf("with")
+        if (separator >= 0) name.substring(separator + 4) else name
+    }.let { if (it.equals("ECDSA", true)) "EC" else it }
+    private var publicKey: PublicKey
+    private var privateKey: PrivateKey
+
+    init {
+        val pair = KeyPairGenerator.getInstance(keyAlgorithm).generateKeyPair()
+        publicKey = pair.public
+        privateKey = pair.private
+    }
 
     @Suppress("MemberVisibilityCanBePrivate")
     fun setPrivateKey(key: ByteArray): AsymmetricCrypto {
-        setPrivateKey(
-            KeyUtil.generatePrivateKey(this.algorithm, key)
-        )
+        privateKey = KeyFactory.getInstance(keyAlgorithm).generatePrivate(PKCS8EncodedKeySpec(key))
         return this
     }
 
@@ -23,27 +39,54 @@ class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
 
     @Suppress("MemberVisibilityCanBePrivate")
     fun setPublicKey(key: ByteArray): AsymmetricCrypto {
-        setPublicKey(
-            KeyUtil.generatePublicKey(this.algorithm, key)
-        )
+        publicKey = KeyFactory.getInstance(keyAlgorithm).generatePublic(X509EncodedKeySpec(key))
         return this
     }
 
     fun setPublicKey(key: String): AsymmetricCrypto = setPublicKey(key.encodeToByteArray())
 
-    private fun getKeyType(usePublicKey: Boolean? = true): KeyType {
-        return when (usePublicKey) {
-            true -> KeyType.PublicKey
-            else -> KeyType.PrivateKey
+    private fun key(usePublicKey: Boolean?): Key = if (usePublicKey == true) publicKey else privateKey
+
+    private fun crypt(data: ByteArray, usePublicKey: Boolean?, mode: Int): ByteArray {
+        val key = key(usePublicKey)
+        val cipher = Cipher.getInstance(algorithm)
+        cipher.init(mode, key)
+        if (key !is RSAKey) return cipher.doFinal(data)
+        val keySize = (key.modulus.bitLength() + 7) / 8
+        val blockSize = if (mode == Cipher.DECRYPT_MODE) keySize else rsaEncryptBlockSize(keySize)
+        if (data.size <= blockSize) return cipher.doFinal(data)
+        val output = ArrayList<Byte>()
+        var offset = 0
+        while (offset < data.size) {
+            cipher.doFinal(data, offset, minOf(blockSize, data.size - offset)).forEach(output::add)
+            offset += blockSize
         }
+        return output.toByteArray()
+    }
+
+    private fun rsaEncryptBlockSize(keySize: Int): Int = when {
+        algorithm.contains("OAEPWithSHA-512", true) -> keySize - 2 * 64 - 2
+        algorithm.contains("OAEPWithSHA-384", true) -> keySize - 2 * 48 - 2
+        algorithm.contains("OAEPWithSHA-256", true) -> keySize - 2 * 32 - 2
+        algorithm.contains("OAEPWithSHA-224", true) -> keySize - 2 * 28 - 2
+        algorithm.contains("OAEPWithSHA-1", true) || algorithm.contains("OAEPPadding", true) -> keySize - 42
+        algorithm.contains("NoPadding", true) -> keySize
+        else -> keySize - 11
     }
 
+    private fun decode(data: String): ByteArray =
+        if (data.length % 2 == 0 && data.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) {
+            data.hexToByteArray()
+        } else {
+            data.base64ToByteArray()
+        }
+
     @JvmOverloads
     fun decrypt(data: Any, usePublicKey: Boolean? = true): ByteArray {
         return when (data) {
-            is ByteArray -> decrypt(data, getKeyType(usePublicKey))
-            is String -> decrypt(data, getKeyType(usePublicKey))
-            is InputStream -> decrypt(data, getKeyType(usePublicKey))
+            is ByteArray -> crypt(data, usePublicKey, Cipher.DECRYPT_MODE)
+            is String -> crypt(decode(data), usePublicKey, Cipher.DECRYPT_MODE)
+            is InputStream -> crypt(data.readBytes(), usePublicKey, Cipher.DECRYPT_MODE)
             else -> throw IllegalArgumentException("Unexpected input type")
         }
     }
@@ -51,9 +94,9 @@ class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
     @JvmOverloads
     fun decryptStr(data: Any, usePublicKey: Boolean? = true): String {
         return when (data) {
-            is ByteArray -> String(decrypt(data, getKeyType(usePublicKey)))
-            is String -> decryptStr(data, getKeyType(usePublicKey))
-            is InputStream -> String(decrypt(data, getKeyType(usePublicKey)))
+            is ByteArray -> String(decrypt(data, usePublicKey), Charsets.UTF_8)
+            is String -> String(decrypt(data, usePublicKey), Charsets.UTF_8)
+            is InputStream -> String(decrypt(data, usePublicKey), Charsets.UTF_8)
             else -> throw IllegalArgumentException("Unexpected input type")
         }
     }
@@ -61,9 +104,9 @@ class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
     @JvmOverloads
     fun encrypt(data: Any, usePublicKey: Boolean? = true): ByteArray {
         return when (data) {
-            is ByteArray -> encrypt(data, getKeyType(usePublicKey))
-            is String -> encrypt(data, getKeyType(usePublicKey))
-            is InputStream -> encrypt(data, getKeyType(usePublicKey))
+            is ByteArray -> crypt(data, usePublicKey, Cipher.ENCRYPT_MODE)
+            is String -> crypt(data.toByteArray(), usePublicKey, Cipher.ENCRYPT_MODE)
+            is InputStream -> crypt(data.readBytes(), usePublicKey, Cipher.ENCRYPT_MODE)
             else -> throw IllegalArgumentException("Unexpected input type")
         }
     }
@@ -71,16 +114,22 @@ class AsymmetricCrypto(algorithm: String) : HutoolAsymmetricCrypto(algorithm) {
     @JvmOverloads
     fun encryptHex(data: Any, usePublicKey: Boolean? = true): String {
         return when (data) {
-            is ByteArray -> encryptHex(data, getKeyType(usePublicKey))
-            is String -> encryptHex(data, getKeyType(usePublicKey))
-            is InputStream -> encryptHex(data, getKeyType(usePublicKey))
+            is ByteArray, is String, is InputStream -> encrypt(data, usePublicKey).toHexString()
             else -> throw IllegalArgumentException("Unexpected input type")
         }
     }
 
     @JvmOverloads
     fun encryptBase64(data: Any, usePublicKey: Boolean? = true): String {
-        return EncoderUtils.base64Encode(encrypt(data, usePublicKey))
+        return encrypt(data, usePublicKey).toBase64()
     }
 
+    @JvmOverloads
+    fun decryptHex(data: String, usePublicKey: Boolean? = true): ByteArray =
+        crypt(data.hexToByteArray(), usePublicKey, Cipher.DECRYPT_MODE)
+
+    @JvmOverloads
+    fun decryptBase64(data: String, usePublicKey: Boolean? = true): ByteArray =
+        crypt(data.base64ToByteArray(), usePublicKey, Cipher.DECRYPT_MODE)
+
 }

+ 57 - 0
app/src/main/java/io/legado/app/help/crypto/CryptoUtils.kt

@@ -0,0 +1,57 @@
+package io.legado.app.help.crypto
+
+import java.io.InputStream
+import java.security.MessageDigest
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import kotlin.io.encoding.Base64
+
+private const val HEX_CHARS = "0123456789abcdef"
+
+internal fun ByteArray.toHexString(): String {
+    val builder = StringBuilder(size * 2)
+    for (b in this) {
+        val v = b.toInt() and 0xFF
+        builder.append(HEX_CHARS[v ushr 4])
+        builder.append(HEX_CHARS[v and 0x0F])
+    }
+    return builder.toString()
+}
+
+internal fun String.hexToByteArray(): ByteArray {
+    require(length % 2 == 0) { "Hex input must contain an even number of characters" }
+    return ByteArray(length / 2) { index ->
+        substring(index * 2, index * 2 + 2).toInt(16).toByte()
+    }
+}
+
+internal fun ByteArray.toBase64(): String = Base64.Default.encode(this)
+
+internal fun String.base64ToByteArray(): ByteArray {
+    val normalized = replace("\\s".toRegex(), "")
+    return try {
+        Base64.Default.decode(normalized)
+    } catch (e: IllegalArgumentException) {
+        Base64.UrlSafe.decode(normalized)
+    }
+}
+
+internal fun digest(algorithm: String, data: ByteArray): ByteArray =
+    MessageDigest.getInstance(algorithm).digest(data)
+
+internal fun digest(algorithm: String, input: InputStream): ByteArray {
+    val messageDigest = MessageDigest.getInstance(algorithm)
+    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+    while (true) {
+        val read = input.read(buffer)
+        if (read < 0) break
+        if (read > 0) messageDigest.update(buffer, 0, read)
+    }
+    return messageDigest.digest()
+}
+
+internal fun hmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray {
+    val mac = Mac.getInstance(algorithm)
+    mac.init(SecretKeySpec(key, algorithm))
+    return mac.doFinal(data)
+}

+ 50 - 6
app/src/main/java/io/legado/app/help/crypto/Sign.kt

@@ -1,26 +1,70 @@
 package io.legado.app.help.crypto
 
 import androidx.annotation.Keep
-import cn.hutool.crypto.KeyUtil
-import cn.hutool.crypto.asymmetric.Sign as HutoolSign
+import java.io.InputStream
+import java.security.KeyFactory
+import java.security.PrivateKey
+import java.security.PublicKey
+import java.security.Signature
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.X509EncodedKeySpec
 
 @Keep
 @Suppress("unused")
-class Sign(algorithm: String): HutoolSign(algorithm) {
+class Sign(private val algorithm: String) {
+
+    private val keyAlgorithm = algorithm.substringBefore('/').let { name ->
+        val separator = name.lowercase().lastIndexOf("with")
+        val keyName = if (separator >= 0) name.substring(separator + 4) else name
+        if (keyName.equals("ECDSA", true)) "EC" else keyName
+    }
+    private var privateKey: PrivateKey? = null
+    private var publicKey: PublicKey? = null
 
     fun setPrivateKey(key: ByteArray): Sign {
-        setPrivateKey(KeyUtil.generatePrivateKey(algorithm, key))
+        privateKey = KeyFactory.getInstance(keyAlgorithm).generatePrivate(PKCS8EncodedKeySpec(key))
         return this
     }
 
     fun setPrivateKey(key: String): Sign = setPrivateKey(key.encodeToByteArray())
 
     fun setPublicKey(key: ByteArray): Sign {
-        setPublicKey(KeyUtil.generatePublicKey(algorithm, key))
+        publicKey = KeyFactory.getInstance(keyAlgorithm).generatePublic(X509EncodedKeySpec(key))
         return this
     }
 
     fun setPublicKey(key: String): Sign = setPublicKey(key.encodeToByteArray())
 
-}
+    fun sign(data: ByteArray): ByteArray = Signature.getInstance(algorithm).run {
+        initSign(requireNotNull(privateKey) { "Private key is required for signing" })
+        update(data)
+        sign()
+    }
+
+    fun sign(data: String): ByteArray = sign(data.toByteArray())
+
+    fun sign(data: InputStream): ByteArray {
+        val signature = Signature.getInstance(algorithm)
+        signature.initSign(requireNotNull(privateKey) { "Private key is required for signing" })
+        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+        while (true) {
+            val read = data.read(buffer)
+            if (read < 0) break
+            if (read > 0) signature.update(buffer, 0, read)
+        }
+        return signature.sign()
+    }
 
+    fun signHex(data: ByteArray): String = sign(data).toHexString()
+
+    fun signHex(data: String): String = sign(data).toHexString()
+
+    fun signHex(data: InputStream): String = sign(data).toHexString()
+
+    fun verify(data: ByteArray, signature: ByteArray): Boolean = Signature.getInstance(algorithm).run {
+        initVerify(requireNotNull(publicKey) { "Public key is required for verification" })
+        update(data)
+        verify(signature)
+    }
+
+}

+ 83 - 21
app/src/main/java/io/legado/app/help/crypto/SymmetricCryptoAndroid.kt

@@ -1,47 +1,109 @@
 package io.legado.app.help.crypto
 
 import androidx.annotation.Keep
-import cn.hutool.core.codec.Base64
-import cn.hutool.core.util.HexUtil
-import cn.hutool.crypto.symmetric.SymmetricCrypto
-import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.isHex
 import java.io.InputStream
 import java.nio.charset.Charset
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
 
 @Keep
-class SymmetricCryptoAndroid(
-    algorithm: String,
+open class SymmetricCryptoAndroid(
+    private val algorithm: String,
     key: ByteArray?,
-) : SymmetricCrypto(algorithm, key) {
+) {
 
-    override fun encryptBase64(data: ByteArray): String {
-        return EncoderUtils.base64Encode(encrypt(data))
+    private val transformation = if (algorithm.contains('/')) algorithm else "$algorithm/ECB/PKCS5Padding"
+    private val keyAlgorithm = algorithm.substringBefore('/')
+    private val secretKey = key?.let { SecretKeySpec(normalizedKey(it), keyAlgorithm) }
+        ?: KeyGenerator.getInstance(keyAlgorithm).generateKey()
+    private var iv: ByteArray? = null
+
+    private fun normalizedKey(key: ByteArray): ByteArray = when {
+        keyAlgorithm.equals("DES", true) && key.size > 8 -> key.copyOf(8)
+        keyAlgorithm.equals("DESede", true) && key.size > 24 -> key.copyOf(24)
+        else -> key
+    }
+
+    fun setIv(iv: ByteArray): SymmetricCryptoAndroid {
+        this.iv = iv.copyOf()
+        return this
+    }
+
+    private fun cipher(mode: Int): Cipher = Cipher.getInstance(transformation).apply {
+        val iv = this@SymmetricCryptoAndroid.iv
+        if (iv == null || transformation.contains("/ECB/", true)) {
+            init(mode, secretKey)
+        } else {
+            init(mode, secretKey, IvParameterSpec(iv))
+        }
+    }
+
+    fun encrypt(data: ByteArray): ByteArray = cipher(Cipher.ENCRYPT_MODE).doFinal(data)
+
+    fun encrypt(data: String): ByteArray = encrypt(data.toByteArray())
+
+    fun encrypt(data: String, charset: String?): ByteArray =
+        encrypt(data.toByteArray(charset?.let(Charset::forName) ?: Charsets.UTF_8))
+
+    fun encrypt(data: String, charset: Charset?): ByteArray =
+        encrypt(data.toByteArray(charset ?: Charsets.UTF_8))
+
+    fun encrypt(data: InputStream): ByteArray = encrypt(data.readBytes())
+
+    fun decrypt(data: ByteArray): ByteArray = cipher(Cipher.DECRYPT_MODE).doFinal(data)
+
+    fun encryptBase64(data: ByteArray): String {
+        return encrypt(data).toBase64()
     }
 
-    override fun encryptBase64(data: String, charset: String?): String {
-        return EncoderUtils.base64Encode(encrypt(data, charset))
+    fun encryptBase64(data: String, charset: String?): String {
+        return encrypt(data, charset).toBase64()
     }
 
-    override fun encryptBase64(data: String, charset: Charset?): String {
-        return EncoderUtils.base64Encode(encrypt(data, charset))
+    fun encryptBase64(data: String, charset: Charset?): String {
+        return encrypt(data, charset).toBase64()
     }
 
-    override fun encryptBase64(data: String): String {
-        return EncoderUtils.base64Encode(encrypt(data))
+    fun encryptBase64(data: String): String {
+        return encrypt(data).toBase64()
     }
 
-    override fun encryptBase64(data: InputStream): String {
-        return EncoderUtils.base64Encode(encrypt(data))
+    fun encryptBase64(data: InputStream): String {
+        return encrypt(data).toBase64()
     }
 
-    override fun decrypt(data: String): ByteArray {
-        val bytes = if (data.isHex()) {
-            HexUtil.decodeHex(data)
+    fun decrypt(data: String): ByteArray {
+        val bytes = if (data.isHex() && data.length % 2 == 0) {
+            data.hexToByteArray()
         } else {
-            Base64.decode(data)
+            data.base64ToByteArray()
         }
         return decrypt(bytes)
     }
 
+    fun decrypt(data: InputStream): ByteArray = decrypt(data.readBytes())
+
+    fun decryptStr(data: ByteArray): String = String(decrypt(data), Charsets.UTF_8)
+
+    fun decryptStr(data: String): String = String(decrypt(data), Charsets.UTF_8)
+
+    fun decryptStr(data: InputStream): String = String(decrypt(data), Charsets.UTF_8)
+
+    fun encryptHex(data: ByteArray): String = encrypt(data).toHexString()
+
+    fun encryptHex(data: String): String = encrypt(data).toHexString()
+
+    fun encryptHex(data: InputStream): String = encrypt(data).toHexString()
+
+    fun decryptHex(data: String): ByteArray = decrypt(data.hexToByteArray())
+
+    fun decryptBase64(data: String): ByteArray = decrypt(data.base64ToByteArray())
+
+    fun encryptStr(data: ByteArray): String = String(encrypt(data), Charsets.UTF_8)
+
+    fun encryptStr(data: String): String = String(encrypt(data), Charsets.UTF_8)
+
 }

+ 3 - 3
app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt

@@ -51,9 +51,9 @@ object ProgressManager {
     }
 
     private fun getUrlNoOption(url: String): String {
-        val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
-        return if (urlMatcher.find()) {
-            url.take(urlMatcher.start())
+        val urlMatch = AnalyzeUrl.paramPattern.find(url)
+        return if (urlMatch != null) {
+            url.take(urlMatch.range.first)
         } else {
             url
         }

+ 2 - 2
app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsFileSynthesizer.kt

@@ -9,7 +9,7 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import java.io.File
-import java.util.UUID
+import kotlin.uuid.Uuid
 import kotlin.coroutines.resume
 
 /** Serializes Android TTS file synthesis and reuses the active engine between adjacent cues. */
@@ -37,7 +37,7 @@ class SystemTtsFileSynthesizer(
         }
         output.parentFile?.mkdirs()
         if (output.exists()) output.delete()
-        val utteranceId = "legado-file-${UUID.randomUUID()}"
+        val utteranceId = "legado-file-${Uuid.random()}"
         suspendCancellableCoroutine { continuation ->
             instance.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
                 override fun onStart(utteranceId: String?) = Unit

+ 2 - 2
app/src/main/java/io/legado/app/help/readaloud/playback/VolcengineCloudTtsProvider.kt

@@ -10,7 +10,7 @@ import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.Request
 import okhttp3.RequestBody.Companion.toRequestBody
 import org.json.JSONObject
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 internal class VolcengineCloudTtsProvider : CloudTtsProvider {
     override val type = CloudTtsProviderType.Volcengine
@@ -49,7 +49,7 @@ internal class VolcengineCloudTtsProvider : CloudTtsProvider {
                 .put("pitch_ratio", request.pitch)
                 .apply { if (request.style.isNotBlank()) put("emotion", request.style) })
             put("request", JSONObject()
-                .put("reqid", UUID.randomUUID().toString())
+                .put("reqid", Uuid.random().toString())
                 .put("text", request.text)
                 .put("text_type", "plain")
                 .put("operation", "query"))

+ 4 - 3
app/src/main/java/io/legado/app/help/storage/BackupAES.kt

@@ -1,9 +1,10 @@
 package io.legado.app.help.storage
 
-import cn.hutool.crypto.symmetric.AES
 import io.legado.app.help.config.LocalConfig
+import io.legado.app.help.crypto.SymmetricCryptoAndroid
 import io.legado.app.utils.MD5Utils
 
-class BackupAES : AES(
+class BackupAES : SymmetricCryptoAndroid(
+    "AES",
     MD5Utils.md5Encode(LocalConfig.password ?: "").encodeToByteArray(0, 16)
-)
+)

+ 6 - 9
app/src/main/java/io/legado/app/help/storage/ImportOldData.kt

@@ -17,14 +17,13 @@ import io.legado.app.help.ReplaceAnalyzer
 import io.legado.app.utils.*
 import splitties.init.appCtx
 import java.io.File
-import java.util.regex.Pattern
 
 object ImportOldData {
 
     @Suppress("RegExpRedundantEscape")
-    private val headerPattern = Pattern.compile("@Header:\\{.+?\\}", Pattern.CASE_INSENSITIVE)
+    private val headerPattern = Regex("@Header:\\{.+?\\}", RegexOption.IGNORE_CASE)
     @Suppress("RegExpRedundantEscape")
-    private val jsPattern = Pattern.compile("\\{\\{.+?\\}\\}", Pattern.CASE_INSENSITIVE)
+    private val jsPattern = Regex("\\{\\{.+?\\}\\}", RegexOption.IGNORE_CASE)
 
     fun importUri(context: Context, uri: Uri) {
         if (uri.isContentScheme()) {
@@ -324,9 +323,8 @@ object ImportOldData {
             return url
         }
         val map = HashMap<String, String>()
-        var mather = headerPattern.matcher(url)
-        if (mather.find()) {
-            val header = mather.group()
+        headerPattern.find(url)?.let { match ->
+            val header = match.value
             url = url.replace(header, "")
             map["headers"] = header.substring(8)
         }
@@ -335,10 +333,9 @@ object ImportOldData {
         if (urlList.size > 1) {
             map["charset"] = urlList[1].split("=")[1]
         }
-        mather = jsPattern.matcher(url)
         val jsList = arrayListOf<String>()
-        while (mather.find()) {
-            jsList.add(mather.group())
+        for (match in jsPattern.findAll(url)) {
+            jsList.add(match.value)
             url = url.replace(jsList.last(), "$${jsList.size - 1}")
         }
         url = url.replace("{", "<").replace("}", ">")

+ 3 - 3
app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt

@@ -22,7 +22,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.isJsonObject
 import java.lang.ref.WeakReference
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 @Suppress("unused")
 class WebJsExtensions(
@@ -282,10 +282,10 @@ class WebJsExtensions(
         }
 
         val uuid by lazy {
-            UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6)
+            Uuid.random().toString().replace('-', getRandomLetter()).chunked(6)
         }
         val uuid2 by lazy {
-            UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6)
+            Uuid.random().toString().replace('-', getRandomLetter()).chunked(6)
         }
         val nameUrl by lazy { "https://" + uuid[0] + ".com/" + uuid2[0] + ".js" }
         val nameJava by lazy { getRandomLetter() + uuid[1] + uuid2[1] }

+ 52 - 3
app/src/main/java/io/legado/app/lib/webdav/WebDav.kt

@@ -2,7 +2,6 @@ package io.legado.app.lib.webdav
 
 import android.annotation.SuppressLint
 import android.net.Uri
-import cn.hutool.core.net.URLDecoder
 import io.legado.app.constant.AppLog
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.http.newCallResponse
@@ -31,6 +30,7 @@ import org.jsoup.parser.Parser
 import java.io.File
 import java.io.FileOutputStream
 import java.io.InputStream
+import java.io.ByteArrayOutputStream
 import java.net.MalformedURLException
 import java.net.URL
 import java.net.URLEncoder
@@ -123,6 +123,55 @@ open class WebDav(
             }
         }
 
+    private fun decodeForPath(str: String): String {
+        if (str.isEmpty()) return ""
+        val result = StringBuilder(str.length / 3)
+        var begin = 0
+        var i = 0
+        while (i < str.length) {
+            val c = str[i]
+            if (c == '%' || c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F') {
+                i++
+                continue
+            }
+            if (i > begin) result.append(decodeSub(str, begin, i))
+            result.append(c)
+            begin = i + 1
+            i++
+        }
+        if (begin < str.length) result.append(decodeSub(str, begin, str.length))
+        return result.toString()
+    }
+
+    private fun decodeSub(str: String, begin: Int, end: Int): String =
+        String(decodePercent(str.substring(begin, end).toByteArray(Charsets.ISO_8859_1)), Charsets.UTF_8)
+
+    private fun decodePercent(bytes: ByteArray): ByteArray {
+        val buffer = ByteArrayOutputStream(bytes.size)
+        var i = 0
+        while (i < bytes.size) {
+            val b = bytes[i].toInt() and 0xFF
+            if (b == '%'.code) {
+                if (i + 1 < bytes.size) {
+                    val u = Character.digit((bytes[i + 1].toInt() and 0xFF).toChar(), 16)
+                    if (u >= 0 && i + 2 < bytes.size) {
+                        val l = Character.digit((bytes[i + 2].toInt() and 0xFF).toChar(), 16)
+                        if (l >= 0) {
+                            buffer.write((u shl 4) + l)
+                            i += 3
+                            continue
+                        }
+                    }
+                }
+                buffer.write(b)
+            } else {
+                buffer.write(b)
+            }
+            i++
+        }
+        return buffer.toByteArray()
+    }
+
     /**
      * 获取当前url文件信息
      */
@@ -194,7 +243,7 @@ open class WebDav(
         for (element in elements) {
             //依然是优化支持 caddy 自建的 WebDav ,其目录后缀都为“/”, 所以删除“/”的判定,不然无法获取该目录项
             val href = element.findNS("href", ns)[0].text()
-            val hrefDecode = URLDecoder.decodeForPath(href, Charsets.UTF_8)
+            val hrefDecode = decodeForPath(href)
             val fileName = hrefDecode.removeSuffix("/").substringAfterLast("/")
             val webDavFile: WebDav
             try {
@@ -204,7 +253,7 @@ open class WebDav(
                 val displayName = element
                     .findNS("displayname", ns)
                     .firstOrNull()?.text()?.takeIf { it.isNotEmpty() }
-                    ?.let { URLDecoder.decodeForPath(it, Charsets.UTF_8) } ?: fileName
+                    ?.let { decodeForPath(it) } ?: fileName
                 val contentType = element
                     .findNS("getcontenttype", ns)
                     .firstOrNull()?.text().orEmpty()

+ 17 - 20
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeByRegex.kt

@@ -1,38 +1,35 @@
 package io.legado.app.model.analyzeRule
 
 import androidx.annotation.Keep
-import java.util.regex.Pattern
 
 @Keep
 object AnalyzeByRegex {
 
     fun getElement(res: String, regs: Array<String>, index: Int = 0): List<String>? {
         var vIndex = index
-        val resM = Pattern.compile(regs[vIndex]).matcher(res)
-        if (!resM.find()) {
-            return null
-        }
+        val regex = Regex(regs[vIndex])
+        val firstMatch = regex.find(res) ?: return null
         // 判断索引的规则是最后一个规则
         return if (vIndex + 1 == regs.size) {
             // 新建容器
             val info = arrayListOf<String>()
-            for (groupIndex in 0..resM.groupCount()) {
-                info.add(resM.group(groupIndex)!!)
+            for (groupIndex in 0..firstMatch.groupValues.lastIndex) {
+                info.add(firstMatch.groupValues[groupIndex])
             }
             info
         } else {
             val result = StringBuilder()
-            do {
-                result.append(resM.group())
-            } while (resM.find())
+            for (m in regex.findAll(res)) {
+                result.append(m.value)
+            }
             getElement(result.toString(), regs, ++vIndex)
         }
     }
 
     fun getElements(res: String, regs: Array<String>, index: Int = 0): List<List<String>> {
         var vIndex = index
-        val resM = Pattern.compile(regs[vIndex]).matcher(res)
-        if (!resM.find()) {
+        val regex = Regex(regs[vIndex])
+        if (regex.find(res) == null) {
             return arrayListOf()
         }
         // 判断索引的规则是最后一个规则
@@ -40,21 +37,21 @@ object AnalyzeByRegex {
             // 创建书息缓存数组
             val books = ArrayList<List<String>>()
             // 提取列表
-            do {
+            for (m in regex.findAll(res)) {
                 // 新建容器
                 val info = arrayListOf<String>()
-                for (groupIndex in 0..resM.groupCount()) {
-                    info.add(resM.group(groupIndex) ?: "")
+                for (groupIndex in 0..m.groupValues.lastIndex) {
+                    info.add(m.groupValues[groupIndex])
                 }
                 books.add(info)
-            } while (resM.find())
+            }
             return books
         } else {
             val result = StringBuilder()
-            do {
-                result.append(resM.group())
-            } while (resM.find())
+            for (m in regex.findAll(res)) {
+                result.append(m.value)
+            }
             return getElements(result.toString(), regs, ++vIndex)
         }
     }
-}
+}

+ 53 - 61
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -45,7 +45,6 @@ import org.mozilla.javascript.Scriptable
 import java.lang.ref.WeakReference
 import java.net.URL
 import java.util.Locale
-import java.util.regex.Pattern
 import kotlin.coroutines.ContinuationInterceptor
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
@@ -453,10 +452,9 @@ class AnalyzeRule(
      */
     private fun splitPutRule(ruleStr: String, putMap: HashMap<String, String>): String {
         var vRuleStr = ruleStr
-        val putMatcher = putPattern.matcher(vRuleStr)
-        while (putMatcher.find()) {
-            vRuleStr = vRuleStr.replace(putMatcher.group(), "")
-            val putJsonStr = putMatcher.group(1)
+        for (putMatch in putPattern.findAll(vRuleStr)) {
+            vRuleStr = vRuleStr.replace(putMatch.value, "")
+            val putJsonStr = putMatch.groupValues[1]
             val putJson = GSONStrict.fromJsonObject<Map<String, String>>(putJsonStr)
                 .getOrNull()
             if (putJson != null) {
@@ -487,10 +485,9 @@ class AnalyzeRule(
         if (rule.replaceFirst) {
             /* ##match##replace### 获取第一个匹配到的结果并进行替换 */
             if (regex != null) kotlin.runCatching {
-                val pattern = regex.toPattern()
-                val matcher = pattern.matcher(result)
-                return if (matcher.find()) {
-                    matcher.group(0)!!.replaceFirst(regex, replacement)
+                val match = regex.find(result)
+                return if (match != null) {
+                    match.value.replaceFirst(regex, replacement)
                 } else {
                     ""
                 }
@@ -542,28 +539,26 @@ class AnalyzeRule(
             mMode = Mode.Regex
         }
         var tmp: String
-        val jsMatcher = JS_PATTERN.matcher(ruleStr)
-        while (jsMatcher.find()) {
-            if (jsMatcher.start() > start) {
-                tmp = ruleStr.substring(start, jsMatcher.start()).trim { it <= ' ' }
+        for (jsMatch in JS_PATTERN.findAll(ruleStr)) {
+            if (jsMatch.range.first > start) {
+                tmp = ruleStr.substring(start, jsMatch.range.first).trim { it <= ' ' }
                 if (tmp.isNotEmpty()) {
                     ruleList.add(SourceRule(tmp, mMode))
                 }
             }
-            ruleList.add(SourceRule(jsMatcher.group(2) ?: jsMatcher.group(1), Mode.Js))
-            start = jsMatcher.end()
+            ruleList.add(SourceRule(jsMatch.groupValues[2].ifEmpty { jsMatch.groupValues[1] }, Mode.Js))
+            start = jsMatch.range.last + 1
         }
 
-        val webJsMatcher = WebJS_PATTERN.matcher(ruleStr)
-        while (webJsMatcher.find()) {
-            if (webJsMatcher.start() > start) {
-                tmp = ruleStr.substring(start, webJsMatcher.start()).trim { it <= ' ' }
+        for (webJsMatch in WebJS_PATTERN.findAll(ruleStr)) {
+            if (webJsMatch.range.first > start) {
+                tmp = ruleStr.substring(start, webJsMatch.range.first).trim { it <= ' ' }
                 if (tmp.isNotEmpty()) {
                     ruleList.add(SourceRule(tmp, mMode))
                 }
             }
-            ruleList.add(SourceRule(webJsMatcher.group(1) ?: "", Mode.WebJs))
-            start = webJsMatcher.end()
+            ruleList.add(SourceRule(webJsMatch.groupValues[1], Mode.WebJs))
+            start = webJsMatch.range.last + 1
         }
 
         if (ruleStr.length > start) {
@@ -640,38 +635,37 @@ class AnalyzeRule(
             //@get,{{ }}, 拆分
             var start = 0
             var tmp: String
-            val evalMatcher = evalPattern.matcher(rule)
-
-            if (evalMatcher.find()) {
-                tmp = rule.substring(start, evalMatcher.start())
+            val firstMatch = evalPattern.find(rule)
+            if (firstMatch != null) {
+                tmp = rule.substring(start, firstMatch.range.first)
                 if (mode != Mode.Js && mode != Mode.Regex &&
-                    (evalMatcher.start() == 0 || !tmp.contains("##"))
+                    (firstMatch.range.first == 0 || !tmp.contains("##"))
                 ) {
                     mode = Mode.Regex
                 }
-                do {
-                    if (evalMatcher.start() > start) {
-                        tmp = rule.substring(start, evalMatcher.start())
-                        splitRegex(tmp)
+            }
+            for (evalMatch in evalPattern.findAll(rule)) {
+                if (evalMatch.range.first > start) {
+                    tmp = rule.substring(start, evalMatch.range.first)
+                    splitRegex(tmp)
+                }
+                tmp = evalMatch.value
+                when {
+                    tmp.startsWith("@get:", true) -> {
+                        ruleType.add(getRuleType)
+                        ruleParam.add(tmp.substring(6, tmp.lastIndex))
                     }
-                    tmp = evalMatcher.group()
-                    when {
-                        tmp.startsWith("@get:", true) -> {
-                            ruleType.add(getRuleType)
-                            ruleParam.add(tmp.substring(6, tmp.lastIndex))
-                        }
 
-                        tmp.startsWith("{{") -> {
-                            ruleType.add(jsRuleType)
-                            ruleParam.add(tmp.substring(2, tmp.length - 2))
-                        }
+                    tmp.startsWith("{{") -> {
+                        ruleType.add(jsRuleType)
+                        ruleParam.add(tmp.substring(2, tmp.length - 2))
+                    }
 
-                        else -> {
-                            splitRegex(tmp)
-                        }
+                    else -> {
+                        splitRegex(tmp)
                     }
-                    start = evalMatcher.end()
-                } while (evalMatcher.find())
+                }
+                start = evalMatch.range.last + 1
             }
             if (rule.length > start) {
                 tmp = rule.substring(start)
@@ -686,23 +680,21 @@ class AnalyzeRule(
             var start = 0
             var tmp: String
             val ruleStrArray = ruleStr.split("##")
-            val regexMatcher = regexPattern.matcher(ruleStrArray[0])
-
-            if (regexMatcher.find()) {
+            if (regexPattern.find(ruleStrArray[0]) != null) {
                 if (mode != Mode.Js && mode != Mode.Regex) {
                     mode = Mode.Regex
                 }
-                do {
-                    if (regexMatcher.start() > start) {
-                        tmp = ruleStr.substring(start, regexMatcher.start())
-                        ruleType.add(defaultRuleType)
-                        ruleParam.add(tmp)
-                    }
-                    tmp = regexMatcher.group()
-                    ruleType.add(tmp.substring(1).toInt())
+            }
+            for (regexMatch in regexPattern.findAll(ruleStrArray[0])) {
+                if (regexMatch.range.first > start) {
+                    tmp = ruleStr.substring(start, regexMatch.range.first)
+                    ruleType.add(defaultRuleType)
                     ruleParam.add(tmp)
-                    start = regexMatcher.end()
-                } while (regexMatcher.find())
+                }
+                tmp = regexMatch.value
+                ruleType.add(tmp.substring(1).toInt())
+                ruleParam.add(tmp)
+                start = regexMatch.range.last + 1
             }
             if (ruleStr.length > start) {
                 tmp = ruleStr.substring(start)
@@ -949,10 +941,10 @@ class AnalyzeRule(
     }
 
     companion object {
-        private val putPattern = Pattern.compile("@put:(\\{[^}]+?\\})", Pattern.CASE_INSENSITIVE)
+        private val putPattern = Regex("@put:(\\{[^}]+?\\})", RegexOption.IGNORE_CASE)
         private val evalPattern =
-            Pattern.compile("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", Pattern.CASE_INSENSITIVE)
-        private val regexPattern = Pattern.compile("\\$\\d{1,2}")
+            Regex("@get:\\{[^}]+?\\}|\\{\\{[\\w\\W]*?\\}\\}", RegexOption.IGNORE_CASE)
+        private val regexPattern = Regex("\\$\\d{1,2}")
 
         fun AnalyzeRule.setCoroutineContext(context: CoroutineContext): AnalyzeRule {
             coroutineContext = context.minusKey(ContinuationInterceptor)

+ 45 - 27
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -4,9 +4,6 @@ import android.annotation.SuppressLint
 import android.util.Base64
 import androidx.annotation.Keep
 import androidx.media3.common.MediaItem
-import cn.hutool.core.codec.PercentCodec
-import cn.hutool.core.net.RFC3986
-import cn.hutool.core.util.HexUtil
 import com.bumptech.glide.load.model.GlideUrl
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
@@ -21,6 +18,7 @@ import io.legado.app.help.CacheManager
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.JsExtensions
 import io.legado.app.help.config.AppConfig
+import io.legado.app.help.crypto.toHexString
 import io.legado.app.help.exoplayer.ExoPlayerHelper
 import io.legado.app.help.glide.GlideHeaders
 import io.legado.app.help.http.BackstageWebView
@@ -65,7 +63,6 @@ import java.net.URLEncoder
 import java.nio.charset.Charset
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.TimeUnit
-import java.util.regex.Pattern
 import kotlin.coroutines.ContinuationInterceptor
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
@@ -128,8 +125,8 @@ class AnalyzeUrl(
 
     init {
         coroutineContext = coroutineContext.minusKey(ContinuationInterceptor)
-        val urlMatcher = paramPattern.matcher(baseUrl)
-        if (urlMatcher.find()) baseUrl = baseUrl.substring(0, urlMatcher.start())
+        val urlMatch = paramPattern.find(baseUrl)
+        if (urlMatch != null) baseUrl = baseUrl.substring(0, urlMatch.range.first)
         (headerMapF ?: runScriptWithContext(coroutineContext) {
             source?.getHeaderMap(AppConfig.userAgent, hasLoginHeader)
         })?.let {
@@ -161,18 +158,17 @@ class AnalyzeUrl(
      */
     private fun analyzeJs() {
         var start = 0
-        val jsMatcher = AppPattern.JS_PATTERN.matcher(ruleUrl)
         var result = ruleUrl
-        while (jsMatcher.find()) {
-            if (jsMatcher.start() > start) {
-                ruleUrl.substring(start, jsMatcher.start()).trim().let {
+        for (m in AppPattern.JS_PATTERN.findAll(ruleUrl)) {
+            if (m.range.first > start) {
+                ruleUrl.substring(start, m.range.first).trim().let {
                     if (it.isNotEmpty()) {
                         result = it.replace("@result", result)
                     }
                 }
             }
-            result = evalJS(jsMatcher.group(2) ?: jsMatcher.group(1), result).toString()
-            start = jsMatcher.end()
+            result = evalJS(m.groupValues[2].ifEmpty { m.groupValues[1] }, result).toString()
+            start = m.range.last + 1
         }
         if (ruleUrl.length > start) {
             ruleUrl.substring(start).trim().let {
@@ -204,13 +200,12 @@ class AnalyzeUrl(
         }
         //page
         page?.let {
-            val matcher = pagePattern.matcher(ruleUrl)
-            while (matcher.find()) {
-                val pages = matcher.group(1)!!.split(",")
+            for (m in pagePattern.findAll(ruleUrl)) {
+                val pages = m.groupValues[1].split(",")
                 ruleUrl = if (page < pages.size) { //pages[pages.size - 1]等同于pages.last()
-                    ruleUrl.replace(matcher.group(), pages[page - 1].trim { it <= ' ' })
+                    ruleUrl.replace(m.value, pages[page - 1].trim { it <= ' ' })
                 } else {
-                    ruleUrl.replace(matcher.group(), pages.last().trim { it <= ' ' })
+                    ruleUrl.replace(m.value, pages.last().trim { it <= ' ' })
                 }
             }
         }
@@ -221,15 +216,15 @@ class AnalyzeUrl(
      */
     private fun analyzeUrl() {
         //replaceKeyPageJs已经替换掉额外内容,此处url是基础形式,可以直接切首个‘,’之前字符串。
-        val urlMatcher = paramPattern.matcher(ruleUrl)
+        val urlMatch = paramPattern.find(ruleUrl)
         val urlNoOption =
-            if (urlMatcher.find()) ruleUrl.substring(0, urlMatcher.start()) else ruleUrl
+            if (urlMatch != null) ruleUrl.substring(0, urlMatch.range.first) else ruleUrl
         url = NetworkUtils.getAbsoluteURL(baseUrl, urlNoOption)
         NetworkUtils.getBaseUrl(url)?.let {
             baseUrl = it
         }
-        if (urlNoOption.length != ruleUrl.length) {
-            val urlOptionStr = ruleUrl.substring(urlMatcher.end())
+        if (urlMatch != null) {
+            val urlOptionStr = ruleUrl.substring(urlMatch.range.last + 1)
             var urlOption = GSONStrict.fromJsonObject<UrlOption>(urlOptionStr).getOrNull()
             if (urlOption == null) {
                 urlOption = GSON.fromJsonObject<UrlOption>(urlOptionStr).getOrNull()
@@ -312,7 +307,7 @@ class AnalyzeUrl(
             if (NetworkUtils.encodedQuery(params)) {
                 return params
             }
-            return queryEncoder.encode(params, charset)
+            return encodeQueryParams(params, charset)
         }
         val len = params.length
         val sb = StringBuilder()
@@ -421,7 +416,7 @@ class AnalyzeUrl(
         skipRateLimit: Boolean = false
     ): StrResponse {
         if (type != null) {
-            return StrResponse(url, HexUtil.encodeHexStr(getByteArrayAwait()))
+            return StrResponse(url, getByteArrayAwait().toHexString())
         }
         if (skipRateLimit) {
             return executeStrRequest(jsStr, sourceRegex, useWebView, isTest)
@@ -776,10 +771,33 @@ class AnalyzeUrl(
     }
 
     companion object {
-        val paramPattern: Pattern = Pattern.compile("\\s*,\\s*(?=\\{)")
-        private val pagePattern = Pattern.compile("<(.*?)>")
-        private val queryEncoder =
-            RFC3986.UNRESERVED.orNew(PercentCodec.of("!$%&()*+,/:;=?@[\\]^`{|}"))
+        val paramPattern: Regex = Regex("\\s*,\\s*(?=\\{)")
+        private val pagePattern = Regex("<(.*?)>")
+        private const val QUERY_HEX_UPPER = "0123456789ABCDEF"
+        private val queryEncoderSafeChars: BooleanArray = BooleanArray(128).apply {
+            for (c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~!$%&()*+,/:;=?@[\\]^`{|}") {
+                this[c.code] = true
+            }
+        }
+
+        private fun encodeQueryParams(str: String, charset: Charset): String {
+            val builder = StringBuilder(str.length)
+            for (c in str) {
+                if (c.code < 128 && queryEncoderSafeChars[c.code]) {
+                    builder.append(c)
+                } else {
+                    val bytes = c.toString().toByteArray(charset)
+                    for (b in bytes) {
+                        val v = b.toInt() and 0xFF
+                        builder.append('%')
+                        builder.append(QUERY_HEX_UPPER[v ushr 4])
+                        builder.append(QUERY_HEX_UPPER[v and 0x0F])
+                    }
+                }
+            }
+            return builder.toString()
+        }
+
         val customIp by lazy { ConcurrentHashMap<String, String>() }
         fun AnalyzeUrl.getMediaItem(): MediaItem {
             setCookie()

+ 4 - 4
app/src/main/java/io/legado/app/model/analyzeRule/CustomUrl.kt

@@ -10,13 +10,13 @@ class CustomUrl(url: String) {
     private val attribute = hashMapOf<String, Any>()
 
     init {
-        val urlMatcher = AnalyzeUrl.paramPattern.matcher(url)
-        mUrl = if (urlMatcher.find()) {
-            val attr = url.substring(urlMatcher.end())
+        val urlMatch = AnalyzeUrl.paramPattern.find(url)
+        mUrl = if (urlMatch != null) {
+            val attr = url.substring(urlMatch.range.last + 1)
             GSON.fromJsonObject<Map<String, Any>>(attr).getOrNull()?.let {
                 attribute.putAll(it)
             }
-            url.substring(0, urlMatcher.start())
+            url.substring(0, urlMatch.range.first)
         } else {
             url
         }

+ 8 - 9
app/src/main/java/io/legado/app/model/localBook/LocalBook.kt

@@ -65,7 +65,6 @@ import java.io.FileInputStream
 import java.io.FileNotFoundException
 import java.io.FileOutputStream
 import java.io.InputStream
-import java.util.regex.Pattern
 
 /**
  * 书籍文件导入 目录正文解析
@@ -74,10 +73,10 @@ import java.util.regex.Pattern
 object LocalBook {
 
     private val nameAuthorPatterns = arrayOf(
-        Pattern.compile("(.*?)《([^《》]+)》.*?作者:(.*)"),
-        Pattern.compile("(.*?)《([^《》]+)》(.*)"),
-        Pattern.compile("(^)(.+) 作者:(.+)$"),
-        Pattern.compile("(^)(.+) by (.+)$")
+        Regex("(.*?)《([^《》]+)》.*?作者:(.*)"),
+        Regex("(.*?)《([^《》]+)》(.*)"),
+        Regex("(^)(.+) 作者:(.+)$"),
+        Regex("(^)(.+) by (.+)$")
     )
 
     @Throws(FileNotFoundException::class, SecurityException::class)
@@ -407,10 +406,10 @@ object LocalBook {
         }
         if (name.isBlank()) {
             for (pattern in nameAuthorPatterns) {
-                pattern.matcher(tempFileName).takeIf { it.find() }?.run {
-                    name = group(2)!!
-                    val group1 = group(1) ?: ""
-                    val group3 = group(3) ?: ""
+                pattern.find(tempFileName)?.let { m ->
+                    name = m.groupValues[2]
+                    val group1 = m.groupValues[1]
+                    val group3 = m.groupValues[3]
                     author = BookHelp.formatBookAuthor(group1 + group3)
                     return Pair(name, author)
                 }

+ 19 - 24
app/src/main/java/io/legado/app/model/localBook/TextFile.kt

@@ -16,8 +16,6 @@ import io.legado.app.utils.StringUtils
 import io.legado.app.utils.Utf8BomUtils
 import java.io.FileNotFoundException
 import java.nio.charset.Charset
-import java.util.regex.Matcher
-import java.util.regex.Pattern
 import java.util.regex.PatternSyntaxException
 import kotlin.math.min
 
@@ -97,7 +95,7 @@ class TextFile(private var book: Book) {
             }
         }
         val volumePattern = getVolumePattern(book.tocUrl)
-        val (toc, wordCount) = analyze(book.tocUrl.toPattern(Pattern.MULTILINE), volumePattern)
+        val (toc, wordCount) = analyze(Regex(book.tocUrl, RegexOption.MULTILINE), volumePattern)
         book.wordCount = StringUtils.wordCountFormat(wordCount)
         book.upKind()
         toc.forEachIndexed { index, bookChapter ->
@@ -156,8 +154,8 @@ class TextFile(private var book: Book) {
     /**
      * 按规则解析目录
      */
-    private fun analyze(pattern: Pattern?, volumePattern: Pattern? = null): Pair<ArrayList<BookChapter>, Int> {
-        if (pattern == null || pattern.pattern().isNullOrEmpty()) {
+    private fun analyze(pattern: Regex?, volumePattern: Regex? = null): Pair<ArrayList<BookChapter>, Int> {
+        if (pattern == null || pattern.pattern.isEmpty()) {
             return analyze()
         }
         val toc = arrayListOf<BookChapter>()
@@ -198,10 +196,8 @@ class TextFile(private var book: Book) {
                 //当前Block下使过的String的指针
                 var seekPos = 0
                 //进行正则匹配
-                val matcher: Matcher = pattern.matcher(blockContent)
-                //如果存在相应章节
-                while (matcher.find()) { //获取匹配到的字符在字符串中的起始位置
-                    val chapterStart = matcher.start()
+                for (m in pattern.findAll(blockContent)) { //获取匹配到的字符在字符串中的起始位置
+                    val chapterStart = m.range.first
                     //获取章节内容
                     val chapterContent = blockContent.substring(seekPos, chapterStart)
                     val chapterLength = chapterContent.toByteArray(charset).size.toLong()
@@ -226,7 +222,7 @@ class TextFile(private var book: Book) {
                         bookWordCount += wordCount
                         //创建当前章节
                         val curChapter = BookChapter()
-                        curChapter.title = matcher.group()
+                        curChapter.title = m.value
                         curChapter.start = curOffset + chapterLength
                         curChapter.end = curChapter.start
                         toc.add(curChapter)
@@ -254,7 +250,7 @@ class TextFile(private var book: Book) {
                             }
                             //创建当前章节
                             val curChapter = BookChapter()
-                            curChapter.title = matcher.group()
+                            curChapter.title = m.value
                             curChapter.start = curOffset + chapterLength
                             curChapter.end = curChapter.start
                             toc.add(curChapter)
@@ -265,7 +261,7 @@ class TextFile(private var book: Book) {
                                 lastChapter.isVolume =
                                     chapterContent.substringAfter(lastChapter.title).isBlank()
                             } else {
-                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                                lastChapter.isVolume = volumePattern.containsMatchIn(lastChapter.title)
                             }
                             //将当前段落添加上一章去
                             lastChapter.end = lastChapter.end!! + chapterLength
@@ -274,7 +270,7 @@ class TextFile(private var book: Book) {
                                 StringUtils.wordCountFormat(lastChapterWordCount)
                             //创建当前章节
                             val curChapter = BookChapter()
-                            curChapter.title = matcher.group()
+                            curChapter.title = m.value
                             curChapter.start = lastChapter.end
                             curChapter.end = curChapter.start
                             toc.add(curChapter)
@@ -289,7 +285,7 @@ class TextFile(private var book: Book) {
                                 lastChapter.isVolume =
                                     chapterContent.substringAfter(lastChapter.title).isBlank()
                             } else {
-                                lastChapter.isVolume = volumePattern.matcher(lastChapter.title).find()
+                                lastChapter.isVolume = volumePattern.containsMatchIn(lastChapter.title)
                             }
                             lastChapter.end =
                                 lastChapter.start!! + chapterLength
@@ -297,13 +293,13 @@ class TextFile(private var book: Book) {
                                 StringUtils.wordCountFormat(chapterContent.length)
                             //创建当前章节
                             val curChapter = BookChapter()
-                            curChapter.title = matcher.group()
+                            curChapter.title = m.value
                             curChapter.start = lastChapter.end
                             curChapter.end = curChapter.start
                             toc.add(curChapter)
                         } else { //如果章节不存在则创建章节
                             val curChapter = BookChapter()
-                            curChapter.title = matcher.group()
+                            curChapter.title = m.value
                             curChapter.start = curOffset
                             curChapter.end = curOffset
                             curChapter.wordCount =
@@ -330,7 +326,7 @@ class TextFile(private var book: Book) {
             // 检查最后一章是否为分卷(循环中无法检测到最后一章),并处理长章节拆分
             toc.lastOrNull()?.let { chapter ->
                 if (volumePattern != null) {
-                    chapter.isVolume = volumePattern.matcher(chapter.title).find()
+                    chapter.isVolume = volumePattern.containsMatchIn(chapter.title)
                 }
                 //章节字数太多进行拆分
                 if (book.getSplitLongChapter() && chapter.end!! - chapter.start!! > maxLengthWithToc) {
@@ -483,18 +479,17 @@ class TextFile(private var book: Book) {
         var bestRule: TxtTocRule? = null
         for (tocRule in rules) {
             val pattern = try {
-                tocRule.chapterRule.toPattern(Pattern.MULTILINE)
+                Regex(tocRule.chapterRule, RegexOption.MULTILINE)
             } catch (e: PatternSyntaxException) {
                 AppLog.put("TXT目录规则正则语法错误:${tocRule.name}\n$e", e)
                 continue
             }
-            val matcher = pattern.matcher(content)
             var start = 0
             var num = 0
-            while (matcher.find()) {
-                if (start == 0 || matcher.start() - start > 1000) {
+            for (m in pattern.findAll(content)) {
+                if (start == 0 || m.range.first - start > 1000) {
                     num++
-                    start = matcher.end()
+                    start = m.range.last + 1
                 }
             }
             if (num >= maxNum) {
@@ -508,13 +503,13 @@ class TextFile(private var book: Book) {
     /**
      * 根据章节正则查找对应的分卷正则(搜索全部规则,含已禁用的)
      */
-    private fun getVolumePattern(chapterPattern: String): Pattern? {
+    private fun getVolumePattern(chapterPattern: String): Regex? {
         if (chapterPattern.isBlank()) return null
         val rule = appDb.txtTocRuleDao.all.find { it.chapterRule == chapterPattern }
         val volumeRule = rule?.volumeRule
         if (volumeRule.isNullOrBlank()) return null
         return try {
-            volumeRule.toPattern(Pattern.MULTILINE)
+            Regex(volumeRule, RegexOption.MULTILINE)
         } catch (e: PatternSyntaxException) {
             AppLog.put("TXT分卷规则正则语法错误:${rule?.name}\n$e", e)
             null

+ 8 - 8
app/src/main/java/io/legado/app/model/rss/RssParserDefault.kt

@@ -122,14 +122,14 @@ object RssParserDefault {
     private fun getImageUrl(input: String): String? {
 
         var url: String? = null
-        val patternImg = "(<img [^>]*>)".toPattern()
-        val matcherImg = patternImg.matcher(input)
-        if (matcherImg.find()) {
-            val imgTag = matcherImg.group(1)
-            val patternLink = "src\\s*=\\s*\"([^\"]+)\"".toPattern()
-            val matcherLink = patternLink.matcher(imgTag!!)
-            if (matcherLink.find()) {
-                url = matcherLink.group(1)!!.trim()
+        val regexImg = Regex("<img [^>]*>")
+        val matchImg = regexImg.find(input)
+        if (matchImg != null) {
+            val imgTag = matchImg.value
+            val regexLink = Regex("src\\s*=\\s*\"([^\"]+)\"")
+            val matchLink = regexLink.find(imgTag)
+            if (matchLink != null) {
+                url = matchLink.groupValues[1].trim()
             }
         }
         return url

+ 4 - 6
app/src/main/java/io/legado/app/service/ExportBookService.kt

@@ -427,9 +427,8 @@ class ExportBookService : BaseService(), KoinComponent {
             //txt导出图片文件 - only for original source
             val srcList = arrayListOf<SrcData>()
             content?.split("\n")?.forEachIndexed { index, text ->
-                val matcher = AppPattern.imgPattern.matcher(text)
-                while (matcher.find()) {
-                    matcher.group(1)?.let {
+                for (m in AppPattern.imgPattern.findAll(text)) {
+                    m.groupValues[1].takeIf { it.isNotEmpty() }?.let {
                         val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
                         srcList.add(SrcData(chapter.title, index, src))
                     }
@@ -727,9 +726,8 @@ class ExportBookService : BaseService(), KoinComponent {
         val resources = arrayListOf<Resource>()
         content.split("\n").forEach { text ->
             var text1 = text
-            val matcher = AppPattern.imgPattern.matcher(text)
-            while (matcher.find()) {
-                matcher.group(1)?.let { rawSrc ->
+            for (m in AppPattern.imgPattern.findAll(text)) {
+                m.groupValues[1].takeIf { it.isNotEmpty() }?.let { rawSrc ->
                     val src = NetworkUtils.getAbsoluteURL(chapter.url, rawSrc)
                     val originalHref =
                         "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"

+ 3 - 3
app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt

@@ -112,10 +112,10 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
                 }
                 val baseUrl = NetworkUtils.getBaseUrl(bookUrl)
                     ?: throw NoStackTraceException("书籍地址格式不对")
-                val urlMatcher = AnalyzeUrl.paramPattern.matcher(bookUrl)
-                if (urlMatcher.find()) {
+                val urlMatch = AnalyzeUrl.paramPattern.find(bookUrl)
+                if (urlMatch != null) {
                     val origin = GSON.fromJsonObject<AnalyzeUrl.UrlOption>(
-                        bookUrl.substring(urlMatcher.end())
+                        bookUrl.substring(urlMatch.range.last + 1)
                     ).getOrNull()?.getOrigin()
                     origin?.let {
                         val source = appDb.bookSourceDao.getBookSource(it)

+ 6 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt

@@ -23,7 +23,6 @@ import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
-import cn.hutool.core.date.DateUtil
 import io.legado.app.R
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
@@ -34,7 +33,9 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.formatReadDuration
-import java.util.Date
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
 
 @Composable
 fun BookReadRecordSheet(
@@ -142,7 +143,9 @@ private fun TimelineSessionRow(session: ReadRecordSession) {
         Spacer(modifier = Modifier.width(8.dp))
         Column(modifier = Modifier.weight(1f)) {
             AppText(
-                text = DateUtil.format(Date(session.endTime), "HH:mm"),
+                text = Instant.ofEpochMilli(session.endTime)
+                    .atZone(ZoneId.systemDefault())
+                    .format(DateTimeFormatter.ofPattern("HH:mm")),
                 style = LegadoTheme.typography.bodyMedium,
                 fontWeight = FontWeight.SemiBold
             )

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt

@@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class BookCharacterDetailViewModel(
     bookUrl: String,
@@ -168,7 +168,7 @@ class BookCharacterDetailViewModel(
                 val now = System.currentTimeMillis()
                 val existing = currentProfile
                 val profile = BookCharacterProfile(
-                    id = existing?.id ?: state.characterId ?: UUID.randomUUID().toString(),
+                    id = existing?.id ?: state.characterId ?: Uuid.random().toString(),
                     bookUrl = state.bookUrl,
                     name = name,
                     aliasesJson = state.aliasesText.toAliasesJson(),

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt

@@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class BookCharacterNetworkViewModel(
     bookUrl: String,
@@ -156,7 +156,7 @@ class BookCharacterNetworkViewModel(
                 val toProfile = findOrCreateProfile(toName)
                 val now = System.currentTimeMillis()
                 val relation = BookCharacterRelation(
-                    id = UUID.randomUUID().toString(),
+                    id = Uuid.random().toString(),
                     bookUrl = state.bookUrl,
                     fromCharacterId = fromProfile.id,
                     toCharacterId = toProfile.id,
@@ -259,7 +259,7 @@ class BookCharacterNetworkViewModel(
         }
         val now = System.currentTimeMillis()
         val profile = BookCharacterProfile(
-            id = UUID.randomUUID().toString(),
+            id = Uuid.random().toString(),
             bookUrl = bookUrl,
             name = name,
             source = BookCharacterProfile.SOURCE_USER,

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailViewModel.kt

@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class BookEventDetailViewModel(
     bookUrl: String,
@@ -119,7 +119,7 @@ class BookEventDetailViewModel(
                         ?: withContext(Dispatchers.IO) {
                             val now = System.currentTimeMillis()
                             val profile = io.legado.app.data.entities.BookCharacterProfile(
-                                id = UUID.randomUUID().toString(),
+                                id = Uuid.random().toString(),
                                 bookUrl = state.bookUrl,
                                 name = charName,
                                 source = io.legado.app.data.entities.BookCharacterProfile.SOURCE_USER,
@@ -134,7 +134,7 @@ class BookEventDetailViewModel(
                 val now = System.currentTimeMillis()
                 val existing = currentEvent
                 val event = BookCharacterEvent(
-                    id = existing?.id ?: state.eventId ?: UUID.randomUUID().toString(),
+                    id = existing?.id ?: state.eventId ?: Uuid.random().toString(),
                     bookUrl = state.bookUrl,
                     characterId = character?.id.orEmpty(),
                     chapterTitle = state.chapterTitle.trim(),

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt

@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class BookKnowledgeDetailViewModel(
     bookUrl: String,
@@ -137,7 +137,7 @@ class BookKnowledgeDetailViewModel(
                 val now = System.currentTimeMillis()
                 val existing = currentEntry
                 val entry = BookKnowledgeEntry(
-                    id = existing?.id ?: state.entryId ?: UUID.randomUUID().toString(),
+                    id = existing?.id ?: state.entryId ?: Uuid.random().toString(),
                     bookUrl = state.bookUrl,
                     type = state.type,
                     title = title,

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarImage.kt

@@ -9,7 +9,7 @@ import android.graphics.RectF
 import android.net.Uri
 import androidx.core.net.toUri
 import java.io.File
-import java.util.UUID
+import kotlin.uuid.Uuid
 import kotlin.math.ceil
 import kotlin.math.max
 
@@ -60,7 +60,7 @@ fun saveCharacterAvatar(
     )
 
     val dir = File(context.filesDir, "character_avatars").apply { mkdirs() }
-    val file = File(dir, "${UUID.randomUUID()}.jpg")
+    val file = File(dir, "${Uuid.random()}.jpg")
     file.outputStream().use { stream ->
         output.compress(Bitmap.CompressFormat.JPEG, 92, stream)
     }

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.util.Date
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 /**
  * 阅读页 AI 域:章节摘要、划词净化、划词重写、重写预设配置。
@@ -1009,7 +1009,7 @@ class ReadAiDelegate(
         val editingId = config.editingPresetId
         val savedPresets = if (editingId == null) {
             config.presets + AiRewritePresetUi(
-                id = UUID.randomUUID().toString(),
+                id = Uuid.random().toString(),
                 name = name,
                 instruction = instruction,
             )

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -28,11 +28,11 @@ import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentMapOf
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 @Stable
 data class ReminderUiState(
-    val id: String = UUID.randomUUID().toString(),
+    val id: String = Uuid.random().toString(),
     val message: String,
     val actionText: String? = null,
     val actionIntent: ReadBookIntent? = null,

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -588,9 +588,9 @@ class ReadBookController(
     }
 
     override fun oldClickImg(src: String): Boolean {
-        val urlMatcher = paramPattern.matcher(src)
-        if (urlMatcher.find()) {
-            val urlOptionStr = src.substring(urlMatcher.end())
+        val urlMatch = paramPattern.find(src)
+        if (urlMatch != null) {
+            val urlOptionStr = src.substring(urlMatch.range.last + 1)
             val urlOptionMap = GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
             val click = urlOptionMap?.get("click")
             if (click != null) {
@@ -626,7 +626,7 @@ class ReadBookController(
                         book.bookUrl,
                         ReadBook.durChapterIndex
                     ) ?: throw Exception("no find chapter")
-                    val urlNoOption = src.take(urlMatcher.start())
+                    val urlNoOption = src.take(urlMatch.range.first)
                     AnalyzeRule(book, source).apply {
                         setCoroutineContext(coroutineContext)
                         setBaseUrl(chapter.url)

+ 25 - 24
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -402,14 +402,15 @@ class TextChapterLayout(
                 //图片样式为文字嵌入类型
                 val srcList = LinkedList<String>()
                 sb.setLength(0)
-                val matcher = AppPattern.imgPattern.matcher(text)
-                while (matcher.find()) {
-                    matcher.group(1)?.let { src ->
-                        srcList.add(src)
-                        matcher.appendReplacement(sb, srcReplaceChar)
-                    }
+                var last = 0
+                for (m in AppPattern.imgPattern.findAll(text)) {
+                    val src = m.groupValues[1]
+                    srcList.add(src)
+                    sb.append(text, last, m.range.first)
+                    sb.append(srcReplaceChar)
+                    last = m.range.last + 1
                 }
-                matcher.appendTail(sb)
+                sb.append(text, last, text.length)
                 text = sb.toString()
                 wordCount += text.replace(noWordCountRegex,"").length
                 setTypeText(
@@ -434,17 +435,16 @@ class TextChapterLayout(
                 sb.setLength(0)
                 var isFirstLine = true
                 if (content.contains("<img")) {
-                    val matcher = AppPattern.imgPattern.matcher(text)
-                    while (matcher.find()) {
+                    for (m in AppPattern.imgPattern.findAll(text)) {
                         currentCoroutineContext().ensureActive()
-                        val imgSrc = matcher.group(1)!!
+                        val imgSrc = m.groupValues[1]
                         var iStyle: String? = null
                         var click: String? = null
                         var imgSize = ImageProvider.getImageSize(book, imgSrc, bookSource)
-                        val urlMatcher = paramPattern.matcher(imgSrc)
-                        if (urlMatcher.find()) {
+                        val urlMatch = paramPattern.find(imgSrc)
+                        if (urlMatch != null) {
                             var width: String? = null
-                            val urlOptionStr = imgSrc.substring(urlMatcher.end())
+                            val urlOptionStr = imgSrc.substring(urlMatch.range.last + 1)
                             GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
                                 ?.let { map ->
                                     map.forEach { (key, value) ->
@@ -475,8 +475,8 @@ class TextChapterLayout(
                                 if (imgSize.width < 80 && imgSize.height < 80) "text" else imageStyle
                         }
 
-                        if (start < matcher.start()) {
-                            val textPart = text.substring(start, matcher.start())
+                        if (start < m.range.first) {
+                            val textPart = text.substring(start, m.range.first)
                             sb.append(textPart)
                         }
                         if (iStyle == "text" || iStyle == "TEXT") {
@@ -509,7 +509,7 @@ class TextChapterLayout(
                             bodyHighlightOffset += 1
                             isSetTypedImage = true
                         }
-                        start = matcher.end()
+                        start = m.range.last + 1
                     }
                 }
                 if (start < content.length) {
@@ -726,9 +726,9 @@ class TextChapterLayout(
                 spanned.getSpans(charIndex, charIndex + 1, ImageSpan::class.java).firstOrNull()
                     ?.let { span -> //处理图片
                         val source = span.source ?: return@let
-                        val urlMatcher = paramPattern.matcher(source)
-                        if (urlMatcher.find()) {
-                            val urlOptionStr = source.substring(urlMatcher.end())
+                        val urlMatch = paramPattern.find(source)
+                        if (urlMatch != null) {
+                            val urlOptionStr = source.substring(urlMatch.range.last + 1)
                             val style =
                                 GSON.fromJsonObject<Map<String, String>>(urlOptionStr).getOrNull()
                                     ?: return@let
@@ -1707,15 +1707,16 @@ class TextChapterLayout(
     }
 
     private fun String.replaceImagesForHighlight(): String {
-        val matcher = AppPattern.imgPattern.matcher(this)
-        if (!matcher.find()) return this
+        val iterator = AppPattern.imgPattern.findAll(this).iterator()
+        if (!iterator.hasNext()) return this
         return buildString(length) {
             var start = 0
             do {
-                append(this@replaceImagesForHighlight, start, matcher.start())
+                val m = iterator.next()
+                append(this@replaceImagesForHighlight, start, m.range.first)
                 append(srcReplaceChar)
-                start = matcher.end()
-            } while (matcher.find())
+                start = m.range.last + 1
+            } while (iterator.hasNext())
             append(this@replaceImagesForHighlight, start, this@replaceImagesForHighlight.length)
         }
     }

+ 8 - 4
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -66,7 +66,6 @@ import androidx.compose.ui.text.withStyle
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import cn.hutool.core.date.DateUtil
 import io.legado.app.R
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
@@ -114,9 +113,10 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.emptyFlow
 import org.koin.androidx.compose.koinViewModel
+import java.time.Instant
 import java.time.LocalDate
+import java.time.ZoneId
 import java.time.format.DateTimeFormatter
-import java.util.Date
 
 data class TimelineItem(
     val session: ReadRecordSession,
@@ -1064,7 +1064,9 @@ fun LatestReadItem(
     }
     val unknownAuthor = stringResource(R.string.unknown_author)
     val author = record.bookAuthor.ifBlank { unknownAuthor }
-    val lastReadText = DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")
+    val lastReadText = Instant.ofEpochMilli(record.lastRead)
+        .atZone(ZoneId.systemDefault())
+        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
     val itemDescription = stringResource(
         R.string.a11y_read_record_latest_item,
         record.bookName,
@@ -1161,7 +1163,9 @@ fun TimelineSessionItem(
         chapterTitle = title ?: fallbackChapterTitle
     }
 
-    val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm")
+    val endTimeText = Instant.ofEpochMilli(session.endTime)
+        .atZone(ZoneId.systemDefault())
+        .format(DateTimeFormatter.ofPattern("HH:mm"))
     val unknownAuthor = stringResource(R.string.unknown_author)
     val author = session.bookAuthor.ifBlank { unknownAuthor }
     val duration = formatDuring(session.endTime - session.startTime)

+ 8 - 5
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.book.readRecord
 import androidx.lifecycle.ViewModel
 import androidx.compose.runtime.Stable
 import androidx.lifecycle.viewModelScope
-import cn.hutool.core.date.DateUtil
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -30,9 +29,10 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
+import java.time.Instant
 import java.time.LocalDate
+import java.time.ZoneId
 import java.time.format.DateTimeFormatter
-import java.util.Date
 
 @Stable
 data class ReadRecordUiState(
@@ -116,7 +116,7 @@ class ReadRecordViewModel(
             .mapValues { it.value.size }
 
         val dailyTimes = data.sessions
-            .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
+            .groupBy { it.startTime.toDateString() }
             .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
             .mapValues { (_, sessions) ->
                 sessions.sumOf { (it.endTime - it.startTime).coerceAtLeast(0L) }
@@ -129,13 +129,13 @@ class ReadRecordViewModel(
         val timelineMap = data.sessions
             .asSequence()
             .filter { session ->
-                val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
+                val sDate = session.startTime.toDateString()
                 (dateStr == null || sDate == dateStr) &&
                         (searchKey.isEmpty() ||
                                 session.bookName.contains(searchKey, ignoreCase = true) ||
                                 session.bookAuthor.contains(searchKey, ignoreCase = true))
             }
-            .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
+            .groupBy { it.startTime.toDateString() }
             .mapValues { (_, sessions) ->
                 mergeContinuousSessions(sessions).reversed()
             }
@@ -291,6 +291,9 @@ class ReadRecordViewModel(
         val latestRecords: List<ReadRecord>,
         val sessions: List<ReadRecordSession>
     )
+
+    private fun Long.toDateString(): String =
+        Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate().toString()
 }
 
 sealed interface ReadRecordIntent {

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt

@@ -58,7 +58,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.io.File
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 class CloudTtsViewModel(
     private val application: Application,
@@ -507,7 +507,7 @@ class CloudTtsViewModel(
         val old = editor.editingEngineId?.let { id -> engines.firstOrNull { it.id == id } }
         val now = System.currentTimeMillis()
         return CloudTtsEngine(
-            id = old?.id ?: UUID.randomUUID().toString(),
+            id = old?.id ?: Uuid.random().toString(),
             name = editor.name.trim(),
             provider = provider,
             baseUrl = editor.baseUrl.trim(),

+ 4 - 7
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -56,7 +56,6 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
-import java.util.regex.Pattern
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
@@ -305,16 +304,14 @@ fun TxtRuleScreen(
         },
         showTestButton = true,
         onTest = { rule, example ->
-            val pattern = Pattern.compile(rule, Pattern.MULTILINE)
+            val regex = Regex(rule, RegexOption.MULTILINE)
             withContext(Dispatchers.Default) {
                 val normalized = example.replace("\r\n", "\n").replace("\r", "\n")
                 val lines = normalized.lines()
                 val fullText = "\n$normalized"
-                val matcher = pattern.matcher(fullText)
-                val matchRanges = mutableListOf<IntRange>()
-                while (matcher.find()) {
-                    matchRanges.add(matcher.start()..matcher.end())
-                }
+                val matchRanges = regex.findAll(fullText)
+                    .map { it.range.first..it.range.last + 1 }
+                    .toMutableList()
                 var offset = 1
                 lines.map { line ->
                     val lineStart = offset

+ 5 - 9
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt

@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import java.util.regex.Pattern
 import java.util.regex.PatternSyntaxException
 import kotlin.coroutines.coroutineContext
 
@@ -147,7 +146,7 @@ class TxtTocRulePreviewViewModel(
         if (book == null) return TocRulePreviewItem(rule = tocRule)
         return try {
             val pattern = try {
-                tocRule.chapterRule.toPattern(Pattern.MULTILINE)
+                Regex(tocRule.chapterRule, RegexOption.MULTILINE)
             } catch (e: PatternSyntaxException) {
                 return TocRulePreviewItem(rule = tocRule, totalCount = 0)
             }
@@ -170,7 +169,7 @@ class TxtTocRulePreviewViewModel(
             _uiState.update { it.copy(editingRule = null) }
             return
         }
-        if (runCatching { updatedRule.chapterRule.toPattern(Pattern.MULTILINE) }.isFailure) {
+        if (runCatching { Regex(updatedRule.chapterRule, RegexOption.MULTILINE) }.isFailure) {
             _effects.tryEmit(TxtTocRulePreviewEffect.ShowToast(context.getString(R.string.invalid_format)))
             _uiState.update { it.copy(editingRule = null) }
             return
@@ -229,7 +228,7 @@ class TxtTocRulePreviewViewModel(
         return rules.filter { it.chapterRule.isNotBlank() }.sortedBy { it.serialNumber }
     }
 
-    private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {
+    private suspend fun analyzeWithPattern(book: Book, pattern: Regex): Pair<List<String>, Int> {
         val chapters = mutableListOf<String>()
         var totalCount = 0
         val charset = book.fileCharset()
@@ -260,11 +259,10 @@ class TxtTocRulePreviewViewModel(
                     buffer.copyInto(buffer, 0, end, bufferStart + length)
                     bufferStart = bufferStart + length - end
 
-                    val matcher = pattern.matcher(blockContent)
-                    while (matcher.find()) {
+                    for (m in pattern.findAll(blockContent)) {
                         totalCount++
                         if (chapters.size < 500) {
-                            chapters.add(matcher.group())
+                            chapters.add(m.value)
                         }
                     }
                 }
@@ -273,5 +271,3 @@ class TxtTocRulePreviewViewModel(
         return chapters to totalCount
     }
 }
-
-private fun String.toPattern(flags: Int): Pattern = Pattern.compile(this, flags)

+ 2 - 2
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt

@@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
 import splitties.init.appCtx
 import java.io.File
 import java.io.FileOutputStream
-import java.util.UUID
+import kotlin.uuid.Uuid
 import kotlin.coroutines.cancellation.CancellationException
 
 class ThemeConfigViewModel(
@@ -551,7 +551,7 @@ class ThemeConfigViewModel(
                 .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
                 ?: "ttf"
             val fontDir = appFontDir()
-            val temp = File(fontDir, "app_font_${UUID.randomUUID()}.tmp")
+            val temp = File(fontDir, "app_font_${Uuid.random()}.tmp")
             val target = try {
                 fileDoc.openInputStream().getOrThrow().use { input ->
                     FileOutputStream(temp).use(input::copyTo)

+ 2 - 2
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt

@@ -14,7 +14,7 @@ import io.legado.app.domain.model.BookshelfAutoGroupPreflight
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
-import java.util.UUID
+import kotlin.uuid.Uuid
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Job
@@ -405,7 +405,7 @@ class AiAutoGroupViewModel(
         _uiState.update { state ->
             state.copy(
                 groups = (state.groups + AiAutoGroupGroupUi(
-                    key = UUID.randomUUID().toString(),
+                    key = Uuid.random().toString(),
                     name = finalName,
                     description = "",
                     reuseExisting = finalName in existingNames,

+ 2 - 2
app/src/main/java/io/legado/app/ui/replace/ReplaceEditRoute.kt

@@ -2,7 +2,7 @@ package io.legado.app.ui.replace
 
 import androidx.navigation3.runtime.NavKey
 import kotlinx.serialization.Serializable
-import java.util.UUID
+import kotlin.uuid.Uuid
 
 @Serializable
 object ReplaceRuleRoute : NavKey
@@ -15,5 +15,5 @@ data class ReplaceEditRoute(
     val scope: String? = null,
     val isScopeTitle: Boolean = false,
     val isScopeContent: Boolean = false,
-    val sessionId: String = UUID.randomUUID().toString()
+    val sessionId: String = Uuid.random().toString()
 ) : NavKey

+ 2 - 3
app/src/main/java/io/legado/app/utils/FileUtils.kt

@@ -8,7 +8,6 @@ import java.io.*
 import java.nio.charset.Charset
 import java.text.SimpleDateFormat
 import java.util.*
-import java.util.regex.Pattern
 
 @Suppress("unused", "MemberVisibilityCanBePrivate")
 object FileUtils {
@@ -254,7 +253,7 @@ object FileUtils {
     @JvmOverloads
     fun listFiles(
         startDirPath: String,
-        filterPattern: Pattern? = null, @SortType sortType: Int = BY_NAME_ASC
+        filterPattern: Regex? = null, @SortType sortType: Int = BY_NAME_ASC
     ): Array<File> {
         val fileList = ArrayList<File>()
         val f = File(startDirPath)
@@ -269,7 +268,7 @@ object FileUtils {
                 return@FileFilter false
             }
 
-            filterPattern?.matcher(file.name)?.find() ?: true
+            filterPattern?.containsMatchIn(file.name) ?: true
         })
             ?: return arrayOf()
         for (file in files) {

+ 11 - 13
app/src/main/java/io/legado/app/utils/HtmlFormatter.kt

@@ -3,7 +3,6 @@ package io.legado.app.utils
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import org.jsoup.Jsoup
 import java.net.URL
-import java.util.regex.Pattern
 
 @Suppress("RegExpRedundantEscape")
 object HtmlFormatter {
@@ -14,9 +13,9 @@ object HtmlFormatter {
     private val commentRegex = "<!--[^>]*-->".toRegex() //注释
     private val notImgHtmlRegex = "</?(?!img)[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val otherHtmlRegex = "</?[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
-    private val formatImagePattern = Pattern.compile(
+    private val formatImagePattern = Regex(
         "<img[^>]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|<img[^>]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>",
-        Pattern.CASE_INSENSITIVE
+        RegexOption.IGNORE_CASE
     )
     private val indent1Regex = "\\s*\\n+\\s*".toRegex()
     private val indent2Regex = "^[\\n\\s]+".toRegex()
@@ -116,26 +115,25 @@ object HtmlFormatter {
         val keepImgHtml = format(html, notImgHtmlRegex)
 
         //正则的“|”处于顶端而不处于()中时,具有类似||的熔断效果,故以此机制简化原来的代码
-        val matcher = formatImagePattern.matcher(keepImgHtml)
         var appendPos = 0
         val sb = StringBuilder()
-        while (matcher.find()) {
+        for (m in formatImagePattern.findAll(keepImgHtml)) {
             var param = ""
             sb.append(
-                keepImgHtml.substring(appendPos, matcher.start()), "<img src=\"${
+                keepImgHtml.substring(appendPos, m.range.first), "<img src=\"${
                     NetworkUtils.getAbsoluteURL(
                         redirectUrl,
-                        matcher.group(1)?.let {
-                            val urlMatcher = AnalyzeUrl.paramPattern.matcher(it)
-                            if (urlMatcher.find()) {
-                                param = ',' + it.substring(urlMatcher.end())
-                                it.substring(0, urlMatcher.start())
+                        m.groups[1]?.value?.let {
+                            val urlMatch = AnalyzeUrl.paramPattern.find(it)
+                            if (urlMatch != null) {
+                                param = ',' + it.substring(urlMatch.range.last + 1)
+                                it.substring(0, urlMatch.range.first)
                             } else it
-                        } ?: matcher.group(2) ?: matcher.group(3)!!
+                        } ?: m.groups[2]?.value ?: m.groups[3]!!.value
                     ) + param
                 }\">"
             )
-            appendPos = matcher.end()
+            appendPos = m.range.last + 1
         }
         if (appendPos < keepImgHtml.length) sb.append(
             keepImgHtml.substring(

+ 4 - 12
app/src/main/java/io/legado/app/utils/MD5Utils.kt

@@ -1,9 +1,8 @@
 package io.legado.app.utils
 
-import cn.hutool.crypto.digest.DigestUtil
-import cn.hutool.crypto.digest.Digester
+import io.legado.app.help.crypto.digest
+import io.legado.app.help.crypto.toHexString
 import java.io.InputStream
-import kotlin.concurrent.getOrSet
 
 /**
  * 将字符串转化为MD5
@@ -11,19 +10,12 @@ import kotlin.concurrent.getOrSet
 @Suppress("unused")
 object MD5Utils {
 
-    private val threadLocal = ThreadLocal<Digester>()
-
-    private val MD5Digester
-        get() = threadLocal.getOrSet {
-            DigestUtil.digester("MD5")
-        }
-
     fun md5Encode(str: String?): String {
-        return MD5Digester.digestHex(str)
+        return digest("MD5", str.orEmpty().toByteArray()).toHexString()
     }
 
     fun md5Encode(inputStream: InputStream): String {
-        return MD5Digester.digestHex(inputStream)
+        return digest("MD5", inputStream).toHexString()
     }
 
     fun md5Encode16(str: String): String {

+ 9 - 3
app/src/main/java/io/legado/app/utils/NetworkUtils.kt

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
 import android.net.ConnectivityManager
 import android.net.NetworkCapabilities
 import android.os.Build
-import cn.hutool.core.lang.Validator
 import io.legado.app.constant.AppLog
 import okhttp3.internal.publicsuffix.PublicSuffixDatabase
 import splitties.systemservices.connectivityManager
@@ -18,6 +17,13 @@ import java.util.Enumeration
 @Suppress("unused", "MemberVisibilityCanBePrivate")
 object NetworkUtils {
 
+    private val ipv4Pattern =
+        Regex("^(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)$")
+    private val ipv6Pattern = Regex(
+        "(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))"
+    )
+
+
     /**
      * 判断是否联网
      */
@@ -278,14 +284,14 @@ object NetworkUtils {
         return input != null && input.isNotEmpty()
                 && input[0] in '1'..'9'
                 && input.count { it == '.' } == 3
-                && Validator.isIpv4(input)
+                && ipv4Pattern.matches(input)
     }
 
     /**
      * Check if valid IPV6 address.
      */
     fun isIPv6Address(input: String?): Boolean {
-        return input != null && input.contains(":") && Validator.isIpv6(input)
+        return input != null && input.contains(":") && ipv6Pattern.matches(input)
     }
 
     /**

+ 28 - 6
app/src/main/java/io/legado/app/utils/StringExtensions.kt

@@ -8,7 +8,6 @@ import android.icu.util.ULocale
 import android.net.Uri
 import android.text.Editable
 import androidx.core.net.toUri
-import cn.hutool.core.net.URLEncodeUtil
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern.dataUriRegex
 import java.io.File
@@ -16,7 +15,6 @@ import java.lang.Character.codePointCount
 import java.lang.Character.offsetByCodePoints
 import java.net.InetAddress
 import java.util.Locale
-import java.util.regex.Pattern
 
 
 fun String?.safeTrim() = if (this.isNullOrBlank()) null else this.trim()
@@ -117,11 +115,11 @@ fun String?.memorySize(): Int {
  * 是否中文
  */
 fun String.isChinese(): Boolean {
-    val p = Pattern.compile("[\u4e00-\u9fa5]")
-    val m = p.matcher(this)
-    return m.find()
+    return chineseCharRegex.containsMatchIn(this)
 }
 
+private val chineseCharRegex = Regex("[\u4e00-\u9fa5]")
+
 /**
  * 将字符串拆分为单个字符,包含emoji
  */
@@ -142,7 +140,31 @@ fun String.escapeRegex(): String {
     return replace(AppPattern.regexCharRegex, "\\\\$0")
 }
 
-fun String.encodeURI(): String = URLEncodeUtil.encodeQuery(this)
+fun String.encodeURI(): String {
+    val builder = StringBuilder(length)
+    for (c in this) {
+        if (c.code < 128 && encodeQuerySafeChars[c.code]) {
+            builder.append(c)
+        } else {
+            val bytes = c.toString().toByteArray(Charsets.UTF_8)
+            for (b in bytes) {
+                val v = b.toInt() and 0xFF
+                builder.append('%')
+                builder.append(ENCODE_HEX_UPPER[v ushr 4])
+                builder.append(ENCODE_HEX_UPPER[v and 0x0F])
+            }
+        }
+    }
+    return builder.toString()
+}
+
+private const val ENCODE_HEX_UPPER = "0123456789ABCDEF"
+
+private val encodeQuerySafeChars: BooleanArray = BooleanArray(128).apply {
+    for (c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~!$&'()*+,;=:@/?") {
+        this[c.code] = true
+    }
+}
 
 fun String.normalizeFileName(): String {
     return replace(AppPattern.fileNameRegex2, "_")

+ 7 - 16
app/src/main/java/io/legado/app/utils/StringUtils.kt

@@ -13,8 +13,6 @@ import java.time.ZoneId
 import java.time.temporal.ChronoUnit
 import java.util.Calendar
 import java.util.Locale
-import java.util.regex.Matcher
-import java.util.regex.Pattern
 import java.util.zip.GZIPInputStream
 import java.util.zip.GZIPOutputStream
 import kotlin.math.abs
@@ -29,6 +27,9 @@ object StringUtils {
     private val wordCountFormatter by lazy {
         DecimalFormat("#.#")
     }
+    private val numberRegex = Regex("[0-9]+")
+    private val numericRegex = Regex("-?[0-9]+")
+    private val unicodeRegex = Regex("\\\\u(\\p{XDigit}{4})")
 
     private val chnMap: HashMap<Char, Int>
         get() {
@@ -249,18 +250,14 @@ object StringUtils {
      * 是否包含数字
      */
     fun isContainNumber(company: String): Boolean {
-        val p = Pattern.compile("[0-9]+")
-        val m = p.matcher(company)
-        return m.find()
+        return numberRegex.containsMatchIn(company)
     }
 
     /**
      * 是否数字
      */
     fun isNumeric(str: String): Boolean {
-        val pattern = Pattern.compile("-?[0-9]+")
-        val isNum = pattern.matcher(str)
-        return isNum.matches()
+        return numericRegex.matches(str)
     }
 
     fun wordCountFormat(words: Int): String {
@@ -329,15 +326,9 @@ object StringUtils {
      */
     fun removeUTFCharacters(data: String?): String? {
         if (data == null) return null
-        val p = Pattern.compile("\\\\u(\\p{XDigit}{4})")
-        val m = p.matcher(data)
-        val buf = StringBuffer(data.length)
-        while (m.find()) {
-            val ch = Integer.parseInt(m.group(1)!!, 16).toChar().toString()
-            m.appendReplacement(buf, Matcher.quoteReplacement(ch))
+        return unicodeRegex.replace(data) { m ->
+            Integer.parseInt(m.groupValues[1], 16).toChar().toString()
         }
-        m.appendTail(buf)
-        return buf.toString()
     }
 
     /**

+ 11 - 0
app/src/main/java/io/legado/app/utils/UuidExtensions.kt

@@ -0,0 +1,11 @@
+package io.legado.app.utils
+
+import java.security.MessageDigest
+import kotlin.uuid.Uuid
+
+fun nameUuidFromBytes(bytes: ByteArray): Uuid {
+    val md5 = MessageDigest.getInstance("MD5").digest(bytes)
+    md5[6] = (md5[6].toInt() and 0x0F or 0x30).toByte()
+    md5[8] = (md5[8].toInt() and 0x3F or 0x80).toByte()
+    return Uuid.fromByteArray(md5)
+}

+ 96 - 0
app/src/test/java/io/legado/app/help/crypto/CryptoCompatibilityTest.kt

@@ -0,0 +1,96 @@
+package io.legado.app.help.crypto
+
+import io.legado.app.utils.nameUuidFromBytes
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CryptoCompatibilityTest {
+
+    @Test
+    fun `MD5 与标准向量一致`() {
+        assertEquals("d41d8cd98f00b204e9800998ecf8427e", digest("MD5", ByteArray(0)).toHexString())
+        assertEquals("900150983cd24fb0d6963f7d28e17f72", digest("MD5", "abc".toByteArray()).toHexString())
+    }
+
+    @Test
+    fun `SHA-256 与标准向量一致`() {
+        assertEquals(
+            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+            digest("SHA-256", "abc".toByteArray()).toHexString()
+        )
+    }
+
+    @Test
+    fun `HMAC-SHA256 与标准向量一致`() {
+        val expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
+        val actual = hmac(
+            "HmacSHA256",
+            "key".toByteArray(),
+            "The quick brown fox jumps over the lazy dog".toByteArray()
+        ).toHexString()
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `hex base64 编解码往返`() {
+        val bytes = byteArrayOf(0x00, 0x01, 0x7f, -0x01, 0x41, 0x42)
+        assertEquals("00017fff4142", bytes.toHexString())
+        assertTrue(bytes.contentEquals("00017fff4142".hexToByteArray()))
+
+        val base64 = bytes.toBase64()
+        assertEquals("AAF//0FC", base64)
+        assertTrue(bytes.contentEquals(base64.base64ToByteArray()))
+        assertTrue(bytes.contentEquals("AAF // 0FC".base64ToByteArray()))
+        assertTrue(bytes.contentEquals("AAF__0FC".base64ToByteArray()))
+    }
+
+    @Test
+    fun `AES ECB 加密解密往返`() {
+        val key = "1234567890123456".toByteArray()
+        val data = "hello legado".toByteArray()
+        val crypto = SymmetricCryptoAndroid("AES", key)
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encrypt(data))))
+        assertTrue(data.contentEquals(crypto.decryptStr(crypto.encryptBase64(data)).toByteArray()))
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encryptHex(data).hexToByteArray())))
+    }
+
+    @Test
+    fun `AES CBC 带 IV 往返`() {
+        val key = "1234567890123456".toByteArray()
+        val iv = "abcdefghijklmnop".toByteArray()
+        val crypto = SymmetricCryptoAndroid("AES/CBC/PKCS5Padding", key).setIv(iv)
+        val data = "带中文的明文".toByteArray()
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encrypt(data))))
+    }
+
+    @Test
+    fun `AES decrypt 自动识别 hex 与 base64`() {
+        val key = "1234567890123456".toByteArray()
+        val data = "密文数据".toByteArray()
+        val crypto = SymmetricCryptoAndroid("AES", key)
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encryptHex(data))))
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encryptBase64(data))))
+    }
+
+    @Test
+    fun `DES 超长密钥静默截断兼容`() {
+        val crypto = SymmetricCryptoAndroid("DES", "1234567890123456".toByteArray())
+        val data = "des data".toByteArray()
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encrypt(data))))
+    }
+
+    @Test
+    fun `nameUuidFromBytes 与 java UUID v3 一致`() {
+        val input = "provider123:model-abc".toByteArray()
+        val expected = java.util.UUID.nameUUIDFromBytes(input).toString()
+        assertEquals(expected, nameUuidFromBytes(input).toString())
+    }
+
+    @Test
+    fun `DESede 超长密钥截断兼容`() {
+        val crypto = SymmetricCryptoAndroid("DESede", "12345678901234567890123456789012".toByteArray())
+        val data = "3des data".toByteArray()
+        assertTrue(data.contentEquals(crypto.decrypt(crypto.encrypt(data))))
+    }
+}

+ 1 - 1
docs/dev/js.md

@@ -201,7 +201,7 @@ cache.delete(java.md5Encode16(url))  // 使缓存失效
 ### 3.5 加解密 ([JsEncodeUtils](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsEncodeUtils.kt))
 
 提供在 JavaScript 环境中快捷调用 crypto
-算法的函数,由 [hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述) 实现(当前版本 5.8.22)。
+算法的函数,由 Android/JDK 原生 API(`javax.crypto`/`java.security`)实现。
 
 ::: warning 输入类型
 如果输入参数不是 Utf8String,可先调用 `java.hexDecodeToByteArray` 或 `java.base64DecodeToByteArray`

+ 2 - 5
gradle/libs.versions.toml

@@ -29,9 +29,6 @@ firebaseBom = "34.6.0"
 flexbox = "3.0.0"
 fragment = "1.8.9"
 
-#不要更新版本
-hutool = "5.8.22"
-
 libarchive = "1.1.6"
 lifecycle = "2.11.0"
 glide = "5.0.7"
@@ -44,6 +41,7 @@ intellijMarkdown = "0.7.3"
 jsoup = "1.16.2"
 jsoupxpath = "2.5.5"
 coroutines = "1.11.0"
+kotlinxDatetime = "0.6.2"
 lifecycleViewmodelCompose = "2.11.0"
 liveeventbus = "1.8.14"
 navigation3 = "1.1.4"
@@ -162,9 +160,8 @@ fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragm
 
 androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" }
 
-hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" }
-
 kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
 libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" }
 lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
 lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }