Explorar o código

合并上游更改
感谢:@821938089、@zhongerxll、@youke2580c、@Leroy-X、@Dark-Tide、@lhjgege、@3harmony、@mgz0227

HapeLee hai 1 ano
pai
achega
8eb4881767
Modificáronse 87 ficheiros con 799 adicións e 364 borrados
  1. 12 3
      app/src/main/assets/web/help/md/jsHelp.md
  2. 4 0
      app/src/main/java/io/legato/kazusa/App.kt
  3. 3 3
      app/src/main/java/io/legato/kazusa/base/BaseService.kt
  4. 2 1
      app/src/main/java/io/legato/kazusa/constant/AppPattern.kt
  5. 1 0
      app/src/main/java/io/legato/kazusa/constant/EventBus.kt
  6. 1 4
      app/src/main/java/io/legato/kazusa/constant/PreferKey.kt
  7. 7 0
      app/src/main/java/io/legato/kazusa/data/dao/BookDao.kt
  8. 1 2
      app/src/main/java/io/legato/kazusa/data/entities/BaseSource.kt
  9. 1 1
      app/src/main/java/io/legato/kazusa/help/DirectLinkUpload.kt
  10. 1 0
      app/src/main/java/io/legato/kazusa/help/JsExtensions.kt
  11. 7 3
      app/src/main/java/io/legato/kazusa/help/book/BookHelp.kt
  12. 3 0
      app/src/main/java/io/legato/kazusa/help/config/AppConfig.kt
  13. 46 59
      app/src/main/java/io/legato/kazusa/help/config/ReadBookConfig.kt
  14. 41 22
      app/src/main/java/io/legato/kazusa/help/config/ThemeConfig.kt
  15. 1 1
      app/src/main/java/io/legato/kazusa/help/glide/BlurTransformation.kt
  16. 2 1
      app/src/main/java/io/legato/kazusa/help/http/DecompressInterceptor.kt
  17. 1 1
      app/src/main/java/io/legato/kazusa/help/http/HttpHelper.kt
  18. 10 4
      app/src/main/java/io/legato/kazusa/help/http/ObsoleteUrlFactory.kt
  19. 4 3
      app/src/main/java/io/legato/kazusa/help/source/SourceVerificationHelp.kt
  20. 6 3
      app/src/main/java/io/legato/kazusa/help/storage/Restore.kt
  21. 25 9
      app/src/main/java/io/legato/kazusa/lib/cronet/AbsCallBack.kt
  22. 1 1
      app/src/main/java/io/legato/kazusa/lib/mobi/MobiBook.kt
  23. 7 0
      app/src/main/java/io/legato/kazusa/lib/mobi/utils/BitwiseExtensions.kt
  24. 0 1
      app/src/main/java/io/legato/kazusa/lib/mobi/utils/ByteBufferExtensions.kt
  25. 15 6
      app/src/main/java/io/legato/kazusa/lib/webdav/WebDav.kt
  26. 47 6
      app/src/main/java/io/legato/kazusa/model/AudioPlay.kt
  27. 2 2
      app/src/main/java/io/legato/kazusa/model/BookCover.kt
  28. 8 7
      app/src/main/java/io/legato/kazusa/model/CacheBook.kt
  29. 29 25
      app/src/main/java/io/legato/kazusa/model/ReadBook.kt
  30. 17 13
      app/src/main/java/io/legato/kazusa/model/ReadManga.kt
  31. 22 23
      app/src/main/java/io/legato/kazusa/model/analyzeRule/AnalyzeUrl.kt
  32. 10 3
      app/src/main/java/io/legato/kazusa/model/localBook/LocalBook.kt
  33. 1 1
      app/src/main/java/io/legato/kazusa/model/localBook/MobiFile.kt
  34. 4 0
      app/src/main/java/io/legato/kazusa/model/localBook/TextFile.kt
  35. 12 1
      app/src/main/java/io/legato/kazusa/model/webBook/SearchModel.kt
  36. 70 16
      app/src/main/java/io/legato/kazusa/service/BaseReadAloudService.kt
  37. 0 1
      app/src/main/java/io/legato/kazusa/service/CacheBookService.kt
  38. 10 9
      app/src/main/java/io/legato/kazusa/service/HttpReadAloudService.kt
  39. 6 2
      app/src/main/java/io/legato/kazusa/service/TTSReadAloudService.kt
  40. 1 1
      app/src/main/java/io/legato/kazusa/ui/association/OnLineImportViewModel.kt
  41. 13 0
      app/src/main/java/io/legato/kazusa/ui/book/audio/AudioPlayActivity.kt
  42. 2 2
      app/src/main/java/io/legato/kazusa/ui/book/audio/AudioPlayViewModel.kt
  43. 1 1
      app/src/main/java/io/legato/kazusa/ui/book/info/BookInfoActivity.kt
  44. 1 1
      app/src/main/java/io/legato/kazusa/ui/book/info/BookInfoViewModel.kt
  45. 8 0
      app/src/main/java/io/legato/kazusa/ui/book/info/edit/BookInfoEditViewModel.kt
  46. 1 1
      app/src/main/java/io/legato/kazusa/ui/book/manga/ReadMangaViewModel.kt
  47. 4 3
      app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt
  48. 1 1
      app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt
  49. 8 7
      app/src/main/java/io/legato/kazusa/ui/book/read/config/BgTextConfigDialog.kt
  50. 2 1
      app/src/main/java/io/legato/kazusa/ui/book/read/config/ClickActionConfigDialog.kt
  51. 4 0
      app/src/main/java/io/legato/kazusa/ui/book/read/config/MoreConfigDialog.kt
  52. 45 14
      app/src/main/java/io/legato/kazusa/ui/book/read/page/AutoPager.kt
  53. 34 4
      app/src/main/java/io/legato/kazusa/ui/book/read/page/PageView.kt
  54. 8 0
      app/src/main/java/io/legato/kazusa/ui/book/read/page/ReadView.kt
  55. 9 0
      app/src/main/java/io/legato/kazusa/ui/book/read/page/entities/TextLine.kt
  56. 34 14
      app/src/main/java/io/legato/kazusa/ui/book/read/page/provider/ChapterProvider.kt
  57. 10 11
      app/src/main/java/io/legato/kazusa/ui/book/read/page/provider/TextChapterLayout.kt
  58. 26 16
      app/src/main/java/io/legato/kazusa/ui/book/search/SearchActivity.kt
  59. 8 0
      app/src/main/java/io/legato/kazusa/ui/book/search/SearchViewModel.kt
  60. 26 10
      app/src/main/java/io/legato/kazusa/ui/book/searchContent/SearchResult.kt
  61. 11 0
      app/src/main/java/io/legato/kazusa/ui/dict/DictDialog.kt
  62. 4 2
      app/src/main/java/io/legato/kazusa/ui/login/SourceLoginActivity.kt
  63. 6 2
      app/src/main/java/io/legato/kazusa/ui/login/SourceLoginViewModel.kt
  64. 1 1
      app/src/main/java/io/legato/kazusa/ui/main/MainViewModel.kt
  65. 2 5
      app/src/main/java/io/legato/kazusa/ui/replace/ReplaceRuleActivity.kt
  66. 1 3
      app/src/main/java/io/legato/kazusa/ui/rss/source/manage/RssSourceActivity.kt
  67. 4 2
      app/src/main/java/io/legato/kazusa/utils/ContextExtensions.kt
  68. 2 1
      app/src/main/java/io/legato/kazusa/utils/FragmentExtensions.kt
  69. 1 1
      app/src/main/java/io/legato/kazusa/utils/HtmlFormatter.kt
  70. 0 7
      app/src/main/java/io/legato/kazusa/utils/RomExtensions.kt
  71. 11 0
      app/src/main/res/drawable/ic_play_mode_list_end_stop.xml
  72. 11 0
      app/src/main/res/drawable/ic_play_mode_list_loop.xml
  73. 11 0
      app/src/main/res/drawable/ic_play_mode_random.xml
  74. 14 0
      app/src/main/res/layout/activity_audio_play.xml
  75. 1 1
      app/src/main/res/layout/dialog_auto_read.xml
  76. 0 5
      app/src/main/res/layout/view_book_page.xml
  77. 1 1
      app/src/main/res/values-es-rES/strings.xml
  78. 1 1
      app/src/main/res/values-ja-rJP/strings.xml
  79. 1 1
      app/src/main/res/values-pt-rBR/strings.xml
  80. 1 1
      app/src/main/res/values-vi/arrays.xml
  81. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  82. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  83. 5 2
      app/src/main/res/values-zh/strings.xml
  84. 5 1
      app/src/main/res/values/strings.xml
  85. 7 0
      app/src/main/res/xml/pref_config_aloud.xml
  86. 9 2
      app/src/main/res/xml/pref_config_read.xml
  87. 1 1
      gradle/libs.versions.toml

+ 12 - 3
app/src/main/assets/web/help/md/jsHelp.md

@@ -45,7 +45,7 @@
 * 调用阅读搜索
 
 ```js
-java.searchBook(bookName: string)
+java.searchBook(bookName: String)
 ```
 
 * 添加书架
@@ -232,6 +232,13 @@ java.s2t(text: String): String
 * 时间格式化
 ```js
 java.timeFormatUTC(time: Long, format: String, sh: Int): String?
+java.timeFormat(time: Long): String
+```
+
+* html格式化
+
+```js
+java.htmlFormat(str: String): String
 ```
 * 文件
 >  所有对于文件的读写删操作都是相对路径,只能操作阅读缓存/android/data/{package}/cache/内的文件
@@ -441,9 +448,11 @@ cache.putFile(key: String, value: String, saveTime: Int)
 读取文件内容
 cache.getFile(key: String): String?
 保存到内存
-cache.deleteMemory(key: String)
-cache.getFromMemory(key: String): Any?
 cache.putMemory(key: String, value: Any)
+读取内存
+cache.getFromMemory(key: String): Any?
+删除内存
+cache.deleteMemory(key: String)
 
 ```
 

+ 4 - 0
app/src/main/java/io/legato/kazusa/App.kt

@@ -7,6 +7,7 @@ import android.app.NotificationManager
 import android.content.Context
 import android.content.pm.ApplicationInfo
 import android.content.res.Configuration
+import android.os.Build
 import com.github.liuyueyi.quick.transfer.constants.TransType
 import com.google.android.material.color.DynamicColors
 import com.jeremyliao.liveeventbus.LiveEventBus
@@ -141,6 +142,9 @@ class App : Application() {
      * @return
      */
     private fun installGmsTlsProvider(context: Context) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            return
+        }
         try {
             val gmsPackageName = "com.google.android.gms"
             val appInfo = packageManager.getApplicationInfo(gmsPackageName, 0)

+ 3 - 3
app/src/main/java/io/legato/kazusa/base/BaseService.kt

@@ -75,9 +75,9 @@ abstract class BaseService : LifecycleService() {
     }
 
     @CallSuper
-    override fun onTimeout(startId: Int) {
-        super.onTimeout(startId)
-        LogUtils.d(simpleName, "onTimeout startId:$startId")
+    override fun onTimeout(startId: Int, fgsType: Int) {
+        super.onTimeout(startId, fgsType)
+        LogUtils.d(simpleName, "onTimeout startId:$startId fgsType:$fgsType")
         stopSelf()
     }
 

+ 2 - 1
app/src/main/java/io/legato/kazusa/constant/AppPattern.kt

@@ -9,7 +9,8 @@ object AppPattern {
     val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
 
     //匹配格式化后的图片格式
-    val imgPattern: Pattern = Pattern.compile("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
+    val imgPattern: Pattern =
+        Pattern.compile("<img[^>]*src=['\"]([^'\"]*(?:['\"][^>]+\\})?)['\"][^>]*>")
 
     //dataURL图片类型
     val dataUriRegex = Regex("^data:.*?;base64,(.*)")

+ 1 - 0
app/src/main/java/io/legato/kazusa/constant/EventBus.kt

@@ -32,4 +32,5 @@ object EventBus {
     const val READ_ALOUD_PLAY = "readAloudPlay"
     const val EXPORT_BOOK = "exportBook"
     const val UP_MANGA_CONFIG = "upMangaConfig"
+    const val PLAY_MODE_CHANGED = "playModeChanged"
 }

+ 1 - 4
app/src/main/java/io/legato/kazusa/constant/PreferKey.kt

@@ -170,14 +170,12 @@ object PreferKey {
     const val showMangaUi = "showMangaUi"
     const val disableMangaScale = "disableMangaScale"
     const val firebaseEnabled = "firebaseEnabled"
-
     const val cPrimary = "colorPrimary"
     const val cAccent = "colorAccent"
     const val cBackground = "colorBackground"
     const val cBBackground = "colorBottomBackground"
     const val bgImage = "backgroundImage"
     const val bgImageBlurring = "backgroundImageBlurring"
-
     const val cNPrimary = "colorPrimaryNight"
     const val cNAccent = "colorAccentNight"
     const val cNBackground = "colorBackgroundNight"
@@ -187,7 +185,6 @@ object PreferKey {
     const val showReadTitleAddition = "showReadTitleAddition"
     const val readBarStyleFollowPage = "readBarStyleFollowPage"
     const val contentSelectSpeakMod = "contentReadAloudMod"
-
     const val themePref = "themePref"
     const val bookshelfSortOrder = "bookshelfSortOrder"
     const val showBottomView = "showBottomView"
@@ -208,5 +205,5 @@ object PreferKey {
     const val permissionChecked = "permissionChecked"
     const val notificationsPost = "notificationsPost"
     const val ignoreBatteryPermission = "ignoreBatteryPermission"
-
+    const val paddingDisplayCutouts = "paddingDisplayCutouts"
 }

+ 7 - 0
app/src/main/java/io/legato/kazusa/data/dao/BookDao.kt

@@ -5,6 +5,7 @@ import androidx.room.Delete
 import androidx.room.Insert
 import androidx.room.OnConflictStrategy
 import androidx.room.Query
+import androidx.room.Transaction
 import androidx.room.Update
 import io.legato.kazusa.constant.BookType
 import io.legato.kazusa.data.entities.Book
@@ -150,6 +151,12 @@ interface BookDao {
     @Delete
     fun delete(vararg book: Book)
 
+    @Transaction
+    fun replace(oldBook: Book, newBook: Book) {
+        delete(oldBook)
+        insert(newBook)
+    }
+
     @Query("update books set durChapterPos = :pos where bookUrl = :bookUrl")
     fun upProgress(bookUrl: String, pos: Int)
 

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

@@ -13,7 +13,6 @@ import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.help.crypto.SymmetricCryptoAndroid
 import io.legato.kazusa.help.http.CookieStore
 import io.legato.kazusa.help.source.getShareScope
-import io.legato.kazusa.model.Debug
 import io.legato.kazusa.utils.GSON
 import io.legato.kazusa.utils.GSONStrict
 import io.legato.kazusa.utils.fromJsonArray
@@ -116,7 +115,7 @@ interface BaseSource : JsExtensions {
                 GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
                     putAll(map)
                 } ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
-                    Debug.log("≡请求头规则 JSON 格式不规范,请改为规范格式")
+                    log("请求头规则 JSON 格式不规范,请改为规范格式")
                     putAll(map)
                 }
             } catch (e: Exception) {

+ 1 - 1
app/src/main/java/io/legato/kazusa/help/DirectLinkUpload.kt

@@ -101,7 +101,7 @@ object DirectLinkUpload {
 
     @Keep
     data class Rule(
-        var uploadUrl: String, //上传url
+        var uploadUrl: String, //创建分享链接
         var downloadUrlRule: String, //下载链接规则
         var summary: String, //注释
         var compress: Boolean = false, //是否压缩

+ 1 - 0
app/src/main/java/io/legato/kazusa/help/JsExtensions.kt

@@ -988,6 +988,7 @@ interface JsExtensions : JsEncodeUtils {
 
     // 新增 mimeType 参数,默认为 null(保持兼容性)
     fun openUrl(url: String, mimeType: String? = null) {
+        require(url.length < 64 * 1024) { "openUrl parameter url too long" }
         rhinoContext.ensureActive()
         val source = getSource() ?: throw NoStackTraceException("openUrl source cannot be null")
         appCtx.startActivity<OpenUrlConfirmActivity> {

+ 7 - 3
app/src/main/java/io/legato/kazusa/help/book/BookHelp.kt

@@ -3,6 +3,7 @@ package io.legato.kazusa.help.book
 import android.graphics.BitmapFactory
 import android.os.ParcelFileDescriptor
 import androidx.documentfile.provider.DocumentFile
+import com.script.rhino.runScriptWithContext
 import io.legato.kazusa.constant.AppLog
 import io.legato.kazusa.constant.AppPattern
 import io.legato.kazusa.constant.EventBus
@@ -28,7 +29,6 @@ import io.legato.kazusa.utils.getFile
 import io.legato.kazusa.utils.isContentScheme
 import io.legato.kazusa.utils.onEachParallel
 import io.legato.kazusa.utils.postEvent
-import com.script.rhino.runScriptWithContext
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.ensureActive
@@ -157,7 +157,7 @@ object BookHelp {
         }
     }
 
-    suspend fun saveContent(
+    fun saveContent(
         bookSource: BookSource,
         book: Book,
         bookChapter: BookChapter,
@@ -404,7 +404,11 @@ object BookHelp {
             bookChapter.getFileName()
         )
         if (file.exists()) {
-            return file.readText()
+            val string = file.readText()
+            if (string.isEmpty()) {
+                return null
+            }
+            return string
         }
         if (book.isLocal) {
             val string = LocalBook.getContent(book, bookChapter)

+ 3 - 0
app/src/main/java/io/legato/kazusa/help/config/AppConfig.kt

@@ -570,6 +570,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     val mouseWheelPage
         get() = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true)
 
+    val paddingDisplayCutouts
+        get() = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false)
+
     var searchScope: String
         get() = appCtx.getPrefString("searchScope") ?: ""
         set(value) {

+ 46 - 59
app/src/main/java/io/legato/kazusa/help/config/ReadBookConfig.kt

@@ -5,8 +5,8 @@ import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.ColorDrawable
 import android.graphics.drawable.Drawable
 import androidx.annotation.Keep
+import androidx.core.graphics.toColorInt
 import com.google.android.material.color.MaterialColors
-import io.legato.kazusa.R
 import io.legato.kazusa.constant.AppLog
 import io.legato.kazusa.constant.PageAnim
 import io.legato.kazusa.constant.PreferKey
@@ -30,8 +30,6 @@ import io.legato.kazusa.utils.printOnDebug
 import io.legato.kazusa.utils.putPrefBoolean
 import io.legato.kazusa.utils.putPrefInt
 import io.legato.kazusa.utils.resizeAndRecycle
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import java.io.File
 
@@ -439,66 +437,55 @@ object ReadBookConfig {
         return exportConfig
     }
 
-    suspend fun import(byteArray: ByteArray): Result<Config> {
-        return kotlin.runCatching {
-            withContext(IO) {
-                val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip")
-                FileUtils.delete(configZipPath)
-                val zipFile = FileUtils.createFileIfNotExist(configZipPath)
-                zipFile.writeBytes(byteArray)
-                val configDir = appCtx.externalCache.getFile("readConfig")
-                configDir.createFolderReplace()
-                ZipUtils.unZipToPath(zipFile, configDir)
-                val configFile = configDir.getFile(configFileName)
-                val config: Config = GSON.fromJsonObject<Config>(configFile.readText()).getOrThrow()
-                if (config.textFont.isNotEmpty()) {
-                    val fontName = FileUtils.getName(config.textFont)
-                    val fontPath =
-                        FileUtils.getPath(appCtx.externalFiles, "font", fontName)
-                    if (!FileUtils.exist(fontPath)) {
-                        configDir.getFile(fontName).copyTo(File(fontPath))
-                    }
-                    config.textFont = fontPath
-                }
-                if (config.bgType == 2) {
-                    val bgName = FileUtils.getName(config.bgStr)
-                    config.bgStr = bgName
-                    val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-                    if (!FileUtils.exist(bgPath)) {
-                        val bgFile = configDir.getFile(bgName)
-                        if (bgFile.exists()) {
-                            bgFile.copyTo(File(bgPath))
-                        }
-                    }
-                    config.bgStr = bgPath
-                }
-                if (config.bgTypeNight == 2) {
-                    val bgName = FileUtils.getName(config.bgStrNight)
-                    config.bgStrNight = bgName
-                    val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-                    if (!FileUtils.exist(bgPath)) {
-                        val bgFile = configDir.getFile(bgName)
-                        if (bgFile.exists()) {
-                            bgFile.copyTo(File(bgPath))
-                        }
-                    }
-                    config.bgStrNight = bgPath
+    fun import(byteArray: ByteArray): Config {
+        val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip")
+        FileUtils.delete(configZipPath)
+        val zipFile = FileUtils.createFileIfNotExist(configZipPath)
+        zipFile.writeBytes(byteArray)
+        val configDir = appCtx.externalCache.getFile("readConfig")
+        configDir.createFolderReplace()
+        ZipUtils.unZipToPath(zipFile, configDir)
+        val configFile = configDir.getFile(configFileName)
+        val config: Config = GSON.fromJsonObject<Config>(configFile.readText()).getOrThrow()
+        if (config.textFont.isNotEmpty()) {
+            val fontName = FileUtils.getName(config.textFont)
+            val fontPath =
+                FileUtils.getPath(appCtx.externalFiles, "font", fontName)
+            if (!FileUtils.exist(fontPath)) {
+                configDir.getFile(fontName).copyTo(File(fontPath))
+            }
+            config.textFont = fontPath
+        }
+        if (config.bgType == 2) {
+            val bgName = FileUtils.getName(config.bgStr)
+            config.bgStr = bgName
+            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
+            if (!FileUtils.exist(bgPath)) {
+                val bgFile = configDir.getFile(bgName)
+                if (bgFile.exists()) {
+                    bgFile.copyTo(File(bgPath))
                 }
-                if (config.bgTypeEInk == 2) {
-                    val bgName = FileUtils.getName(config.bgStrEInk)
-                    config.bgStrEInk = bgName
-                    val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
-                    if (!FileUtils.exist(bgPath)) {
-                        val bgFile = configDir.getFile(bgName)
-                        if (bgFile.exists()) {
-                            bgFile.copyTo(File(bgPath))
-                        }
-                    }
-                    config.bgStrEInk = bgPath
+            }
+            config.bgStrNight = bgPath
+        } else if (config.bgTypeNight == 0) {
+            config.bgStrNight.toColorInt()
+        }
+        if (config.bgTypeEInk == 2) {
+            val bgName = FileUtils.getName(config.bgStrEInk)
+            config.bgStrEInk = bgName
+            val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
+            if (!FileUtils.exist(bgPath)) {
+                val bgFile = configDir.getFile(bgName)
+                if (bgFile.exists()) {
+                    bgFile.copyTo(File(bgPath))
                 }
-                return@withContext config
             }
+            config.bgStrEInk = bgPath
+        } else if (config.bgTypeEInk == 0) {
+            config.bgStrEInk.toColorInt()
         }
+        config.curTextColor()
+        return config
     }
 
     @Keep

+ 41 - 22
app/src/main/java/io/legato/kazusa/help/config/ThemeConfig.kt

@@ -2,19 +2,18 @@ package io.legato.kazusa.help.config
 
 import android.content.Context
 import android.graphics.Bitmap
-import android.graphics.Color
 import android.util.DisplayMetrics
 import androidx.annotation.Keep
 import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.graphics.toColorInt
 import io.legato.kazusa.R
+import io.legato.kazusa.constant.AppLog
 import io.legato.kazusa.constant.EventBus
 import io.legato.kazusa.constant.PreferKey
 import io.legato.kazusa.constant.Theme
 import io.legato.kazusa.help.DefaultData
-import io.legato.kazusa.lib.theme.ThemeStore
 import io.legato.kazusa.model.BookCover
 import io.legato.kazusa.utils.BitmapUtils
-import io.legato.kazusa.utils.ColorUtils
 import io.legato.kazusa.utils.FileUtils
 import io.legato.kazusa.utils.GSON
 import io.legato.kazusa.utils.fromJsonArray
@@ -29,7 +28,6 @@ import io.legato.kazusa.utils.putPrefInt
 import io.legato.kazusa.utils.stackBlur
 import splitties.init.appCtx
 import java.io.File
-import androidx.core.graphics.toColorInt
 
 @Keep
 object ThemeConfig {
@@ -117,13 +115,18 @@ object ThemeConfig {
     fun addConfig(json: String): Boolean {
         GSON.fromJsonObject<Config>(json.trim { it < ' ' }).getOrNull()
             ?.let {
-                addConfig(it)
-                return true
+                if (validateConfig(it)) {
+                    addConfig(it)
+                    return true
+                }
             }
         return false
     }
 
     fun addConfig(newConfig: Config) {
+        if (!validateConfig(newConfig)) {
+            return
+        }
         configList.forEachIndexed { index, config ->
             if (newConfig.themeName == config.themeName) {
                 configList[index] = newConfig
@@ -134,6 +137,18 @@ object ThemeConfig {
         save()
     }
 
+    private fun validateConfig(config: Config): Boolean {
+        try {
+            config.primaryColor.toColorInt()
+            config.accentColor.toColorInt()
+            config.backgroundColor.toColorInt()
+            config.bottomBackground.toColorInt()
+            return true
+        } catch (_: Exception) {
+            return false
+        }
+    }
+
     private fun getConfigs(): List<Config>? {
         val configFile = File(configFilePath)
         if (configFile.exists()) {
@@ -148,23 +163,27 @@ object ThemeConfig {
     }
 
     fun applyConfig(context: Context, config: Config) {
-        val primary = config.primaryColor.toColorInt()
-        val accent = config.accentColor.toColorInt()
-        val background = config.backgroundColor.toColorInt()
-        val bBackground = config.bottomBackground.toColorInt()
-        if (config.isNightTheme) {
-            context.putPrefInt(PreferKey.cNPrimary, primary)
-            context.putPrefInt(PreferKey.cNAccent, accent)
-            context.putPrefInt(PreferKey.cNBackground, background)
-            context.putPrefInt(PreferKey.cNBBackground, bBackground)
-        } else {
-            context.putPrefInt(PreferKey.cPrimary, primary)
-            context.putPrefInt(PreferKey.cAccent, accent)
-            context.putPrefInt(PreferKey.cBackground, background)
-            context.putPrefInt(PreferKey.cBBackground, bBackground)
+        try {
+            val primary = config.primaryColor.toColorInt()
+            val accent = config.accentColor.toColorInt()
+            val background = config.backgroundColor.toColorInt()
+            val bBackground = config.bottomBackground.toColorInt()
+            if (config.isNightTheme) {
+                context.putPrefInt(PreferKey.cNPrimary, primary)
+                context.putPrefInt(PreferKey.cNAccent, accent)
+                context.putPrefInt(PreferKey.cNBackground, background)
+                context.putPrefInt(PreferKey.cNBBackground, bBackground)
+            } else {
+                context.putPrefInt(PreferKey.cPrimary, primary)
+                context.putPrefInt(PreferKey.cAccent, accent)
+                context.putPrefInt(PreferKey.cBackground, background)
+                context.putPrefInt(PreferKey.cBBackground, bBackground)
+            }
+            AppConfig.isNightTheme = config.isNightTheme
+            applyDayNight(context)
+        } catch (e: Exception) {
+            AppLog.put("设置主题出错\n$e", e, true)
         }
-        AppConfig.isNightTheme = config.isNightTheme
-        applyDayNight(context)
     }
 
     fun saveDayTheme(context: Context, name: String) {

+ 1 - 1
app/src/main/java/io/legato/kazusa/help/glide/BlurTransformation.kt

@@ -12,7 +12,7 @@ import java.security.MessageDigest
  * @radius: 0..25
  */
 class BlurTransformation(
-    @IntRange(from = 0, to = 25) private val radius: Int
+    @param:IntRange(from = 0, to = 25) private val radius: Int
 ) : BitmapTransformation() {
 
     override fun transform(

+ 2 - 1
app/src/main/java/io/legato/kazusa/help/http/DecompressInterceptor.kt

@@ -2,6 +2,7 @@ package io.legato.kazusa.help.http
 
 import okhttp3.Interceptor
 import okhttp3.Response
+import okhttp3.ResponseBody
 import okhttp3.ResponseBody.Companion.asResponseBody
 import okhttp3.internal.http.promisesBody
 import okio.buffer
@@ -24,7 +25,7 @@ object DecompressInterceptor : Interceptor {
         val response = chain.proceed(requestBuilder.build())
         val body = response.body
 
-        if (!transparentDecompress || !response.promisesBody() || body == null) {
+        if (!transparentDecompress || !response.promisesBody() || body == ResponseBody.EMPTY) {
             return response
         }
 

+ 1 - 1
app/src/main/java/io/legato/kazusa/help/http/HttpHelper.kt

@@ -127,7 +127,7 @@ val okHttpClientManga by lazy {
             val response = chain.proceed(request)
             val url = request.url.toString()
             response.newBuilder()
-                .body(ProgressResponseBody(url, LISTENER, response.body!!))
+                .body(ProgressResponseBody(url, LISTENER, response.body))
                 .build()
         }
         interceptors.add(1) { chain ->

+ 10 - 4
app/src/main/java/io/legato/kazusa/help/http/ObsoleteUrlFactory.kt

@@ -18,8 +18,6 @@ import okhttp3.Protocol
 import okhttp3.Request
 import okhttp3.RequestBody
 import okhttp3.Response
-import okhttp3.internal.notifyAll
-import okhttp3.internal.wait
 import okio.Buffer
 import okio.BufferedSink
 import okio.Pipe
@@ -186,7 +184,7 @@ class ObsoleteUrlFactory(private var client: OkHttpClient) : URLStreamHandlerFac
             return try {
                 val response = getResponse(true)
                 if (hasBody(response) && response.code >= HTTP_BAD_REQUEST) {
-                    response.body!!.byteStream()
+                    response.body.byteStream()
                 } else null
             } catch (e: IOException) {
                 null
@@ -258,7 +256,7 @@ class ObsoleteUrlFactory(private var client: OkHttpClient) : URLStreamHandlerFac
             }
             val response = getResponse(false)
             if (response.code >= HTTP_BAD_REQUEST) throw FileNotFoundException(url.toString())
-            return response.body!!.byteStream()
+            return response.body.byteStream()
         }
 
         @Throws(IOException::class)
@@ -1174,6 +1172,14 @@ class ObsoleteUrlFactory(private var client: OkHttpClient) : URLStreamHandlerFac
             throw AssertionError()
         }
 
+        @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
+        private inline fun Any.wait() = (this as Object).wait()
+
+        @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
+        private inline fun Any.notify() = (this as Object).notify()
+
+        @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
+        private inline fun Any.notifyAll() = (this as Object).notifyAll()
         @Throws(Exception::class)
         @JvmStatic
         fun main(args: Array<String>) {

+ 4 - 3
app/src/main/java/io/legato/kazusa/help/source/SourceVerificationHelp.kt

@@ -29,6 +29,7 @@ object SourceVerificationHelp {
      * 获取书源验证结果
      * 图片验证码 防爬 滑动验证码 点击字符 等等
      */
+    @Synchronized
     fun getVerificationResult(
         source: BaseSource?,
         url: String,
@@ -38,9 +39,8 @@ object SourceVerificationHelp {
     ): String {
         source
             ?: throw NoStackTraceException("getVerificationResult parameter source cannot be null")
-        if (isMainThread) {
-            error("getVerificationResult must be called on a background thread")
-        }
+        require(url.length < 64 * 1024) { "getVerificationResult parameter url too long" }
+        check(!isMainThread) { "getVerificationResult must be called on a background thread" }
 
         clearResult(source.getKey())
 
@@ -84,6 +84,7 @@ object SourceVerificationHelp {
         refetchAfterSuccess: Boolean? = true
     ) {
         source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
+        require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
         appCtx.startActivity<WebViewActivity> {
             putExtra("title", title)
             putExtra("url", url)

+ 6 - 3
app/src/main/java/io/legato/kazusa/help/storage/Restore.kt

@@ -1,6 +1,7 @@
 package io.legato.kazusa.help.storage
 
 import android.content.Context
+import android.database.sqlite.SQLiteConstraintException
 import android.net.Uri
 import androidx.core.content.edit
 import androidx.documentfile.provider.DocumentFile
@@ -106,7 +107,6 @@ object Restore {
                 .forEach { book ->
                     book.coverUrl = LocalBook.getCoverPath(book)
                 }
-            val updateBooks = arrayListOf<Book>()
             val newBooks = arrayListOf<Book>()
             val ignoreLocalBook = BackupConfig.ignoreLocalBook
             it.forEach { book ->
@@ -114,12 +114,15 @@ object Restore {
                     return@forEach
                 }
                 if (appDb.bookDao.has(book.bookUrl)) {
-                    updateBooks.add(book)
+                    try {
+                        appDb.bookDao.update(book)
+                    } catch (_: SQLiteConstraintException) {
+                        appDb.bookDao.insert(book)
+                    }
                 } else {
                     newBooks.add(book)
                 }
             }
-            appDb.bookDao.update(*updateBooks.toTypedArray())
             appDb.bookDao.insert(*newBooks.toTypedArray())
         }
         fileToListT<Bookmark>(path, "bookmark.json")?.let {

+ 25 - 9
app/src/main/java/io/legato/kazusa/lib/cronet/AbsCallBack.kt

@@ -9,12 +9,19 @@ import io.legato.kazusa.utils.DebugLog
 import io.legato.kazusa.utils.asIOException
 import io.legato.kazusa.utils.splitNotBlank
 import kotlinx.coroutines.delay
-import okhttp3.*
+import okhttp3.Call
+import okhttp3.Callback
 import okhttp3.EventListener
+import okhttp3.Headers
 import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
 import okhttp3.ResponseBody.Companion.asResponseBody
+import okhttp3.internal.http.HTTP_PERM_REDIRECT
+import okhttp3.internal.http.HTTP_TEMP_REDIRECT
 import okhttp3.internal.http.HttpMethod
-import okhttp3.internal.http.StatusLine
 import okio.Buffer
 import okio.Source
 import okio.Timeout
@@ -25,7 +32,7 @@ import org.chromium.net.UrlResponseInfo
 import java.io.IOException
 import java.net.ProtocolException
 import java.nio.ByteBuffer
-import java.util.*
+import java.util.Locale
 import java.util.concurrent.ArrayBlockingQueue
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.atomic.AtomicBoolean
@@ -98,9 +105,15 @@ abstract class AbsCallBack(
         followCount += 1
         urlResponseInfoChain.add(info)
         val client = okHttpClient
-        if (originalRequest.url.isHttps && newLocationUrl.startsWith("http://") && client.followSslRedirects) {
+        if (originalRequest.url.isHttps
+            && newLocationUrl.startsWith("http://")
+            && client.followSslRedirects
+        ) {
             followRedirect = true
-        } else if (!originalRequest.url.isHttps && newLocationUrl.startsWith("https://") && client.followSslRedirects) {
+        } else if (!originalRequest.url.isHttps
+            && newLocationUrl.startsWith("https://")
+            && client.followSslRedirects
+        ) {
             followRedirect = true
         } else if (okHttpClient.followRedirects) {
             followRedirect = true
@@ -315,7 +328,7 @@ abstract class AbsCallBack(
                     contentLength,
                     bodySource
                 )
-            }
+            } ?: ResponseBody.EMPTY
 
             return Response.Builder()
                 .request(request)
@@ -381,9 +394,12 @@ abstract class AbsCallBack(
             if (HttpMethod.permitsRequestBody(method)) {
                 val responseCode = userResponse.code
                 val maintainBody = HttpMethod.redirectsWithBody(method) ||
-                        responseCode == StatusLine.HTTP_PERM_REDIRECT ||
-                        responseCode == StatusLine.HTTP_TEMP_REDIRECT
-                if (HttpMethod.redirectsToGet(method) && responseCode != StatusLine.HTTP_PERM_REDIRECT && responseCode != StatusLine.HTTP_TEMP_REDIRECT) {
+                        responseCode == HTTP_PERM_REDIRECT ||
+                        responseCode == HTTP_TEMP_REDIRECT
+                if (HttpMethod.redirectsToGet(method)
+                    && responseCode != HTTP_PERM_REDIRECT
+                    && responseCode != HTTP_TEMP_REDIRECT
+                ) {
                     requestBuilder.method("GET", null)
                 } else {
                     val requestBody = if (maintainBody) userResponse.request.body else null

+ 1 - 1
app/src/main/java/io/legato/kazusa/lib/mobi/MobiBook.kt

@@ -16,11 +16,11 @@ import io.legato.kazusa.lib.mobi.entities.Ptagx
 import io.legato.kazusa.lib.mobi.entities.TOC
 import io.legato.kazusa.lib.mobi.entities.TagxHeader
 import io.legato.kazusa.lib.mobi.entities.TagxTag
+import io.legato.kazusa.lib.mobi.utils.and
 import io.legato.kazusa.lib.mobi.utils.readString
 import io.legato.kazusa.lib.mobi.utils.readUInt16Array
 import io.legato.kazusa.lib.mobi.utils.readUInt32
 import io.legato.kazusa.lib.mobi.utils.readUInt8
-import okhttp3.internal.and
 import java.io.ByteArrayInputStream
 import java.io.InputStream
 import java.nio.ByteBuffer

+ 7 - 0
app/src/main/java/io/legato/kazusa/lib/mobi/utils/BitwiseExtensions.kt

@@ -0,0 +1,7 @@
+package io.legato.kazusa.lib.mobi.utils
+
+internal infix fun Byte.and(mask: Int): Int = toInt() and mask
+
+internal infix fun Short.and(mask: Int): Int = toInt() and mask
+
+internal infix fun Int.and(mask: Long): Long = toLong() and mask

+ 0 - 1
app/src/main/java/io/legato/kazusa/lib/mobi/utils/ByteBufferExtensions.kt

@@ -1,6 +1,5 @@
 package io.legato.kazusa.lib.mobi.utils
 
-import okhttp3.internal.and
 import java.nio.ByteBuffer
 import java.nio.charset.Charset
 

+ 15 - 6
app/src/main/java/io/legato/kazusa/lib/webdav/WebDav.kt

@@ -18,6 +18,7 @@ import io.legato.kazusa.utils.toRequestBody
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl.Companion.toHttpUrl
 import okhttp3.Interceptor
 import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.RequestBody.Companion.asRequestBody
@@ -92,6 +93,7 @@ open class WebDav(
                 .replace("+", "%20")
                 .replace("%3A", ":")
                 .replace("%2F", "/")
+            raw.toHttpUrl().toString()
         }.getOrNull()
     }
     private val webDavClient by lazy {
@@ -112,7 +114,14 @@ open class WebDav(
             build()
         }
     }
-    val host: String? get() = url.host
+    private val host: String?
+        get() = url.host?.let {
+            if (it.startsWith("[")) {
+                it.substring(1, it.lastIndex)
+            } else {
+                it
+            }
+        }
 
     /**
      * 获取当前url文件信息
@@ -165,7 +174,7 @@ open class WebDav(
             method("PROPFIND", requestBody)
         }.apply {
             checkResult(this)
-        }.body?.text()
+        }.body.text()
     }
 
     /**
@@ -397,8 +406,8 @@ open class WebDav(
             url(url)
         }.apply {
             checkResult(this)
-        }.body?.byteStream()
-        return byteStream ?: throw WebDavException("WebDav下载出错\nNull Exception")
+        }.body.byteStream()
+        return byteStream
     }
 
     /**
@@ -425,7 +434,7 @@ open class WebDav(
      */
     private fun checkResult(response: Response) {
         if (!response.isSuccessful) {
-            val body = response.body?.string()
+            val body = response.body.string()
             if (response.code == 401) {
                 val headers = response.headers("WWW-Authenticate")
                 val supportBasicAuth = headers.any {
@@ -436,7 +445,7 @@ open class WebDav(
                 }
             }
 
-            if (response.message.isNotBlank() || body.isNullOrBlank()) {
+            if (response.message.isNotBlank() || body.isBlank()) {
                 throw WebDavException("${url}\n${response.code}:${response.message}")
             }
             val document = Jsoup.parse(body)

+ 47 - 6
app/src/main/java/io/legato/kazusa/model/AudioPlay.kt

@@ -31,6 +31,22 @@ import splitties.init.appCtx
 @SuppressLint("StaticFieldLeak")
 @Suppress("unused")
 object AudioPlay : CoroutineScope by MainScope() {
+
+    enum class PlayMode(val iconRes: Int) {
+        LIST_END_STOP(R.drawable.ic_play_mode_list_end_stop),
+        RANDOM(R.drawable.ic_play_mode_random),
+        LIST_LOOP(R.drawable.ic_play_mode_list_loop);
+
+        fun next(): PlayMode {
+            return when (this) {
+                LIST_END_STOP -> RANDOM
+                RANDOM -> LIST_LOOP
+                LIST_LOOP -> LIST_END_STOP
+            }
+        }
+    }
+
+    var playMode = PlayMode.LIST_END_STOP
     var status = Status.STOP
     private var activityContext: Context? = null
     private var serviceContext: Context? = null
@@ -48,6 +64,11 @@ object AudioPlay : CoroutineScope by MainScope() {
     var bookSource: BookSource? = null
     val loadingChapters = arrayListOf<Int>()
 
+    fun changePlayMode() {
+        playMode = playMode.next()
+        postEvent(EventBus.PLAY_MODE_CHANGED, playMode)
+    }
+
     fun upData(book: Book) {
         AudioPlay.book = book
         chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
@@ -259,12 +280,32 @@ object AudioPlay : CoroutineScope by MainScope() {
 
     fun next() {
         stopPlay()
-        if (durChapterIndex + 1 < simulatedChapterSize) {
-            durChapterIndex += 1
-            durChapterPos = 0
-            durPlayUrl = ""
-            saveRead()
-            loadPlayUrl()
+        when (playMode) {
+            PlayMode.LIST_END_STOP -> {
+                if (durChapterIndex + 1 < simulatedChapterSize) {
+                    durChapterIndex += 1
+                    durChapterPos = 0
+                    durPlayUrl = ""
+                    saveRead()
+                    loadPlayUrl()
+                }
+            }
+
+            PlayMode.RANDOM -> {
+                durChapterIndex = (0 until simulatedChapterSize).random()
+                durChapterPos = 0
+                durPlayUrl = ""
+                saveRead()
+                loadPlayUrl()
+            }
+
+            PlayMode.LIST_LOOP -> {
+                durChapterIndex = (durChapterIndex + 1) % simulatedChapterSize
+                durChapterPos = 0
+                durPlayUrl = ""
+                saveRead()
+                loadPlayUrl()
+            }
         }
     }
 

+ 2 - 2
app/src/main/java/io/legato/kazusa/model/BookCover.kt

@@ -135,7 +135,6 @@ object BookCover {
     /**
      * 加载漫画图片
      */
-    @SuppressLint("CheckResult")
     fun loadManga(
         context: Context,
         path: String?,
@@ -155,8 +154,9 @@ object BookCover {
             .skipMemoryCache(true).let {
                 if (transformation != null) {
                     it.transform(transformation)
+                } else {
+                    it
                 }
-                it
             }
             .transition(DrawableTransitionOptions.withCrossFade())
     }

+ 8 - 7
app/src/main/java/io/legato/kazusa/model/CacheBook.kt

@@ -11,6 +11,7 @@ import io.legato.kazusa.data.entities.BookSource
 import io.legato.kazusa.exception.ConcurrentException
 import io.legato.kazusa.help.book.BookHelp
 import io.legato.kazusa.help.book.isLocal
+import io.legato.kazusa.help.coroutine.CompositeCoroutine
 import io.legato.kazusa.help.coroutine.Coroutine
 import io.legato.kazusa.model.webBook.WebBook
 import io.legato.kazusa.service.CacheBookService
@@ -96,15 +97,11 @@ object CacheBook {
         }
     }
 
-    fun clear() {
-        successDownloadSet.clear()
-        errorDownloadMap.clear()
-    }
-
     fun close() {
         cacheBookMap.forEach { it.value.stop() }
         cacheBookMap.clear()
-        clear()
+        successDownloadSet.clear()
+        errorDownloadMap.clear()
     }
 
     val downloadSummary: String
@@ -146,6 +143,7 @@ object CacheBook {
 
         private val waitDownloadSet = linkedSetOf<Int>()
         private val onDownloadSet = linkedSetOf<Int>()
+        private val tasks = CompositeCoroutine()
         private var isStopped = false
         private var waitingRetry = false
 
@@ -158,7 +156,7 @@ object CacheBook {
 
         @Synchronized
         fun isRun(): Boolean {
-            return waitDownloadSet.size > 0 || onDownloadSet.size > 0
+            return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty()
         }
 
         @Synchronized
@@ -169,6 +167,7 @@ object CacheBook {
         @Synchronized
         fun stop() {
             waitDownloadSet.clear()
+            tasks.clear()
             isStopped = true
             postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
         }
@@ -309,6 +308,8 @@ object CacheBook {
                 onCancel(chapterIndex)
             }.onFinally {
                 onFinally()
+            }.apply {
+                tasks.add(this)
             }.start()
         }
 

+ 29 - 25
app/src/main/java/io/legato/kazusa/model/ReadBook.kt

@@ -63,20 +63,8 @@ object ReadBook : CoroutineScope by MainScope() {
     var isLocalBook = true
     var chapterChanged = false
     var prevTextChapter: TextChapter? = null
-        set(value) {
-            field?.cancelLayout()
-            field = value
-        }
     var curTextChapter: TextChapter? = null
-        set(value) {
-            field?.cancelLayout()
-            field = value
-        }
     var nextTextChapter: TextChapter? = null
-        set(value) {
-            field?.cancelLayout()
-            field = value
-        }
     var bookSource: BookSource? = null
     var msg: String? = null
     private val loadingChapters = arrayListOf<Int>()
@@ -222,6 +210,9 @@ object ReadBook : CoroutineScope by MainScope() {
     }
 
     fun clearTextChapter() {
+        prevTextChapter?.cancelLayout()
+        curTextChapter?.cancelLayout()
+        nextTextChapter?.cancelLayout()
         prevTextChapter = null
         curTextChapter = null
         nextTextChapter = null
@@ -333,6 +324,7 @@ object ReadBook : CoroutineScope by MainScope() {
         if (durChapterIndex < simulatedChapterSize - 1) {
             durChapterPos = 0
             durChapterIndex++
+            prevTextChapter?.cancelLayout()
             prevTextChapter = curTextChapter
             curTextChapter = nextTextChapter
             nextTextChapter = null
@@ -363,6 +355,7 @@ object ReadBook : CoroutineScope by MainScope() {
         if (durChapterIndex < simulatedChapterSize - 1) {
             durChapterPos = 0
             durChapterIndex++
+            prevTextChapter?.cancelLayout()
             prevTextChapter = curTextChapter
             curTextChapter = nextTextChapter
             nextTextChapter = null
@@ -394,6 +387,7 @@ object ReadBook : CoroutineScope by MainScope() {
         if (durChapterIndex > 0) {
             durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
             durChapterIndex--
+            nextTextChapter?.cancelLayout()
             nextTextChapter = curTextChapter
             curTextChapter = prevTextChapter
             prevTextChapter = null
@@ -703,6 +697,7 @@ object ReadBook : CoroutineScope by MainScope() {
             )
             when (val offset = chapter.index - durChapterIndex) {
                 0 -> {
+                    curTextChapter?.cancelLayout()
                     curTextChapter = textChapter
                     callBack?.upMenuView()
                     var available = false
@@ -727,12 +722,14 @@ object ReadBook : CoroutineScope by MainScope() {
                 }
 
                 -1 -> {
+                    prevTextChapter?.cancelLayout()
                     prevTextChapter = textChapter
                     textChapter.layoutChannel.receiveAsFlow().collect()
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
 
                 1 -> {
+                    nextTextChapter?.cancelLayout()
                     nextTextChapter = textChapter
                     for (page in textChapter.layoutChannel) {
                         if (page.index > 1) {
@@ -776,6 +773,7 @@ object ReadBook : CoroutineScope by MainScope() {
             )
             when (val offset = chapter.index - durChapterIndex) {
                 0 -> {
+                    curTextChapter?.cancelLayout()
                     curTextChapter = textChapter
                     callBack?.upMenuView()
                     var available = false
@@ -800,12 +798,14 @@ object ReadBook : CoroutineScope by MainScope() {
                 }
 
                 -1 -> {
+                    prevTextChapter?.cancelLayout()
                     prevTextChapter = textChapter
                     textChapter.layoutChannel.receiveAsFlow().collect()
                     if (upContent) callBack?.upContent(offset, resetPageOffset)
                 }
 
                 1 -> {
+                    nextTextChapter?.cancelLayout()
                     nextTextChapter = textChapter
                     for (page in textChapter.layoutChannel) {
                         if (page.index > 1) {
@@ -858,21 +858,25 @@ object ReadBook : CoroutineScope by MainScope() {
 
     fun saveRead(pageChanged: Boolean = false) {
         executor.execute {
-            val book = book ?: return@execute
-            book.lastCheckCount = 0
-            book.durChapterTime = System.currentTimeMillis()
-            val chapterChanged = book.durChapterIndex != durChapterIndex
-            book.durChapterIndex = durChapterIndex
-            book.durChapterPos = durChapterPos
-            if (!pageChanged || chapterChanged) {
-                appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
-                    book.durChapterTitle = it.getDisplayTitle(
-                        ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                        book.getUseReplaceRule()
-                    )
+            kotlin.runCatching {
+                val book = book ?: return@execute
+                book.lastCheckCount = 0
+                book.durChapterTime = System.currentTimeMillis()
+                val chapterChanged = book.durChapterIndex != durChapterIndex
+                book.durChapterIndex = durChapterIndex
+                book.durChapterPos = durChapterPos
+                if (!pageChanged || chapterChanged) {
+                    appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
+                        book.durChapterTitle = it.getDisplayTitle(
+                            ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
+                            book.getUseReplaceRule()
+                        )
+                    }
                 }
+                appDb.bookDao.update(book)
+            }.onFailure {
+                AppLog.put("保存书籍阅读进度信息出错\n$it", it)
             }
-            appDb.bookDao.update(book)
         }
     }
 

+ 17 - 13
app/src/main/java/io/legato/kazusa/model/ReadManga.kt

@@ -327,21 +327,25 @@ object ReadManga : CoroutineScope by MainScope() {
 
     fun saveRead(pageChanged: Boolean = false) {
         executor.execute {
-            val book = book ?: return@execute
-            book.lastCheckCount = 0
-            book.durChapterTime = System.currentTimeMillis()
-            val chapterChanged = book.durChapterIndex != durChapterIndex
-            book.durChapterIndex = durChapterIndex
-            book.durChapterPos = durChapterPos
-            if (!pageChanged || chapterChanged) {
-                appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
-                    book.durChapterTitle = it.getDisplayTitle(
-                        ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                        book.getUseReplaceRule()
-                    )
+            kotlin.runCatching {
+                val book = book ?: return@execute
+                book.lastCheckCount = 0
+                book.durChapterTime = System.currentTimeMillis()
+                val chapterChanged = book.durChapterIndex != durChapterIndex
+                book.durChapterIndex = durChapterIndex
+                book.durChapterPos = durChapterPos
+                if (!pageChanged || chapterChanged) {
+                    appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
+                        book.durChapterTitle = it.getDisplayTitle(
+                            ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
+                            book.getUseReplaceRule()
+                        )
+                    }
                 }
+                appDb.bookDao.update(book)
+            }.onFailure {
+                AppLog.put("保存漫画阅读进度信息出错\n$it", it)
             }
-            appDb.bookDao.update(book)
         }
     }
 

+ 22 - 23
app/src/main/java/io/legato/kazusa/model/analyzeRule/AnalyzeUrl.kt

@@ -39,7 +39,6 @@ import io.legato.kazusa.help.http.postForm
 import io.legato.kazusa.help.http.postJson
 import io.legato.kazusa.help.http.postMultipart
 import io.legato.kazusa.help.source.getShareScope
-import io.legato.kazusa.model.Debug
 import io.legato.kazusa.utils.EncoderUtils
 import io.legato.kazusa.utils.GSON
 import io.legato.kazusa.utils.GSONStrict
@@ -225,32 +224,32 @@ class AnalyzeUrl(
             if (urlOption == null) {
                 urlOption = GSON.fromJsonObject<UrlOption>(urlOptionStr).getOrNull()
                 if (urlOption != null) {
-                    Debug.log("≡链接参数 JSON 格式不规范,请改为规范格式")
+                    log("链接参数 JSON 格式不规范,请改为规范格式")
                 }
             }
             urlOption?.let { option ->
-                    option.getMethod()?.let {
-                        if (it.equals("POST", true)) method = RequestMethod.POST
-                    }
-                    option.getHeaderMap()?.forEach { entry ->
-                        headerMap[entry.key.toString()] = entry.value.toString()
-                    }
-                    option.getBody()?.let {
-                        body = it
-                    }
-                    type = option.getType()
-                    charset = option.getCharset()
-                    retry = option.getRetry()
-                    useWebView = option.useWebView()
-                    webJs = option.getWebJs()
-                    option.getJs()?.let { jsStr ->
-                        evalJS(jsStr, url)?.toString()?.let {
-                            url = it
-                        }
+                option.getMethod()?.let {
+                    if (it.equals("POST", true)) method = RequestMethod.POST
+                }
+                option.getHeaderMap()?.forEach { entry ->
+                    headerMap[entry.key.toString()] = entry.value.toString()
+                }
+                option.getBody()?.let {
+                    body = it
+                }
+                type = option.getType()
+                charset = option.getCharset()
+                retry = option.getRetry()
+                useWebView = option.useWebView()
+                webJs = option.getWebJs()
+                option.getJs()?.let { jsStr ->
+                    evalJS(jsStr, url)?.toString()?.let {
+                        url = it
                     }
                     serverID = option.getServerID()
                     webViewDelayTime = max(0, option.getWebViewDelayTime() ?: 0)
                 }
+            }
         }
         urlNoQuery = url
         when (method) {
@@ -458,7 +457,7 @@ class AnalyzeUrl(
                         else -> get(urlNoQuery, encodedQuery)
                     }
                 }.let {
-                    val isXml = it.raw.body?.contentType()?.toString()
+                    val isXml = it.raw.body.contentType()?.toString()
                         ?.matches(AppPattern.xmlContentTypeRegex) == true
                     if (isXml && it.body?.trim()?.startsWith("<?xml", true) == false) {
                         StrResponse(it.raw, "<?xml version=\"1.0\"?>" + it.body)
@@ -553,7 +552,7 @@ class AnalyzeUrl(
         getByteArrayIfDataUri()?.let {
             return it
         }
-        return getResponseAwait().body!!.bytes()
+        return getResponseAwait().body.bytes()
     }
 
     fun getByteArray(): ByteArray {
@@ -569,7 +568,7 @@ class AnalyzeUrl(
         getByteArrayIfDataUri()?.let {
             return ByteArrayInputStream(it)
         }
-        return getResponseAwait().body!!.byteStream()
+        return getResponseAwait().body.byteStream()
     }
 
     fun getInputStream(): InputStream {

+ 10 - 3
app/src/main/java/io/legato/kazusa/model/localBook/LocalBook.kt

@@ -2,6 +2,7 @@ package io.legato.kazusa.model.localBook
 
 import android.net.Uri
 import android.util.Base64
+import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
 import com.script.ScriptBindings
 import com.script.rhino.RhinoScriptEngine
@@ -103,7 +104,7 @@ object LocalBook {
 
     fun getLastModified(book: Book): Result<Long> {
         return kotlin.runCatching {
-            val uri = Uri.parse(book.bookUrl)
+            val uri = book.bookUrl.toUri()
             if (uri.isContentScheme()) {
                 return@runCatching DocumentFile.fromSingleUri(appCtx, uri)!!.lastModified()
             }
@@ -144,6 +145,9 @@ object LocalBook {
         val list = ArrayList(LinkedHashSet(chapters))
         list.forEachIndexed { index, bookChapter ->
             bookChapter.index = index
+            if (bookChapter.title.isEmpty()) {
+                bookChapter.title = "无标题章节"
+            }
         }
         val replaceRules = ContentProcessor.get(book).getTitleReplaceRules()
         book.durChapterTitle = list.getOrElse(book.durChapterIndex) { list.last() }
@@ -191,6 +195,9 @@ object LocalBook {
                 return StringEscapeUtils.unescapeHtml4(content)
             }
         }
+        if (content.isNullOrEmpty()) {
+            return null
+        }
         return content
     }
 
@@ -374,7 +381,7 @@ object LocalBook {
             }
             if (deleteOriginal) {
                 if (book.bookUrl.isContentScheme()) {
-                    val uri = Uri.parse(book.bookUrl)
+                    val uri = book.bookUrl.toUri()
                     DocumentFile.fromSingleUri(appCtx, uri)?.delete()
                 } else {
                     FileUtils.delete(book.bookUrl)
@@ -419,7 +426,7 @@ object LocalBook {
         inputStream.use {
             val defaultBookTreeUri = AppConfig.defaultBookTreeUri
             if (defaultBookTreeUri.isNullOrBlank()) throw NoBooksDirException()
-            val treeUri = Uri.parse(defaultBookTreeUri)
+            val treeUri = defaultBookTreeUri.toUri()
             return if (treeUri.isContentScheme()) {
                 val treeDoc = DocumentFile.fromTreeUri(appCtx, treeUri)
                 var doc = treeDoc!!.findFile(fileName)

+ 1 - 1
app/src/main/java/io/legato/kazusa/model/localBook/MobiFile.kt

@@ -229,7 +229,7 @@ class MobiFile(var book: Book) {
                 break
             }
             section = section.next ?: break
-            if (section.linear) {
+            if (!section.linear) {
                 continue
             }
             if (section.href == nextSectionHref) {

+ 4 - 0
app/src/main/java/io/legato/kazusa/model/localBook/TextFile.kt

@@ -215,6 +215,7 @@ class TextFile(private var book: Book) {
                         val curChapter = BookChapter()
                         curChapter.title = matcher.group()
                         curChapter.start = curOffset + chapterLength
+                        curChapter.end = curChapter.start
                         toc.add(curChapter)
                         lastChapterWordCount = 0
                     } else if (seekPos == 0 && chapterStart != 0) {
@@ -242,6 +243,7 @@ class TextFile(private var book: Book) {
                             val curChapter = BookChapter()
                             curChapter.title = matcher.group()
                             curChapter.start = curOffset + chapterLength
+                            curChapter.end = curChapter.start
                             toc.add(curChapter)
                         } else { //否则就block分割之后,上一个章节的剩余内容
                             //获取上一章节
@@ -257,6 +259,7 @@ class TextFile(private var book: Book) {
                             val curChapter = BookChapter()
                             curChapter.title = matcher.group()
                             curChapter.start = lastChapter.end
+                            curChapter.end = curChapter.start
                             toc.add(curChapter)
                         }
                         bookWordCount += chapterContent.length
@@ -275,6 +278,7 @@ class TextFile(private var book: Book) {
                             val curChapter = BookChapter()
                             curChapter.title = matcher.group()
                             curChapter.start = lastChapter.end
+                            curChapter.end = curChapter.start
                             toc.add(curChapter)
                         } else { //如果章节不存在则创建章节
                             val curChapter = BookChapter()

+ 12 - 1
app/src/main/java/io/legato/kazusa/model/webBook/SearchModel.kt

@@ -17,8 +17,10 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.asCoroutineDispatcher
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onEach
@@ -39,7 +41,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
     private var bookSourceParts = emptyList<BookSourcePart>()
     private var searchBooks = arrayListOf<SearchBook>()
     private var searchJob: Job? = null
-
+    private var workingState = MutableStateFlow(true)
 
     private fun initSearchPool() {
         searchPool?.close()
@@ -82,6 +84,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
                     bs.getBookSource()?.let {
                         emit(it)
                     }
+                    workingState.first { it }
                 }
             }.onStart {
                 callBack.onSearchStart()
@@ -191,6 +194,14 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
         mSearchId = 0L
     }
 
+    fun pause() {
+        workingState.value = false
+    }
+
+    fun resume() {
+        workingState.value = true
+    }
+
     interface CallBack {
         fun getSearchScope(): SearchScope
         fun onSearchStart()

+ 70 - 16
app/src/main/java/io/legato/kazusa/service/BaseReadAloudService.kt

@@ -27,6 +27,7 @@ import io.legato.kazusa.R
 import io.legato.kazusa.base.BaseService
 import io.legato.kazusa.constant.AppConst
 import io.legato.kazusa.constant.AppLog
+import io.legato.kazusa.constant.AppPattern
 import io.legato.kazusa.constant.EventBus
 import io.legato.kazusa.constant.IntentAction
 import io.legato.kazusa.constant.NotificationId
@@ -45,9 +46,7 @@ import io.legato.kazusa.ui.book.read.ReadBookActivity
 import io.legato.kazusa.ui.book.read.page.entities.TextChapter
 import io.legato.kazusa.utils.LogUtils
 import io.legato.kazusa.utils.activityPendingIntent
-import io.legato.kazusa.utils.broadcastPendingIntent
 import io.legato.kazusa.utils.getPrefBoolean
-import io.legato.kazusa.utils.isVivoDevice
 import io.legato.kazusa.utils.observeEvent
 import io.legato.kazusa.utils.observeSharedPreferences
 import io.legato.kazusa.utils.postEvent
@@ -326,9 +325,11 @@ abstract class BaseReadAloudService : BaseService(),
     private fun prevP() {
         if (nowSpeak > 0) {
             playStop()
-            nowSpeak--
-            readAloudNumber -= contentList[nowSpeak].length + 1 + paragraphStartPos
-            paragraphStartPos = 0
+            do {
+                nowSpeak--
+                readAloudNumber -= contentList[nowSpeak].length + 1 + paragraphStartPos
+                paragraphStartPos = 0
+            } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
             textChapter?.let {
                 if (readAloudByPage) {
                     val paragraphs = it.getParagraphs(true)
@@ -358,7 +359,9 @@ abstract class BaseReadAloudService : BaseService(),
                     val paragraphs = it.getParagraphs(true)
                     if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber--
                 }
-                if (readAloudNumber >= it.getReadLength(pageIndex + 1)) {
+                if (pageIndex + 1 < it.pageSize
+                    && readAloudNumber >= it.getReadLength(pageIndex + 1)
+                ) {
                     pageIndex++
                     ReadBook.moveToNextPage()
                 }
@@ -443,24 +446,75 @@ abstract class BaseReadAloudService : BaseService(),
             PlaybackStateCompat.Builder()
                 .setActions(MediaHelp.MEDIA_SESSION_ACTIONS)
                 .setState(state, nowSpeak.toLong(), 1f)
+                // 为系统媒体控件添加定时按钮
+                .addCustomAction(
+                    PlaybackStateCompat.CustomAction.Builder(
+                        "ACTION_ADD_TIMER",
+                        getString(R.string.set_timer),
+                        R.drawable.ic_time_add_24dp
+                    ).build()
+                )
                 .build()
         )
     }
 
+    /**
+     * 初始化MediaSession, 注册多媒体按钮
+     */
     /**
      * 初始化MediaSession, 注册多媒体按钮
      */
     @SuppressLint("UnspecifiedImmutableFlag")
     private fun initMediaSession() {
-        mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
-            override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
-                return MediaButtonReceiver.handleIntent(this@BaseReadAloudService, mediaButtonEvent)
-            }
-        })
-        mediaSessionCompat.setMediaButtonReceiver(
-            broadcastPendingIntent<MediaButtonReceiver>(Intent.ACTION_MEDIA_BUTTON)
-        )
-        mediaSessionCompat.isActive = true
+        if (getPrefBoolean("systemMediaControlCompatibilityChange")) {
+            mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
+                override fun onPlay() {
+                    resumeReadAloud()
+                }
+
+                override fun onPause() {
+                    pauseReadAloud()
+                }
+
+                override fun onSkipToNext() {
+                    if (getPrefBoolean("mediaButtonPerNext", false)) {
+                        nextChapter()
+                    } else {
+                        nextP()
+                    }
+                }
+
+                override fun onSkipToPrevious() {
+                    if (getPrefBoolean("mediaButtonPerNext", false)) {
+                        prevChapter()
+                    } else {
+                        prevP()
+                    }
+                }
+
+                override fun onStop() {
+                    stopSelf()
+                }
+
+                override fun onCustomAction(action: String, extras: Bundle?) {
+                    if (action == "ACTION_ADD_TIMER") addTimer()
+                }
+
+                override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
+                    return MediaButtonReceiver.handleIntent(
+                        this@BaseReadAloudService, mediaButtonEvent
+                    )
+                }
+            })
+        } else {
+            mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
+                override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
+                    return MediaButtonReceiver.handleIntent(
+                        this@BaseReadAloudService, mediaButtonEvent
+                    )
+                }
+            })
+        }
     }
 
     /**
@@ -523,7 +577,7 @@ abstract class BaseReadAloudService : BaseService(),
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
             .setShowActionsInCompactView(1, 2, 4)
-        if (isVivoDevice) {
+        if (getPrefBoolean("systemMediaControlCompatibilityChange")) {
             //fix #4090 android 14 can not show play control in lock screen
             mediaStyle.setMediaSession(mediaSessionCompat.sessionToken)
         }

+ 0 - 1
app/src/main/java/io/legato/kazusa/service/CacheBookService.kt

@@ -65,7 +65,6 @@ class CacheBookService : BaseService() {
     override fun onCreate() {
         super.onCreate()
         isRun = true
-        CacheBook.clear()
         lifecycleScope.launch {
             while (isActive) {
                 delay(1000)

+ 10 - 9
app/src/main/java/io/legato/kazusa/service/HttpReadAloudService.kt

@@ -3,6 +3,7 @@ package io.legato.kazusa.service
 import android.annotation.SuppressLint
 import android.app.PendingIntent
 import android.net.Uri
+import androidx.core.net.toUri
 import androidx.lifecycle.lifecycleScope
 import androidx.media3.common.C
 import androidx.media3.common.MediaItem
@@ -306,7 +307,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
 
     private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader {
-        val uri = Uri.parse(fileName)
+        val uri = fileName.toUri()
         val request = DownloadRequest.Builder(fileName, uri).build()
         return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService)
             .createDownloader(request)
@@ -343,17 +344,17 @@ class HttpReadAloudService : BaseReadAloudService(),
                     val contentType = contentType.substringBefore(";")
                     val ct = httpTts.contentType
                     if (contentType == "application/json" || contentType.startsWith("text/")) {
-                        throw NoStackTraceException(response.body!!.string())
+                        throw NoStackTraceException(response.body.string())
                     } else if (ct?.isNotBlank() == true) {
                         if (!contentType.matches(ct.toRegex())) {
                             throw NoStackTraceException(
-                                "TTS服务器返回错误:" + response.body!!.string()
+                                "TTS服务器返回错误:" + response.body.string()
                             )
                         }
                     }
                 }
                 coroutineContext.ensureActive()
-                response.body!!.byteStream().let { stream ->
+                response.body.byteStream().let { stream ->
                     downloadErrorNo = 0
                     return stream
                 }
@@ -477,12 +478,12 @@ class HttpReadAloudService : BaseReadAloudService(),
             val sleep = exoPlayer.duration / speakTextLength
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
             for (i in start..contentList[nowSpeak].length) {
-                if (readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)) {
+                if (pageIndex + 1 < textChapter.pageSize
+                    && readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)
+                ) {
                     pageIndex++
-                    if (pageIndex < textChapter.pageSize) {
-                        ReadBook.moveToNextPage()
-                        upTtsProgress(readAloudNumber + i.toInt())
-                    }
+                    ReadBook.moveToNextPage()
+                    upTtsProgress(readAloudNumber + i.toInt())
                 }
                 delay(sleep)
             }

+ 6 - 2
app/src/main/java/io/legato/kazusa/service/TTSReadAloudService.kt

@@ -195,7 +195,9 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph()
                 }
-                if (readAloudNumber + 1 > it.getReadLength(pageIndex + 1)) {
+                if (pageIndex + 1 < it.pageSize
+                    && readAloudNumber + 1 > it.getReadLength(pageIndex + 1)
+                ) {
                     pageIndex++
                     ReadBook.moveToNextPage()
                 }
@@ -214,7 +216,9 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
             LogUtils.d(TAG, msg)
             textChapter?.let {
-                if (readAloudNumber + start > it.getReadLength(pageIndex + 1)) {
+                if (pageIndex + 1 < it.pageSize
+                    && readAloudNumber + start > it.getReadLength(pageIndex + 1)
+                ) {
                     pageIndex++
                     ReadBook.moveToNextPage()
                     upTtsProgress(readAloudNumber + start)

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/association/OnLineImportViewModel.kt

@@ -56,7 +56,7 @@ class OnLineImportViewModel(app: Application) : BaseAssociationViewModel(app) {
 
     fun importReadConfig(bytes: ByteArray, finally: (title: String, msg: String) -> Unit) {
         execute {
-            val config = ReadBookConfig.import(bytes).getOrThrow()
+            val config = ReadBookConfig.import(bytes)
             ReadBookConfig.configList.forEachIndexed { index, c ->
                 if (c.name == config.name) {
                     ReadBookConfig.configList[index] = config

+ 13 - 0
app/src/main/java/io/legato/kazusa/ui/book/audio/AudioPlayActivity.kt

@@ -64,6 +64,7 @@ class AudioPlayActivity :
     override val viewModel by viewModels<AudioPlayViewModel>()
     private val timerSliderPopup by lazy { TimerSliderPopup(this) }
     private var adjustProgress = false
+    private var playMode = AudioPlay.PlayMode.LIST_END_STOP
 
     private val progressTimeFormat by lazy {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -140,6 +141,14 @@ class AudioPlayActivity :
     }
 
     private fun initView() {
+        binding.ivPlayMode.setOnClickListener {
+            AudioPlay.changePlayMode()
+        }
+
+        observeEventSticky<AudioPlay.PlayMode>(EventBus.PLAY_MODE_CHANGED) {
+            playMode = it
+            updatePlayModeIcon()
+        }
         binding.fabPlayStop.setOnClickListener {
             playButton()
         }
@@ -207,6 +216,10 @@ class AudioPlayActivity :
         binding.llPlayMenu.applyNavigationBarPadding()
     }
 
+    private fun updatePlayModeIcon() {
+        binding.ivPlayMode.setImageResource(playMode.iconRes)
+    }
+
     private fun upCover(path: String?) {
         BookCover.load(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl) {
             BookCover.loadBlur(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl)

+ 2 - 2
app/src/main/java/io/legato/kazusa/ui/book/audio/AudioPlayViewModel.kt

@@ -26,7 +26,7 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
 
     fun initData(intent: Intent) = AudioPlay.apply {
         execute {
-            val bookUrl = intent.getStringExtra("bookUrl") ?: return@execute
+            val bookUrl = intent.getStringExtra("bookUrl") ?: book?.bookUrl ?: return@execute
             val book = appDb.bookDao.getBook(bookUrl) ?: return@execute
             inBookshelf = intent.getBooleanExtra("inBookshelf", true)
             initBook(book)
@@ -71,7 +71,7 @@ class AudioPlayViewModel(application: Application) : BaseViewModel(application)
             if (oldBook.bookUrl == book.bookUrl) {
                 appDb.bookDao.update(book)
             } else {
-                appDb.bookDao.insert(book)
+                appDb.bookDao.replace(oldBook, book)
             }
             appDb.bookChapterDao.delByBook(book.bookUrl)
             appDb.bookChapterDao.insert(*cList.toTypedArray())

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/book/info/BookInfoActivity.kt

@@ -739,7 +739,7 @@ class BookInfoActivity :
             else -> readBookResult.launch(
                 Intent(
                     this,
-                    if (book.isImage && AppConfig.showMangaUi) ReadMangaActivity::class.java
+                    if (!book.isLocal && book.isImage && AppConfig.showMangaUi) ReadMangaActivity::class.java
                     else ReadBookActivity::class.java
                 )
                     .putExtra("bookUrl", book.bookUrl)

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/book/info/BookInfoViewModel.kt

@@ -235,7 +235,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
             WebBook.getChapterList(scope, bookSource, book, runPreUpdateJs)
                 .onSuccess(IO) {
                     if (inBookshelf) {
-                        book.save()
+                        appDb.bookDao.replace(oldBook, book)
                         /**
                          * runPreUpdateJs 有可能会修改 book 的 bookUrl
                          */

+ 8 - 0
app/src/main/java/io/legato/kazusa/ui/book/info/edit/BookInfoEditViewModel.kt

@@ -1,8 +1,10 @@
 package io.legato.kazusa.ui.book.info.edit
 
 import android.app.Application
+import android.database.sqlite.SQLiteConstraintException
 import androidx.lifecycle.MutableLiveData
 import io.legato.kazusa.base.BaseViewModel
+import io.legato.kazusa.constant.AppLog
 import io.legato.kazusa.data.appDb
 import io.legato.kazusa.data.entities.Book
 import io.legato.kazusa.model.ReadBook
@@ -28,6 +30,12 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
             appDb.bookDao.update(book)
         }.onSuccess {
             success?.invoke()
+        }.onError {
+            if (it is SQLiteConstraintException) {
+                AppLog.put("书籍信息保存失败,存在相同书名作者书籍\n$it", it, true)
+            } else {
+                AppLog.put("书籍信息保存失败\n$it", it, true)
+            }
         }
     }
 }

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/book/manga/ReadMangaViewModel.kt

@@ -119,7 +119,7 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
                 if (oldBook.bookUrl == book.bookUrl) {
                     appDb.bookDao.update(book)
                 } else {
-                    appDb.bookDao.insert(book)
+                    appDb.bookDao.replace(oldBook, book)
                     BookHelp.updateCacheFolder(oldBook, book)
                 }
                 appDb.bookChapterDao.delByBook(oldBook.bookUrl)

+ 4 - 3
app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookActivity.kt

@@ -5,7 +5,6 @@ import android.annotation.SuppressLint
 import android.content.Intent
 import android.content.res.Configuration
 import android.graphics.Color
-import android.net.Uri
 import android.os.Bundle
 import android.view.Gravity
 import android.view.HapticFeedbackConstants
@@ -19,6 +18,7 @@ import androidx.activity.addCallback
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AlertDialog
 import androidx.appcompat.widget.PopupMenu
+import androidx.core.net.toUri
 import androidx.core.view.doOnPreDraw
 import androidx.core.view.get
 import androidx.core.view.size
@@ -906,10 +906,11 @@ class ReadBookActivity : BaseReadBookActivity(),
                 ReadBook.bookSource?.bookSourceUrl?.let {
                     scopes.add(it)
                 }
+                val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n")
                 replaceActivity.launch(
                     ReplaceEditActivity.startIntent(
                         this,
-                        pattern = selectedText,
+                        pattern = text,
                         scope = scopes.joinToString(";")
                     )
                 )
@@ -1421,7 +1422,7 @@ class ReadBookActivity : BaseReadBookActivity(),
                             value = src
                         }
                     } else {
-                        viewModel.saveImage(src, Uri.parse(path))
+                        viewModel.saveImage(src, path.toUri())
                     }
                 }
 

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/book/read/ReadBookViewModel.kt

@@ -214,7 +214,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
                         if (oldBook.bookUrl == book.bookUrl) {
                             appDb.bookDao.update(book)
                         } else {
-                            appDb.bookDao.insert(book)
+                            appDb.bookDao.replace(oldBook, book)
                             BookHelp.updateCacheFolder(oldBook, book)
                         }
                         appDb.bookChapterDao.delByBook(oldBook.bookUrl)

+ 8 - 7
app/src/main/java/io/legato/kazusa/ui/book/read/config/BgTextConfigDialog.kt

@@ -1,16 +1,14 @@
 package io.legato.kazusa.ui.book.read.config
 
-//import io.legado.app.lib.theme.bottomBackground
-//import io.legado.app.lib.theme.getPrimaryTextColor
-//import io.legado.app.lib.theme.getSecondaryTextColor
 import android.annotation.SuppressLint
 import android.content.DialogInterface
-import android.graphics.Color
 import android.graphics.PorterDuff
 import android.net.Uri
 import android.os.Bundle
 import android.view.View
 import androidx.appcompat.widget.TooltipCompat
+import androidx.core.graphics.toColorInt
+import androidx.core.view.isGone
 import androidx.documentfile.provider.DocumentFile
 import com.google.android.material.slider.Slider
 import com.jaredrummler.android.colorpicker.ColorPickerDialog
@@ -22,12 +20,14 @@ import io.legato.kazusa.databinding.DialogEditTextBinding
 import io.legato.kazusa.databinding.DialogReadBgTextBinding
 import io.legato.kazusa.databinding.ItemBgImageBinding
 import io.legato.kazusa.help.DefaultData
+import io.legato.kazusa.help.book.isImage
 import io.legato.kazusa.help.config.ReadBookConfig
 import io.legato.kazusa.help.http.newCallResponseBody
 import io.legato.kazusa.help.http.okHttpClient
 import io.legato.kazusa.lib.dialogs.SelectItem
 import io.legato.kazusa.lib.dialogs.alert
 import io.legato.kazusa.lib.dialogs.selector
+import io.legato.kazusa.model.ReadBook
 import io.legato.kazusa.ui.book.read.ReadBookActivity
 import io.legato.kazusa.ui.file.HandleFileContract
 import io.legato.kazusa.utils.FileUtils
@@ -119,6 +119,7 @@ class BgTextConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_bg
                 tvName.text = getString(R.string.select_image)
                 ivBg.setImageResource(R.drawable.ic_image)
                 ivBg.setColorFilter(primaryTextColor, PorterDuff.Mode.SRC_IN)
+                swUnderline.isGone = ReadBook.book?.isImage == true
                 root.setOnClickListener {
                     selectBgImage.launch()
                 }
@@ -184,8 +185,8 @@ class BgTextConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_bg
         }
         binding.tvBgColor.setOnClickListener {
             val bgColor =
-                if (curBgType() == 0) Color.parseColor(curBgStr())
-                else Color.parseColor("#015A86")
+                if (curBgType() == 0) curBgStr().toColorInt()
+                else "#015A86".toColorInt()
             ColorPickerDialog.newBuilder()
                 .setColor(bgColor)
                 .setShowAlphaSlider(false)
@@ -355,7 +356,7 @@ class BgTextConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_bg
 
     private fun importConfig(byteArray: ByteArray) {
         execute {
-            ReadBookConfig.import(byteArray).getOrThrow()
+            ReadBookConfig.import(byteArray)
         }.onSuccess {
             ReadBookConfig.durConfig = it
             postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))

+ 2 - 1
app/src/main/java/io/legato/kazusa/ui/book/read/config/ClickActionConfigDialog.kt

@@ -36,7 +36,8 @@ class ClickActionConfigDialog : BaseDialogFragment(R.layout.dialog_click_action_
             Pair(9, getString(R.string.replace_state_change)),
             Pair(10, getString(R.string.chapter_list)),
             Pair(11, getString(R.string.search_content)),
-            Pair(12, getString(R.string.sync_book_progress_t))
+            Pair(12, getString(R.string.sync_book_progress_t)),
+            Pair(13, getString(R.string.read_aloud_pause_resume))
         )
     }
 

+ 4 - 0
app/src/main/java/io/legato/kazusa/ui/book/read/config/MoreConfigDialog.kt

@@ -130,6 +130,10 @@ class MoreConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_more_conf
                     ReadBook.callBack?.upPageAnim(true)
                     ReadBook.loadContent(false)
                 }
+
+                PreferKey.paddingDisplayCutouts -> {
+                    postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+                }
             }
         }
 

+ 45 - 14
app/src/main/java/io/legato/kazusa/ui/book/read/page/AutoPager.kt

@@ -14,12 +14,12 @@ import io.legato.kazusa.utils.themeColor
 /**
  * 自动翻页
  */
-class AutoPager(
-    private val readView: ReadView) {
+class AutoPager(private val readView: ReadView) : Runnable {
     private var progress = 0
     var isRunning = false
         private set
     private var isPausing = false
+    private var isEInkMode = false
     private var scrollOffsetRemain = 0.0
     private var scrollOffset = 0
     private var lastTimeMillis = 0L
@@ -29,10 +29,15 @@ class AutoPager(
 
     fun start() {
         isRunning = true
-        paint.color = readView.context.themeColor(androidx.appcompat.R.attr.colorPrimary)
-        lastTimeMillis = SystemClock.uptimeMillis()
+        isEInkMode = AppConfig.isEInkMode
         readView.curPage.upSelectAble(false)
-        readView.invalidate()
+        if (isEInkMode) {
+            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
+        } else {
+            paint.color = readView.context.themeColor(androidx.appcompat.R.attr.colorPrimary)
+            lastTimeMillis = SystemClock.uptimeMillis()
+            readView.invalidate()
+        }
     }
 
     fun stop() {
@@ -41,6 +46,8 @@ class AutoPager(
         }
         isRunning = false
         isPausing = false
+        isEInkMode = false
+        readView.removeCallbacks(this)
         readView.curPage.upSelectAble(AppConfig.textSelectAble)
         readView.invalidate()
         reset()
@@ -52,6 +59,7 @@ class AutoPager(
             return
         }
         isPausing = true
+        readView.removeCallbacks(this)
     }
 
     fun resume() {
@@ -59,15 +67,24 @@ class AutoPager(
             return
         }
         isPausing = false
-        lastTimeMillis = SystemClock.uptimeMillis()
-        readView.invalidate()
+        if (isEInkMode) {
+            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
+        } else {
+            lastTimeMillis = SystemClock.uptimeMillis()
+            readView.invalidate()
+        }
     }
 
     fun reset() {
-        progress = 0
-        scrollOffsetRemain = 0.0
-        scrollOffset = 0
-        canvasRecorder.invalidate()
+        if (isEInkMode) {
+            readView.removeCallbacks(this)
+            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
+        } else {
+            progress = 0
+            scrollOffsetRemain = 0.0
+            scrollOffset = 0
+            canvasRecorder.invalidate()
+        }
     }
 
     fun upRecorder() {
@@ -76,12 +93,15 @@ class AutoPager(
     }
 
     fun onDraw(canvas: Canvas) {
-        if (!isRunning) {
+        if (!isRunning || isEInkMode) {
             return
         }
 
         if (readView.isScroll) {
-            if (!isPausing) readView.curPage.scroll(-scrollOffset)
+            if (!isPausing) {
+                readView.curPage.scroll(-scrollOffset)
+                scrollOffset = 0
+            }
         } else {
             val bottom = progress
             val width = readView.width
@@ -104,7 +124,7 @@ class AutoPager(
     }
 
     fun computeOffset() {
-        if (!isRunning || isPausing) {
+        if (!isRunning || isPausing || isEInkMode) {
             return
         }
 
@@ -132,4 +152,15 @@ class AutoPager(
         }
     }
 
+    override fun run() {
+        if (!isRunning || isPausing) {
+            return
+        }
+        if (!readView.fillPage(PageDirection.NEXT)) {
+            stop()
+        } else {
+            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
+        }
+    }
+
 }

+ 34 - 4
app/src/main/java/io/legato/kazusa/ui/book/read/page/PageView.kt

@@ -2,15 +2,20 @@ package io.legato.kazusa.ui.book.read.page
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.graphics.drawable.LayerDrawable
 import android.view.LayoutInflater
 import android.widget.FrameLayout
 import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.toDrawable
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.isGone
 import androidx.core.view.isInvisible
 import io.legato.kazusa.R
 import io.legato.kazusa.constant.AppConst.timeFormat
 import io.legato.kazusa.data.entities.Bookmark
 import io.legato.kazusa.databinding.ViewBookPageBinding
+import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.help.config.ReadBookConfig
 import io.legato.kazusa.help.config.ReadTipConfig
 import io.legato.kazusa.model.ReadBook
@@ -25,6 +30,7 @@ import io.legato.kazusa.utils.applyNavigationBarPadding
 import io.legato.kazusa.utils.applyStatusBarPadding
 import io.legato.kazusa.utils.dpToPx
 import io.legato.kazusa.utils.gone
+import io.legato.kazusa.utils.setOnApplyWindowInsetsListenerCompat
 import io.legato.kazusa.utils.setTextIfNotEqual
 import io.legato.kazusa.utils.statusBarHeight
 import splitties.views.backgroundColor
@@ -56,7 +62,7 @@ class PageView(context: Context) : FrameLayout(context) {
         get() {
             val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
             val h2 = if (binding.llHeader.isGone) 0 else binding.llHeader.height
-            return h1 + h2
+            return h1 + h2 + binding.vwRoot.paddingTop
         }
 
     init {
@@ -94,6 +100,7 @@ class PageView(context: Context) : FrameLayout(context) {
             vwBottomDivider.backgroundColor = tipDividerColor
             upStatusBar()
             upNavigationBar()
+            upPaddingDisplayCutouts()
             llHeader.setPadding(
                 it.headerPaddingLeft.dpToPx(),
                 it.headerPaddingTop.dpToPx(),
@@ -125,6 +132,24 @@ class PageView(context: Context) : FrameLayout(context) {
         binding.vwNavigationBar.isGone = ReadBookConfig.hideNavigationBar
     }
 
+    fun upPaddingDisplayCutouts() {
+        if (AppConfig.paddingDisplayCutouts) {
+            binding.vwRoot.setOnApplyWindowInsetsListenerCompat { _, windowInsets ->
+                val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+                binding.vwRoot.setPadding(
+                    insets.left,
+                    if (binding.vwStatusBar.isGone) insets.top else 0,
+                    insets.right,
+                    insets.bottom
+                )
+                windowInsets
+            }
+        } else {
+            ViewCompat.setOnApplyWindowInsetsListener(binding.vwRoot, null)
+            binding.vwRoot.setPadding(0, 0, 0, 0)
+        }
+    }
+
     /**
      * 更新阅读信息
      */
@@ -239,8 +264,12 @@ class PageView(context: Context) : FrameLayout(context) {
      * 更新背景
      */
     fun upBg() {
-        binding.vwRoot.backgroundColor = ReadBookConfig.bgMeanColor
-        binding.vwBg.background = ReadBookConfig.bg
+        binding.vwRoot.background = LayerDrawable(
+            arrayOf(
+                ReadBookConfig.bgMeanColor.toDrawable(),
+                ReadBookConfig.bg
+            )
+        )
         upBgAlpha()
     }
 
@@ -248,7 +277,8 @@ class PageView(context: Context) : FrameLayout(context) {
      * 更新背景透明度
      */
     fun upBgAlpha() {
-        binding.vwBg.alpha = ReadBookConfig.bgAlpha / 100f
+        ReadBookConfig.bg?.alpha = (ReadBookConfig.bgAlpha / 100f * 255).toInt()
+        binding.vwRoot.invalidate()
     }
 
     /**

+ 8 - 0
app/src/main/java/io/legato/kazusa/ui/book/read/page/ReadView.kt

@@ -17,6 +17,7 @@ import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.help.config.ReadBookConfig
 import io.legato.kazusa.model.ReadAloud
 import io.legato.kazusa.model.ReadBook
+import io.legato.kazusa.service.BaseReadAloudService
 import io.legato.kazusa.ui.book.read.ContentEditDialog
 import io.legato.kazusa.ui.book.read.page.api.DataSource
 import io.legato.kazusa.ui.book.read.page.delegate.CoverPageDelegate
@@ -456,6 +457,13 @@ class ReadView(context: Context, attrs: AttributeSet) :
                 { progress -> callBack.sureNewProgress(progress) },
                 { context.longToastOnUi(context.getString(R.string.upload_book_success)) },
                 { context.longToastOnUi(context.getString(R.string.sync_book_progress_success)) })
+            13 -> {
+                if (BaseReadAloudService.isPlay()) {
+                    ReadAloud.pause(context)
+                } else {
+                    ReadAloud.resume(context)
+                }
+            }
         }
     }
 

+ 9 - 0
app/src/main/java/io/legato/kazusa/ui/book/read/page/entities/TextLine.kt

@@ -160,6 +160,15 @@ data class TextLine(
                 columns[i].draw(view, canvas)
             }
         }
+        // 墨水屏模式下的朗读和搜索下划线
+        if (AppConfig.isEInkMode && (isReadAloud || searchResultColumnCount > 0)) {
+            val underlinePaint = PaintPool.obtain()
+            underlinePaint.set(ChapterProvider.contentPaint)
+            underlinePaint.strokeWidth = 1.dpToPx().toFloat()
+            val lineY = height - 1.dpToPx()
+            canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, underlinePaint)
+            PaintPool.recycle(underlinePaint)
+        }
         if (ReadBookConfig.underline && !isImage && ReadBook.book?.isImage != true) {
             drawUnderline(canvas)
         }

+ 34 - 14
app/src/main/java/io/legato/kazusa/ui/book/read/page/provider/ChapterProvider.kt

@@ -34,6 +34,7 @@ import io.legato.kazusa.utils.postEvent
 import io.legato.kazusa.utils.spToPx
 import io.legato.kazusa.utils.splitNotBlank
 import io.legato.kazusa.utils.textHeight
+import io.legato.kazusa.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
 import splitties.init.appCtx
 import java.util.LinkedList
@@ -1001,20 +1002,8 @@ object ChapterProvider {
             }
         }
 
-        if (viewWidth > 0 && viewHeight > 0) {
-            paddingLeft = ReadBookConfig.paddingLeft.dpToPx()
-            paddingTop = ReadBookConfig.paddingTop.dpToPx()
-            paddingRight = ReadBookConfig.paddingRight.dpToPx()
-            paddingBottom = ReadBookConfig.paddingBottom.dpToPx()
-            visibleWidth = if (doublePage) {
-                viewWidth / 2 - paddingLeft - paddingRight
-            } else {
-                viewWidth - paddingLeft - paddingRight
-            }
-            //留1dp画最后一行下划线
-            visibleHeight = viewHeight - paddingTop - paddingBottom
-            visibleRight = viewWidth - paddingRight
-            visibleBottom = paddingTop + visibleHeight
+        if (viewWidth <= 0 || viewHeight <= 0) {
+            return
         }
 
         visibleRect.set(
@@ -1024,6 +1013,37 @@ object ChapterProvider {
             visibleBottom.toFloat()
         )
 
+        paddingLeft = ReadBookConfig.paddingLeft.dpToPx()
+        paddingTop = ReadBookConfig.paddingTop.dpToPx()
+        paddingRight = ReadBookConfig.paddingRight.dpToPx()
+        paddingBottom = ReadBookConfig.paddingBottom.dpToPx()
+        visibleWidth = if (doublePage) {
+            viewWidth / 2 - paddingLeft - paddingRight
+        } else {
+            viewWidth - paddingLeft - paddingRight
+        }
+        //留1dp画最后一行下划线
+        visibleHeight = viewHeight - paddingTop - paddingBottom
+        visibleRight = viewWidth - paddingRight
+        visibleBottom = paddingTop + visibleHeight
+
+        if (paddingLeft >= visibleRight || paddingTop >= visibleBottom) {
+            appCtx.toastOnUi("边距设置过大,请重新设置")
+            visibleRect.set(
+                0f,
+                0f,
+                viewWidth.toFloat(),
+                viewHeight.toFloat()
+            )
+        } else {
+            visibleRect.set(
+                paddingLeft.toFloat(),
+                paddingTop.toFloat(),
+                visibleRight.toFloat(),
+                visibleBottom.toFloat()
+            )
+        }
+
     }
 
 }

+ 10 - 11
app/src/main/java/io/legato/kazusa/ui/book/read/page/provider/TextChapterLayout.kt

@@ -20,7 +20,6 @@ import io.legato.kazusa.ui.book.read.page.entities.TextChapter
 import io.legato.kazusa.ui.book.read.page.entities.TextLine
 import io.legato.kazusa.ui.book.read.page.entities.TextPage
 import io.legato.kazusa.ui.book.read.page.entities.column.ImageColumn
-import io.legato.kazusa.ui.book.read.page.entities.column.ReviewColumn
 import io.legato.kazusa.ui.book.read.page.entities.column.TextColumn
 import io.legato.kazusa.utils.dpToPx
 import io.legato.kazusa.utils.fastSum
@@ -214,13 +213,13 @@ class TextChapterLayout(
                     emptyContent = contents.isEmpty(),
                     isVolumeTitle = bookChapter.isVolume
                 )
+                pendingTextPage.lines.last().isParagraphEnd = true
+                stringBuilder.append("\n")
             }
-            pendingTextPage.lines.last().isParagraphEnd = true
-            stringBuilder.append("\n")
             durY += titleBottomSpacing
 
             // 如果是单图模式且当前页有内容,强制分页
-            if (isSingleImageStyle && pendingTextPage.lines.isNotEmpty()) {
+            if (isSingleImageStyle && pendingTextPage.lines.isNotEmpty() && contents.isNotEmpty()) {
                 prepareNextPageIfNeed()
             }
         }
@@ -708,13 +707,13 @@ class TextChapterLayout(
                 )
             }
 
-            isLineEnd && char == ChapterProvider.reviewChar -> {
-                ReviewColumn(
-                    start = absStartX + xStart,
-                    end = absStartX + xEnd,
-                    count = 100
-                )
-            }
+//            isLineEnd && char == ChapterProvider.reviewChar -> {
+//                ReviewColumn(
+//                    start = absStartX + xStart,
+//                    end = absStartX + xEnd,
+//                    count = 100
+//                )
+//            }
 
             else -> {
                 TextColumn(

+ 26 - 16
app/src/main/java/io/legato/kazusa/ui/book/search/SearchActivity.kt

@@ -12,12 +12,13 @@ import androidx.activity.viewModels
 import androidx.core.os.bundleOf
 import androidx.core.view.isVisible
 import androidx.core.widget.doAfterTextChanged
+import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.recyclerview.widget.DefaultItemAnimator
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.flexbox.FlexboxLayoutManager
-import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.search.SearchBar
 import com.google.android.material.search.SearchView
 import io.legato.kazusa.R
@@ -45,6 +46,7 @@ import io.legato.kazusa.utils.viewbindingdelegate.viewBinding
 import io.legato.kazusa.utils.visible
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.conflate
 import kotlinx.coroutines.flow.flowOn
@@ -183,21 +185,19 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
 
         binding.appBar.post {
             val maxPaddingTop = binding.appBar.paddingTop
-            binding.appBar.addOnOffsetChangedListener(
-                AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset ->
-                    val totalScrollRange = appBarLayout.totalScrollRange
-                    val scrollRatio =
-                        (1f - (abs(verticalOffset).toFloat() / totalScrollRange)).coerceIn(0f, 1f)
-
-                    appBarLayout.alpha = scrollRatio
-                    appBarLayout.setPadding(
-                        appBarLayout.paddingLeft,
-                        (maxPaddingTop * scrollRatio).toInt(),
-                        appBarLayout.paddingRight,
-                        appBarLayout.paddingBottom
-                    )
-                }
-            )
+            binding.appBar.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
+                val totalScrollRange = appBarLayout.totalScrollRange
+                val scrollRatio =
+                    (1f - (abs(verticalOffset).toFloat() / totalScrollRange)).coerceIn(0f, 1f)
+
+                appBarLayout.alpha = scrollRatio
+                appBarLayout.setPadding(
+                    appBarLayout.paddingLeft,
+                    (maxPaddingTop * scrollRatio).toInt(),
+                    appBarLayout.paddingRight,
+                    appBarLayout.paddingBottom
+                )
+            }
         }
 
         searchBar.setOnMenuItemClickListener { item ->
@@ -343,6 +343,16 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
                 groups = it
             }
         }
+        lifecycleScope.launch {
+            repeatOnLifecycle(Lifecycle.State.RESUMED) {
+                viewModel.resume()
+                try {
+                    awaitCancellation()
+                } finally {
+                    viewModel.pause()
+                }
+            }
+        }
     }
 
     /**

+ 8 - 0
app/src/main/java/io/legato/kazusa/ui/book/search/SearchViewModel.kt

@@ -123,6 +123,14 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
         searchModel.cancelSearch()
     }
 
+    fun pause() {
+        searchModel.pause()
+    }
+
+    fun resume() {
+        searchModel.resume()
+    }
+
     /**
      * 保存搜索关键字
      */

+ 26 - 10
app/src/main/java/io/legato/kazusa/ui/book/searchContent/SearchResult.kt

@@ -2,6 +2,7 @@ package io.legato.kazusa.ui.book.searchContent
 
 import android.text.Spanned
 import androidx.core.text.HtmlCompat
+import io.legato.kazusa.help.config.AppConfig
 
 data class SearchResult(
     val resultCount: Int = 0,
@@ -22,19 +23,34 @@ data class SearchResult(
             val leftString = resultText.substring(0, queryIndexInSurrounding)
             val rightString =
                 resultText.substring(queryIndexInSurrounding + query.length, resultText.length)
-            val html = buildString {
-                append(chapterTitle.colorTextForHtml(accentColor))
-                append("<br>")
-                append(leftString.colorTextForHtml(textColor))
-                append(query.colorTextForHtml(accentColor))
-                append(rightString.colorTextForHtml(textColor))
+            // 检查是否为墨水屏模式
+            val html = if (AppConfig.isEInkMode) {
+                // 墨水屏模式:使用下划线
+                buildString {
+                    append("<u>${chapterTitle}</u>")
+                    append("<br>")
+                    append(leftString)
+                    append("<u>${query}</u>")
+                    append(rightString)
+                }
+            } else {
+                // 普通模式:使用颜色
+                buildString {
+                    append(chapterTitle.colorTextForHtml(accentColor))
+                    append("<br>")
+                    append(leftString.colorTextForHtml(textColor))
+                    append(query.colorTextForHtml(accentColor))
+                    append(rightString.colorTextForHtml(textColor))
+                }
             }
             HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
         } else {
-            HtmlCompat.fromHtml(
-                resultText.colorTextForHtml(textColor),
-                HtmlCompat.FROM_HTML_MODE_LEGACY
-            )
+            val html = if (AppConfig.isEInkMode) {
+                resultText
+            } else {
+                resultText.colorTextForHtml(textColor)
+            }
+            HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
         }
     }
 

+ 11 - 0
app/src/main/java/io/legato/kazusa/ui/dict/DictDialog.kt

@@ -65,8 +65,19 @@ class DictDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_dict) {
                     tag = it
                 })
             }
+            setupTabLayoutMode(it.size)
         }
+    }
 
+    //根据已启用词典数动态选取布局
+    private fun setupTabLayoutMode(dictCount: Int) {
+        if (dictCount <= 4) {
+            binding.tabLayout.tabMode = TabLayout.MODE_FIXED
+            binding.tabLayout.tabGravity = TabLayout.GRAVITY_FILL
+        } else {
+            binding.tabLayout.tabMode = TabLayout.MODE_SCROLLABLE
+            binding.tabLayout.tabGravity = TabLayout.GRAVITY_CENTER
+        }
     }
 
 }

+ 4 - 2
app/src/main/java/io/legato/kazusa/ui/login/SourceLoginActivity.kt

@@ -17,9 +17,11 @@ class SourceLoginActivity : VMBaseActivity<ActivitySourceLoginBinding, SourceLog
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        viewModel.initData(intent) { source ->
+        viewModel.initData(intent, success = { source ->
             initView(source)
-        }
+        }, error = {
+            finish()
+        })
     }
 
     private fun initView(source: BaseSource) {

+ 6 - 2
app/src/main/java/io/legato/kazusa/ui/login/SourceLoginViewModel.kt

@@ -2,11 +2,12 @@ package io.legato.kazusa.ui.login
 
 import android.app.Application
 import android.content.Intent
+import com.script.rhino.runScriptWithContext
 import io.legato.kazusa.base.BaseViewModel
+import io.legato.kazusa.constant.AppLog
 import io.legato.kazusa.data.appDb
 import io.legato.kazusa.data.entities.BaseSource
 import io.legato.kazusa.exception.NoStackTraceException
-import com.script.rhino.runScriptWithContext
 import io.legato.kazusa.utils.toastOnUi
 
 class SourceLoginViewModel(application: Application) : BaseViewModel(application) {
@@ -14,7 +15,7 @@ class SourceLoginViewModel(application: Application) : BaseViewModel(application
     var source: BaseSource? = null
     var headerMap: Map<String, String> = emptyMap()
 
-    fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit) {
+    fun initData(intent: Intent, success: (bookSource: BaseSource) -> Unit, error: () -> Unit) {
         execute {
             val sourceKey = intent.getStringExtra("key")
                 ?: throw NoStackTraceException("没有参数")
@@ -33,6 +34,9 @@ class SourceLoginViewModel(application: Application) : BaseViewModel(application
             } else {
                 context.toastOnUi("未找到书源")
             }
+        }.onError {
+            error.invoke()
+            AppLog.put("登录 UI 初始化失败\n$it", it, true)
         }
     }
 

+ 1 - 1
app/src/main/java/io/legato/kazusa/ui/main/MainViewModel.kt

@@ -167,7 +167,7 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
             if (book.bookUrl == bookUrl) {
                 appDb.bookDao.update(book)
             } else {
-                appDb.bookDao.insert(book)
+                appDb.bookDao.replace(oldBook, book)
                 BookHelp.updateCacheFolder(oldBook, book)
             }
             appDb.bookChapterDao.delByBook(bookUrl)

+ 2 - 5
app/src/main/java/io/legato/kazusa/ui/replace/ReplaceRuleActivity.kt

@@ -1,7 +1,6 @@
 package io.legato.kazusa.ui.replace
 
 import android.annotation.SuppressLint
-import android.app.Activity
 import android.os.Bundle
 import android.view.Menu
 import android.view.MenuItem
@@ -25,8 +24,6 @@ import io.legato.kazusa.help.DirectLinkUpload
 import io.legato.kazusa.help.book.ContentProcessor
 import io.legato.kazusa.help.coroutine.Coroutine
 import io.legato.kazusa.lib.dialogs.alert
-//import io.legado.app.lib.theme.primaryColor
-//import io.legado.app.lib.theme.primaryTextColor
 import io.legato.kazusa.ui.association.ImportReplaceRuleDialog
 import io.legato.kazusa.ui.file.HandleFileContract
 import io.legato.kazusa.ui.qrcode.QrCodeResult
@@ -72,7 +69,7 @@ class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRu
     private val searchView: SearchView by lazy {
         binding.titleBar.findViewById(R.id.search_view)
     }
-    private var groups = hashSetOf<String>()
+    private var groups = arrayListOf<String>()
     private var groupMenu: SubMenu? = null
     private var replaceRuleFlowJob: Job? = null
     private var dataInit = false
@@ -224,7 +221,7 @@ class ReplaceRuleActivity : VMBaseActivity<ActivityReplaceRuleBinding, ReplaceRu
                 AppLog.put("替换规则管理界面更新数据出错", it)
             }.flowOn(IO).conflate().collect {
                 if (dataInit) {
-                    setResult(Activity.RESULT_OK)
+                    setResult(RESULT_OK)
                 }
                 adapter.setItems(it, adapter.diffItemCallBack)
                 dataInit = true

+ 1 - 3
app/src/main/java/io/legato/kazusa/ui/rss/source/manage/RssSourceActivity.kt

@@ -20,8 +20,6 @@ import io.legato.kazusa.databinding.ActivityRssSourceBinding
 import io.legato.kazusa.databinding.DialogEditTextBinding
 import io.legato.kazusa.help.DirectLinkUpload
 import io.legato.kazusa.lib.dialogs.alert
-//import io.legado.app.lib.theme.primaryColor
-//import io.legado.app.lib.theme.primaryTextColor
 import io.legato.kazusa.ui.association.ImportRssSourceDialog
 import io.legato.kazusa.ui.file.HandleFileContract
 import io.legato.kazusa.ui.qrcode.QrCodeResult
@@ -70,7 +68,7 @@ class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceView
         binding.titleBar.findViewById(R.id.search_view)
     }
     private var sourceFlowJob: Job? = null
-    private var groups = hashSetOf<String>()
+    private var groups = arrayListOf<String>()
     private var groupMenu: SubMenu? = null
     private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
         it ?: return@registerForActivityResult

+ 4 - 2
app/src/main/java/io/legato/kazusa/utils/ContextExtensions.kt

@@ -35,6 +35,7 @@ import androidx.annotation.DrawableRes
 import androidx.core.content.ContextCompat
 import androidx.core.content.FileProvider
 import androidx.core.content.edit
+import androidx.core.net.toUri
 import androidx.datastore.preferences.preferencesDataStore
 import androidx.preference.PreferenceManager
 import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
@@ -44,6 +45,7 @@ import io.legato.kazusa.data.entities.Book
 import io.legato.kazusa.help.IntentHelp
 import io.legato.kazusa.help.book.isAudio
 import io.legato.kazusa.help.book.isImage
+import io.legato.kazusa.help.book.isLocal
 import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.ui.book.audio.AudioPlayActivity
 import io.legato.kazusa.ui.book.manga.ReadMangaActivity
@@ -68,7 +70,7 @@ fun Context.startActivityForBook(
 ) {
     val cls = when {
         book.isAudio -> AudioPlayActivity::class.java
-        book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
+        !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
         else -> ReadBookActivity::class.java
     }
     val intent = Intent(this, cls)
@@ -323,7 +325,7 @@ fun Context.getClipText(): String? {
 fun Context.sendMail(mail: String) {
     try {
         val intent = Intent(Intent.ACTION_SENDTO)
-        intent.data = Uri.parse("mailto:$mail")
+        intent.data = "mailto:$mail".toUri()
         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
         startActivity(intent)
     } catch (e: Exception) {

+ 2 - 1
app/src/main/java/io/legato/kazusa/utils/FragmentExtensions.kt

@@ -17,6 +17,7 @@ import io.legato.kazusa.R
 import io.legato.kazusa.data.entities.Book
 import io.legato.kazusa.help.book.isAudio
 import io.legato.kazusa.help.book.isImage
+import io.legato.kazusa.help.book.isLocal
 import io.legato.kazusa.help.config.AppConfig
 import io.legato.kazusa.ui.book.audio.AudioPlayActivity
 import io.legato.kazusa.ui.book.manga.ReadMangaActivity
@@ -93,7 +94,7 @@ fun Fragment.startActivityForBook(
 ) {
     val cls = when {
         book.isAudio -> AudioPlayActivity::class.java
-        book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
+        !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
         else -> ReadBookActivity::class.java
     }
     val intent = Intent(requireActivity(), cls)

+ 1 - 1
app/src/main/java/io/legato/kazusa/utils/HtmlFormatter.kt

@@ -14,7 +14,7 @@ object HtmlFormatter {
     private val notImgHtmlRegex = "</?(?!img)[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val otherHtmlRegex = "</?[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val formatImagePattern = Pattern.compile(
-        "<img[^>]*\\ssrc\\s*=\\s*\"([^\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})\"[^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*\"([^\">]*)\"[^>]*>|<img[^>]*\\ssrc\\s*=\\s*\"([^\">]*)\"[^>]*>",
+        "<img[^>]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|<img[^>]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>",
         Pattern.CASE_INSENSITIVE
     )
     private val indent1Regex = "\\s*\\n+\\s*".toRegex()

+ 0 - 7
app/src/main/java/io/legato/kazusa/utils/RomExtensions.kt

@@ -1,7 +0,0 @@
-package io.legato.kazusa.utils
-
-import android.os.Build
-
-val isVivoDevice by lazy {
-    Build.MANUFACTURER.equals("vivo", ignoreCase = true)
-}

+ 11 - 0
app/src/main/res/drawable/ic_play_mode_list_end_stop.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M460,600L460,420L400,420L400,360L520,360L520,600L460,600ZM280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
+</vector>

+ 11 - 0
app/src/main/res/drawable/ic_play_mode_list_loop.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
+</vector>

+ 11 - 0
app/src/main/res/drawable/ic_play_mode_random.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z" />
+</vector>

+ 14 - 0
app/src/main/res/layout/activity_audio_play.xml

@@ -125,6 +125,20 @@
         android:paddingHorizontal="6dp"
         app:layout_constraintBottom_toBottomOf="parent">
 
+        <io.legato.kazusa.ui.widget.image.ImageButton
+            android:id="@+id/iv_play_mode"
+            android:layout_width="46dp"
+            android:layout_height="46dp"
+            android:layout_marginStart="16dp"
+            android:background="@drawable/selector_circle_btn_bg"
+            android:contentDescription="@string/play_mode"
+            android:padding="5dp"
+            android:src="@drawable/ic_play_mode_list_end_stop"
+            app:layout_constraintBottom_toTopOf="@+id/tv_sub_title"
+            app:layout_constraintStart_toStartOf="parent"
+            app:tint="@color/md_white_1000"
+            tools:ignore="ImageContrastCheck" />
+
         <io.legato.kazusa.ui.widget.image.ImageButton
             android:id="@+id/iv_timer"
             android:layout_width="46dp"

+ 1 - 1
app/src/main/res/layout/dialog_auto_read.xml

@@ -42,7 +42,7 @@
         android:layout_height="wrap_content"
         android:stepSize="1.0"
         android:valueFrom="1.0"
-        android:valueTo="60.0"
+        android:valueTo="120.0"
         tools:ignore="UnusedAttribute" />
 
     <LinearLayout

+ 0 - 5
app/src/main/res/layout/view_book_page.xml

@@ -7,11 +7,6 @@
     android:layout_height="match_parent"
     android:orientation="vertical">
 
-    <View
-        android:id="@+id/vw_bg"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent" />
-
     <FrameLayout
         android:id="@+id/vw_status_bar"
         android:layout_width="match_parent"

+ 1 - 1
app/src/main/res/values-es-rES/strings.xml

@@ -886,7 +886,7 @@
     <string name="pref_cronet_summary">使用Cronet网络组件</string>
     <string name="anti_alias">Anti-Aliasing</string>
     <string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
-    <string name="upload_url">上传URL</string>
+    <string name="upload_url">创建分享链接</string>
     <string name="download_url_rule">downloadUrlRule(downloadUrls)</string>
     <string name="sort_by_respondTime">Ordenar por tiempo de respuesta</string>
     <string name="respondTime">tiempo de respuesta: %1$d ms</string>

+ 1 - 1
app/src/main/res/values-ja-rJP/strings.xml

@@ -889,7 +889,7 @@
     <string name="pref_cronet_summary">使用Cronet网络组件</string>
     <string name="anti_alias">Anti-Aliasing</string>
     <string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
-    <string name="upload_url">上传URL</string>
+    <string name="upload_url">创建分享链接</string>
     <string name="download_url_rule">downloadUrlRule(downloadUrls)</string>
     <string name="sort_by_respondTime">Sort by respond time</string>
     <string name="respondTime">respondTime: %1$d ms</string>

+ 1 - 1
app/src/main/res/values-pt-rBR/strings.xml

@@ -887,7 +887,7 @@
     <string name="pref_cronet_summary">Usando componentes de rede Cronet</string>
     <string name="anti_alias">Anti-Aliasing</string>
     <string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
-    <string name="upload_url">上传URL</string>
+    <string name="upload_url">创建分享链接</string>
     <string name="download_url_rule">downloadUrlRule(downloadUrls)</string>
     <string name="sort_by_respondTime">Classificar por tempo de resposta</string>
     <string name="respondTime">tempo de resposta: %1$d ms</string>

+ 1 - 1
app/src/main/res/values-vi/arrays.xml

@@ -12,7 +12,7 @@
 		<item>@string/screen_portrait</item>
 		<item>@string/screen_landscape</item>
 		<item>@string/screen_sensor</item>
-		<item>Đảo ngược màn hình dọc</item>
+        <item>@string/screen_portrait_reversed</item>
 	</string-array>
     <string-array name="group_style">
         <item>Nhãn</item>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1210,4 +1210,5 @@
     <string name="manga_color_filter">滤镜</string>
     <string name="hide_manga_title">隐藏漫画列表标题</string>
     <string name="refresh_explore">刷新发现</string>
+    <string name="padding_display_cutouts">填充刘海区域</string>
 </resources>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1212,4 +1212,5 @@
     <string name="manga_color_filter">滤镜</string>
     <string name="hide_manga_title">隐藏漫画列表标题</string>
     <string name="refresh_explore">刷新发现</string>
+    <string name="padding_display_cutouts">填充刘海区域</string>
 </resources>

+ 5 - 2
app/src/main/res/values-zh/strings.xml

@@ -1234,9 +1234,12 @@
     <string name="list">列表</string>
     <string name="grid">网格</string>
     <string name="descending_order">降序</string>
-    <string name="checking_update">检查更新中...</string>
+    <string name="checking_update">检查更新中…</string>
     <string name="search_empty">没有内容哦</string>
     <string name="firebase_enable_title">匿名统计</string>
     <string name="locate_current">定位至当前</string>
-
+    <string name="padding_display_cutouts">填充刘海区域</string>
+    <string name="system_media_control_compatibility_change">系统媒体控件兼容性更改</string>
+    <string name="system_media_control_compatibility_change_summary">当锁屏不显示系统媒体控件时可以尝试开启,比如 OneUI 7.0或 OriginOS 等</string>
+    <string name="read_aloud_pause_resume">朗读暂停/继续</string>
 </resources>

+ 5 - 1
app/src/main/res/values/strings.xml

@@ -1242,5 +1242,9 @@
     <string name="firebase_enable_title">匿名统计</string>
     <string name="firebase_enable_summary">允许应用使用Google Firebase进行崩溃和性能统计</string>
     <string name="locate_current">定位至当前</string>
-
+    <string name="padding_display_cutouts">填充刘海区域</string>
+    <string name="system_media_control_compatibility_change">系统媒体控件兼容性更改</string>
+    <string name="system_media_control_compatibility_change_summary">当锁屏不显示系统媒体控件时可以尝试开启,比如 OneUI 7.0或 OriginOS 等</string>
+    <string name="read_aloud_pause_resume">朗读暂停/继续</string>
+    <string name="play_mode">播放模式</string>
 </resources>

+ 7 - 0
app/src/main/res/xml/pref_config_aloud.xml

@@ -41,6 +41,13 @@
             android:key="readAloudByPage"
             app:iconSpaceReserved="false" />
 
+        <io.legato.kazusa.lib.prefs.SwitchPreference
+            android:defaultValue="false"
+            android:key="systemMediaControlCompatibilityChange"
+            android:summary="@string/system_media_control_compatibility_change_summary"
+            android:title="@string/system_media_control_compatibility_change"
+            app:iconSpaceReserved="false" />
+
         <io.legato.kazusa.lib.prefs.SwitchPreference
             android:defaultValue="false"
             android:title="@string/stream_read_aloud_audio"

+ 9 - 2
app/src/main/res/xml/pref_config_read.xml

@@ -35,6 +35,13 @@
         app:iconSpaceReserved="false"
         app:isBottomBackground="true" />
 
+    <io.legato.kazusa.lib.prefs.SwitchPreference
+        android:defaultValue="false"
+        android:key="hideNavigationBar"
+        android:title="@string/pt_hide_navigation_bar"
+        app:iconSpaceReserved="false"
+        app:isBottomBackground="true" />
+
     <io.legato.kazusa.lib.prefs.SwitchPreference
         android:defaultValue="true"
         android:key="readBodyToLh"
@@ -44,8 +51,8 @@
 
     <io.legato.kazusa.lib.prefs.SwitchPreference
         android:defaultValue="false"
-        android:key="hideNavigationBar"
-        android:title="@string/pt_hide_navigation_bar"
+        android:key="paddingDisplayCutouts"
+        android:title="@string/padding_display_cutouts"
         app:iconSpaceReserved="false"
         app:isBottomBackground="true" />
 

+ 1 - 1
gradle/libs.versions.toml

@@ -32,7 +32,7 @@ material = "1.14.0-alpha04"
 media = "1.7.1"
 media3 = "1.8.0"
 nanoHttpd = "2.3.1"
-okhttp = "4.12.0"
+okhttp = "5.1.0"
 palette = "1.0.0"
 preference = "1.2.1"
 protobufJavalite = "4.26.1"