Просмотр исходного кода

Merge branch 'gedoor:master' into master

Xwite 4 лет назад
Родитель
Сommit
4659e7522a
43 измененных файлов с 622 добавлено и 370 удалено
  1. 1 0
      app/src/main/AndroidManifest.xml
  2. 1 1
      app/src/main/assets/defaultData/directLinkUpload.json
  3. 10 2
      app/src/main/assets/updateLog.md
  4. 1 0
      app/src/main/java/io/legado/app/constant/AppConst.kt
  5. 7 1
      app/src/main/java/io/legado/app/constant/BookType.kt
  6. 6 1
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  7. 3 0
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  8. 1 0
      app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt
  9. 8 0
      app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt
  10. 1 1
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  11. 32 21
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  12. 1 1
      app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt
  13. 10 0
      app/src/main/java/io/legado/app/help/source/BookSourceExtensions.kt
  14. 3 3
      app/src/main/java/io/legado/app/help/storage/ImportOldData.kt
  15. 2 2
      app/src/main/java/io/legado/app/lib/webdav/WebDav.kt
  16. 8 0
      app/src/main/java/io/legado/app/service/AudioPlayService.kt
  17. 8 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  18. 9 0
      app/src/main/java/io/legado/app/service/WebService.kt
  19. 4 15
      app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
  20. 36 31
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt
  21. 4 4
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  22. 2 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  23. 1 0
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt
  24. 3 0
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt
  25. 3 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  26. 11 0
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  27. 20 5
      app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
  28. 4 5
      app/src/main/java/io/legado/app/ui/book/search/SearchScopeDialog.kt
  29. 1 1
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt
  30. 13 21
      app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt
  31. 6 0
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  32. 1 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt
  33. 1 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt
  34. 8 1
      app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt
  35. 0 225
      app/src/main/java/io/legado/app/utils/DocumentExtensions.kt
  36. 47 0
      app/src/main/java/io/legado/app/utils/DocumentUtils.kt
  37. 255 0
      app/src/main/java/io/legado/app/utils/FileDocExtensions.kt
  38. 21 0
      app/src/main/java/io/legado/app/utils/FileExtensions.kt
  39. 1 2
      app/src/main/java/io/legado/app/utils/FileUtils.kt
  40. 48 19
      app/src/main/java/io/legado/app/utils/RegexExtensions.kt
  41. 1 3
      app/src/main/java/io/legado/app/utils/StringExtensions.kt
  42. 18 4
      app/src/main/java/io/legado/app/utils/UriExtensions.kt
  43. 1 1
      gradle.properties

+ 1 - 0
app/src/main/AndroidManifest.xml

@@ -4,6 +4,7 @@
     package="io.legado.app">
 
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

+ 1 - 1
app/src/main/assets/defaultData/directLinkUpload.json

@@ -1,5 +1,5 @@
 {
-  "uploadUrl": "http://sy.mgz6.cc/shuyuan,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}",
+  "uploadUrl": "https://sy.mgz6.cc/shuyuan,{\"method\":\"POST\",\"body\": {\"file\": \"fileRequest\"},\"type\": \"multipart/form-data\"}",
   "downloadUrlRule": "$.data@js:if (result == '') \n '' \n else \n 'https://shuyuan.mgz6.cc/shuyuan/' + result",
   "summary": "有效期2天"
 }

+ 10 - 2
app/src/main/assets/updateLog.md

@@ -11,13 +11,21 @@
 * 正文出现缺字漏字、内容缺失、排版错乱等情况,有可能是净化规则或简繁转换出现问题。
 * 漫画源看书显示乱码,**阅读与其他软件的源并不通用**,请导入阅读的支持的漫画源!
 
-**2022/10/13**
+**2022/10/16**
+
+* 添加更新失败分组
+* web服务和朗读服务添加唤醒锁
+* 修复搜索禁用书源的bug
+* 修复本地书籍很慢的问题
+
+**2022/10/14**
 
 * 更新cronet: 106.0.5249.126
 * 替换规则支持js,可以用js判断匹配到的内容决定替换为什么,匹配到的内容变量为result,替换为@js:开头则自动采用js判断替换内容
 * 书架管理添加筛选功能
 * 搜索支持搜索范围多分组和单书源设置
-* 书源管理界面书源菜单添加单书源搜索数据功能
+* 书源管理界面书源菜单添加单书源搜索书籍功能
+* 复web阅读时app未退出阅读界面导致的进度bug by Xwite
 
 **2022/10/09**
 

+ 1 - 0
app/src/main/java/io/legado/app/constant/AppConst.kt

@@ -43,6 +43,7 @@ object AppConst {
     const val bookGroupAudioId = -3L
     const val bookGroupNetNoneId = -4L
     const val bookGroupLocalNoneId = -5L
+    const val bookGroupErrorId = -11L
 
     const val notificationIdRead = -1122391
     const val notificationIdAudio = -1122392

+ 7 - 1
app/src/main/java/io/legado/app/constant/BookType.kt

@@ -12,6 +12,11 @@ object BookType {
      */
     const val text = 0b1000
 
+    /**
+     * 16 更新失败
+     */
+    const val updateError = 0b10000
+
     /**
      * 32 音频
      */
@@ -35,9 +40,10 @@ object BookType {
 
     @Target(AnnotationTarget.VALUE_PARAMETER)
     @Retention(AnnotationRetention.SOURCE)
-    @IntDef(text, audio, image, webFile)
+    @IntDef(text, updateError, audio, image, webFile, local)
     annotation class Type
 
+
     /**
      * 本地书籍书源标志
      */

+ 6 - 1
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -105,9 +105,14 @@ abstract class AppDatabase : RoomDatabase() {
                 )
                 db.execSQL(
                     """insert into book_groups(groupId, groupName, 'order', show) 
-                    select ${AppConst.bookGroupLocalNoneId}, '本地未分组', -8, 0
+                    select ${AppConst.bookGroupLocalNoneId}, '本地未分组', -6, 0
                     where not exists (select * from book_groups where groupId = ${AppConst.bookGroupLocalNoneId})"""
                 )
+                db.execSQL(
+                    """insert into book_groups(groupId, groupName, 'order', show) 
+                    select ${AppConst.bookGroupErrorId}, '更新失败', -1, 1
+                    where not exists (select * from book_groups where groupId = ${AppConst.bookGroupErrorId})"""
+                )
                 db.execSQL("update book_sources set loginUi = null where loginUi = 'null'")
                 db.execSQL("update rssSources set loginUi = null where loginUi = 'null'")
                 db.execSQL("update httpTTS set loginUi = null where loginUi = 'null'")

+ 3 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -53,6 +53,9 @@ interface BookDao {
     @Query("SELECT * FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%'")
     fun flowSearch(key: String): Flow<List<Book>>
 
+    @Query("SELECT * FROM books where type & ${BookType.updateError} > 0 order by durChapterTime desc")
+    fun flowUpdateError(): Flow<List<Book>>
+
     @Query("SELECT * FROM books WHERE (`group` & :group) > 0")
     fun getBooksByGroup(group: Long): List<Book>
 

+ 1 - 0
app/src/main/java/io/legado/app/data/dao/BookGroupDao.kt

@@ -25,6 +25,7 @@ interface BookGroupDao {
         or (groupId = -1 and show > 0)
         or (groupId = -2 and show > 0 and (select count(*) from books where type & ${BookType.local} > 0) > 0)
         or (groupId = -3 and show > 0 and (select count(*) from books where type & ${BookType.audio} > 0) > 0)
+        or (groupId = -11 and show > 0 and (select count(*) from books where type & ${BookType.updateError} > 0) > 0)
         or (groupId = -4 and show > 0 
             and (
                 select count(*) from books 

+ 8 - 0
app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt

@@ -134,6 +134,9 @@ interface BookSourceDao {
     @get:Query("select distinct bookSourceGroup from book_sources where trim(bookSourceGroup) <> ''")
     val allGroupsUnProcessed: List<String>
 
+    @get:Query("select distinct bookSourceGroup from book_sources where enabled = 1 and trim(bookSourceGroup) <> ''")
+    val allEnabledGroupsUnProcessed: List<String>
+
     @Query("select * from book_sources where bookSourceUrl = :key")
     fun getBookSource(key: String): BookSource?
 
@@ -175,6 +178,11 @@ interface BookSourceDao {
             return dealGroups(allGroupsUnProcessed)
         }
 
+    val allEnabledGroups: List<String>
+        get() {
+            return dealGroups(allEnabledGroupsUnProcessed)
+        }
+
     fun flowGroups(): Flow<List<String>> {
         return flowGroupsUnProcessed().map { list ->
             dealGroups(list)

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

@@ -166,7 +166,7 @@ interface JsExtensions {
     fun importScript(path: String): String {
         val result = when {
             path.startsWith("http") -> cacheFile(path) ?: ""
-            path.isContentScheme() -> DocumentUtils.readText(appCtx, Uri.parse(path))
+            path.isUri() -> Uri.parse(path).readText(appCtx)
             path.startsWith("/storage") -> FileUtils.readText(path)
             else -> readTxtFile(path)
         }

+ 32 - 21
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -1,17 +1,16 @@
+@file:Suppress("unused")
+
 package io.legado.app.help.book
 
 import android.net.Uri
-import androidx.documentfile.provider.DocumentFile
 import io.legado.app.constant.BookSourceType
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
-import io.legado.app.help.config.AppConfig.defaultBookTreeUri
 import io.legado.app.exception.NoStackTraceException
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.getFile
+import io.legado.app.help.config.AppConfig.defaultBookTreeUri
+import io.legado.app.utils.*
 import java.io.File
-import splitties.init.appCtx
 
 
 val Book.isAudio: Boolean
@@ -54,25 +53,16 @@ val Book.isOnLineTxt: Boolean
 
 fun Book.getLocalUri(): Uri {
     if (isLocal) {
-         val originBookUri = if (bookUrl.isContentScheme()) {
-             Uri.parse(bookUrl)
-         } else {
-             Uri.fromFile(File(bookUrl))
-         }
+        val originBookUri = if (bookUrl.isContentScheme()) {
+            Uri.parse(bookUrl)
+        } else {
+            Uri.fromFile(File(bookUrl))
+        }
         //不同的设备书籍保存路径可能不一样 优先尝试寻找当前保存路径下的文件
         defaultBookTreeUri ?: return originBookUri
         val treeUri = Uri.parse(defaultBookTreeUri)
-        return if (treeUri.isContentScheme()) {
-            DocumentFile.fromTreeUri(appCtx, treeUri)?.run {
-                findFile(originName)?.let {
-                    if (it.exists()) it.uri else originBookUri
-                } ?: originBookUri
-            } ?: originBookUri
-        } else {
-            val treeFile = File(treeUri.path!!)
-            val file = treeFile.getFile(originName)
-            if (file.exists()) Uri.fromFile(file) else originBookUri
-        }
+        val treeFileDoc = FileDoc.fromUri(treeUri, true)
+        return treeFileDoc.find(originName, 5)?.uri ?: originBookUri
     }
     throw NoStackTraceException("不是本地书籍")
 }
@@ -84,6 +74,27 @@ fun Book.getRemoteUrl(): String? {
     return null
 }
 
+fun Book.setType(@BookType.Type vararg types: Int) {
+    type = 0
+    addType(*types)
+}
+
+fun Book.addType(@BookType.Type vararg types: Int) {
+    types.forEach {
+        type = type or it
+    }
+}
+
+fun Book.removeType(@BookType.Type vararg types: Int) {
+    types.forEach {
+        type = type and it.inv()
+    }
+}
+
+fun Book.clearType() {
+    type = 0
+}
+
 fun Book.upType() {
     if (type < 8) {
         type = when (type) {

+ 1 - 1
app/src/main/java/io/legado/app/help/coroutine/Coroutine.kt

@@ -145,7 +145,7 @@ class Coroutine<T>(
         context: CoroutineContext,
         block: suspend CoroutineScope.() -> T
     ): Job {
-        return (scope + Dispatchers.Main).launch(start = startOption) {
+        return (scope.plus(Dispatchers.Main)).launch(start = startOption) {
             try {
                 start?.let { dispatchVoidCallback(this, it) }
                 ensureActive()

+ 10 - 0
app/src/main/java/io/legado/app/help/source/BookSourceExtensions.kt

@@ -75,4 +75,14 @@ suspend fun BookSource.clearExploreKindsCache() {
         aCache.remove(exploreKindsKey)
         exploreKindsMap.remove(getExploreKindsKey())
     }
+}
+
+fun BookSource.contains(word: String?): Boolean {
+    if (word.isNullOrEmpty()) {
+        return true
+    }
+    return bookSourceName.contains(word)
+            || bookSourceUrl.contains(word)
+            || bookSourceGroup?.contains(word) == true
+            || bookSourceComment?.contains(word) == true
 }

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

@@ -19,7 +19,7 @@ object ImportOldData {
                 when (doc.name) {
                     "myBookShelf.json" ->
                         kotlin.runCatching {
-                            DocumentUtils.readText(context, doc.uri).let { json ->
+                            doc.uri.readText(context).let { json ->
                                 val importCount = importOldBookshelf(json)
                                 context.toastOnUi("成功导入书籍${importCount}")
                             }
@@ -28,7 +28,7 @@ object ImportOldData {
                         }
                     "myBookSource.json" ->
                         kotlin.runCatching {
-                            DocumentUtils.readText(context, doc.uri).let { json ->
+                            doc.uri.readText(context).let { json ->
                                 val importCount = importOldSource(json)
                                 context.toastOnUi("成功导入书源${importCount}")
                             }
@@ -37,7 +37,7 @@ object ImportOldData {
                         }
                     "myBookReplaceRule.json" ->
                         kotlin.runCatching {
-                            DocumentUtils.readText(context, doc.uri).let { json ->
+                            doc.uri.readText(context).let { json ->
                                 val importCount = importOldReplaceRule(json)
                                 context.toastOnUi("成功导入替换规则${importCount}")
                             }

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

@@ -358,9 +358,9 @@ open class WebDav(val path: String, val authorization: Authorization) {
             val exception = document.getElementsByTag("s:exception").firstOrNull()?.text()
             val message = document.getElementsByTag("s:message").firstOrNull()?.text()
             if (exception == "ObjectNotFound") {
-                throw ObjectNotFoundException(message ?: "$path doesn't exist")
+                throw ObjectNotFoundException(message ?: "$path doesn't exist. code:${response.code}")
             }
-            throw WebDavException(message ?: "未知错误")
+            throw WebDavException(message ?: "未知错误 code:${response.code}")
         }
     }
 

+ 8 - 0
app/src/main/java/io/legado/app/service/AudioPlayService.kt

@@ -9,6 +9,7 @@ import android.graphics.BitmapFactory
 import android.media.AudioManager
 import android.net.Uri
 import android.os.Build
+import android.os.PowerManager
 import android.support.v4.media.session.MediaSessionCompat
 import android.support.v4.media.session.PlaybackStateCompat
 import androidx.core.app.NotificationCompat
@@ -36,6 +37,7 @@ import io.legado.app.utils.*
 import kotlinx.coroutines.*
 import kotlinx.coroutines.Dispatchers.Main
 import splitties.systemservices.audioManager
+import splitties.systemservices.powerManager
 
 /**
  * 音频播放服务
@@ -61,6 +63,9 @@ class AudioPlayService : BaseService(),
             private set
     }
 
+    private val wakeLock by lazy {
+        powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:webService")
+    }
     private val mFocusRequest: AudioFocusRequestCompat by lazy {
         MediaHelp.buildAudioFocusRequestCompat(this)
     }
@@ -82,8 +87,10 @@ class AudioPlayService : BaseService(),
     private var upPlayProgressJob: Job? = null
     private var playSpeed: Float = 1f
 
+    @SuppressLint("WakelockTimeout")
     override fun onCreate() {
         super.onCreate()
+        wakeLock.acquire()
         isRun = true
         upNotification()
         exoPlayer.addListener(this)
@@ -120,6 +127,7 @@ class AudioPlayService : BaseService(),
 
     override fun onDestroy() {
         super.onDestroy()
+        wakeLock.release()
         isRun = false
         abandonFocus()
         exoPlayer.release()

+ 8 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -8,6 +8,7 @@ import android.content.Intent
 import android.content.IntentFilter
 import android.graphics.BitmapFactory
 import android.media.AudioManager
+import android.os.PowerManager
 import android.support.v4.media.session.MediaSessionCompat
 import android.support.v4.media.session.PlaybackStateCompat
 import androidx.annotation.CallSuper
@@ -30,6 +31,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import splitties.systemservices.audioManager
+import splitties.systemservices.powerManager
 
 /**
  * 朗读服务
@@ -55,6 +57,9 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    private val wakeLock by lazy {
+        powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:readAloud")
+    }
     private val mFocusRequest: AudioFocusRequestCompat by lazy {
         MediaHelp.buildAudioFocusRequestCompat(this)
     }
@@ -77,8 +82,10 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    @SuppressLint("WakelockTimeout")
     override fun onCreate() {
         super.onCreate()
+        wakeLock.acquire()
         isRun = true
         pause = false
         initMediaSession()
@@ -90,6 +97,7 @@ abstract class BaseReadAloudService : BaseService(),
 
     override fun onDestroy() {
         super.onDestroy()
+        wakeLock.release()
         isRun = false
         pause = true
         abandonFocus()

+ 9 - 0
app/src/main/java/io/legado/app/service/WebService.kt

@@ -1,8 +1,10 @@
 package io.legado.app.service
 
+import android.annotation.SuppressLint
 import android.content.Context
 import android.content.Intent
 import android.os.Build
+import android.os.PowerManager
 import androidx.core.app.NotificationCompat
 import io.legado.app.R
 import io.legado.app.base.BaseService
@@ -14,6 +16,7 @@ import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.utils.*
 import io.legado.app.web.HttpServer
 import io.legado.app.web.WebSocketServer
+import splitties.systemservices.powerManager
 
 import java.io.IOException
 
@@ -33,6 +36,9 @@ class WebService : BaseService() {
 
     }
 
+    private val wakeLock by lazy {
+        powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:webService")
+    }
     private var httpServer: HttpServer? = null
     private var webSocketServer: WebSocketServer? = null
     private var notificationContent = ""
@@ -40,8 +46,10 @@ class WebService : BaseService() {
         NetworkChangedListener(this)
     }
 
+    @SuppressLint("WakelockTimeout")
     override fun onCreate() {
         super.onCreate()
+        wakeLock.acquire()
         isRun = true
         notificationContent = getString(R.string.service_starting)
         upNotification()
@@ -73,6 +81,7 @@ class WebService : BaseService() {
 
     override fun onDestroy() {
         super.onDestroy()
+        wakeLock.release()
         networkChangedListener.unRegister()
         isRun = false
         if (httpServer?.isAlive == true) {

+ 4 - 15
app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt

@@ -2,16 +2,11 @@ package io.legado.app.ui.association
 
 import android.app.Application
 import android.net.Uri
-import androidx.documentfile.provider.DocumentFile
 import androidx.lifecycle.MutableLiveData
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern.bookFileRegex
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.model.localBook.LocalBook
-import io.legado.app.utils.inputStream
-import io.legado.app.utils.isJson
-import io.legado.app.utils.printOnDebug
-import java.io.File
+import io.legado.app.utils.*
 
 class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) {
     val importBookLiveData = MutableLiveData<Uri>()
@@ -24,15 +19,9 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
         execute {
             lateinit var fileName: String
             //如果是普通的url,需要根据返回的内容判断是什么
-            if (uri.scheme == "file" || uri.scheme == "content") {
-                fileName = if (uri.scheme == "file") {
-                    val file = File(uri.path.toString())
-                    file.name
-                } else {
-                    val file = DocumentFile.fromSingleUri(context, uri)
-                    if (file?.exists() != true) throw NoStackTraceException("文件不存在")
-                    file.name ?: ""
-                }
+            if (uri.isContentScheme() || uri.isFileScheme()) {
+                val fileDoc = FileDoc.fromUri(uri, false)
+                fileName = fileDoc.name
                 kotlin.runCatching {
                     if (uri.inputStream(context).isJson()) {
                         importJson(uri)

+ 36 - 31
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt

@@ -46,22 +46,19 @@ class ImportBookActivity : BaseImportBookActivity<ActivityImportBookBinding, Imp
 
     private val selectFolder = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { uri ->
-            if (uri.isContentScheme()) {
-                AppConfig.importBookPath = uri.toString()
-                initRootDoc(true)
-            } else {
-                AppConfig.importBookPath = uri.path
-                initRootDoc(true)
-            }
+            AppConfig.importBookPath = uri.toString()
+            initRootDoc(true)
         }
     }
 
     override fun onActivityCreated(savedInstanceState: Bundle?) {
         binding.titleBar.setTitle(R.string.book_local)
         launch {
-            setBookStorage()
             initView()
             initEvent()
+            if (setBookStorage() && AppConfig.importBookPath.isNullOrBlank()) {
+                AppConfig.importBookPath = AppConfig.defaultBookTreeUri
+            }
             initData()
         }
     }
@@ -150,35 +147,43 @@ class ImportBookActivity : BaseImportBookActivity<ActivityImportBookBinding, Imp
     }
 
     private fun initRootDoc(changedFolder: Boolean = false) {
-        val lastPath = AppConfig.importBookPath
-        when {
-            viewModel.rootDoc != null && !changedFolder -> upPath()
-            lastPath.isNullOrEmpty() -> {
+        if (viewModel.rootDoc != null && !changedFolder) {
+            upPath()
+        } else {
+            val lastPath = AppConfig.importBookPath
+            if (lastPath.isNullOrBlank()) {
                 binding.tvEmptyMsg.visible()
                 selectFolder.launch()
-            }
-            lastPath.isContentScheme() -> {
-                val rootUri = Uri.parse(lastPath)
-                kotlin.runCatching {
-                    val doc = DocumentFile.fromTreeUri(this, rootUri)
-                    if (doc == null || doc.name.isNullOrEmpty()) {
+            } else {
+                val rootUri = if (lastPath.isUri()) {
+                    Uri.parse(lastPath)
+                } else {
+                    Uri.fromFile(File(lastPath))
+                }
+                when {
+                    rootUri.isContentScheme() -> {
+                        kotlin.runCatching {
+                            val doc = DocumentFile.fromTreeUri(this, rootUri)
+                            if (doc == null || doc.name.isNullOrEmpty()) {
+                                binding.tvEmptyMsg.visible()
+                                selectFolder.launch()
+                            } else {
+                                viewModel.subDocs.clear()
+                                viewModel.rootDoc = FileDoc.fromDocumentFile(doc)
+                                upDocs(viewModel.rootDoc!!)
+                            }
+                        }.onFailure {
+                            binding.tvEmptyMsg.visible()
+                            selectFolder.launch()
+                        }
+                    }
+                    Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> {
                         binding.tvEmptyMsg.visible()
                         selectFolder.launch()
-                    } else {
-                        viewModel.subDocs.clear()
-                        viewModel.rootDoc = FileDoc.fromDocumentFile(doc)
-                        upDocs(viewModel.rootDoc!!)
                     }
-                }.onFailure {
-                    binding.tvEmptyMsg.visible()
-                    selectFolder.launch()
+                    else -> initRootPath(rootUri.path!!)
                 }
             }
-            Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> {
-                binding.tvEmptyMsg.visible()
-                selectFolder.launch()
-            }
-            else -> initRootPath(lastPath)
         }
     }
 
@@ -228,7 +233,7 @@ class ImportBookActivity : BaseImportBookActivity<ActivityImportBookBinding, Imp
         binding.tvPath.text = path
         adapter.selectedUris.clear()
         adapter.clearItems()
-        viewModel.loadDoc(lastDoc.uri)
+        viewModel.loadDoc(lastDoc)
     }
 
     /**

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -102,16 +102,16 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         }
     }
 
-    fun loadDoc(uri: Uri) {
+    fun loadDoc(fileDoc: FileDoc) {
         execute {
-            val docList = DocumentUtils.listFiles(uri) { item ->
+            val docList = fileDoc.list { item ->
                 when {
                     item.name.startsWith(".") -> false
                     item.isDir -> true
                     else -> item.name.matches(bookFileRegex)
                 }
             }
-            dataCallback?.setItems(docList)
+            dataCallback?.setItems(docList!!)
         }.onError {
             context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
         }
@@ -132,7 +132,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         }
         kotlin.runCatching {
             val list = ArrayList<FileDoc>()
-            DocumentUtils.listFiles(fileDoc.uri).forEach { docItem ->
+            fileDoc.list()!!.forEach { docItem ->
                 if (!scope.isActive) {
                     finally?.invoke()
                     return

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -18,6 +18,7 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.getRemoteUrl
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.webdav.ObjectNotFoundException
 import io.legado.app.model.BookCover
@@ -236,6 +237,7 @@ class BookInfoViewModel(application: Application) : BaseViewModel(application) {
             bookSource = source
             bookData.value?.changeTo(book, toc)
             if (inBookshelf) {
+                book.removeType(BookType.updateError)
                 appDb.bookDao.insert(book)
                 appDb.bookChapterDao.insert(*toc.toTypedArray())
             }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt

@@ -184,6 +184,7 @@ class BookshelfManageActivity :
                 AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio()
                 AppConst.bookGroupNetNoneId -> appDb.bookDao.flowNetNoGroup()
                 AppConst.bookGroupLocalNoneId -> appDb.bookDao.flowLocalNoGroup()
+                AppConst.bookGroupErrorId -> appDb.bookDao.flowUpdateError()
                 else -> appDb.bookDao.flowByGroup(viewModel.groupId)
             }.conflate().map { list ->
                 val books = if (searchKey.isNullOrBlank()) {

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt

@@ -3,10 +3,12 @@ package io.legado.app.ui.book.manage
 import android.app.Application
 import androidx.compose.runtime.mutableStateOf
 import io.legado.app.base.BaseViewModel
+import io.legado.app.constant.BookType
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.toastOnUi
@@ -58,6 +60,7 @@ class BookshelfManageViewModel(application: Application) : BaseViewModel(applica
                                 context.toastOnUi("获取目录出错\n${it.localizedMessage}")
                             }.getOrNull()?.let { toc ->
                                 book.changeTo(newBook, toc)
+                                book.removeType(BookType.updateError)
                                 appDb.bookDao.insert(newBook)
                                 appDb.bookChapterDao.insert(*toc.toTypedArray())
                             }

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
@@ -19,6 +20,7 @@ import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.ReadAloud
@@ -215,6 +217,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
         changeSourceCoroutine = execute {
             ReadBook.upMsg(context.getString(R.string.loading))
             ReadBook.book?.changeTo(book, toc)
+            book.removeType(BookType.updateError)
             appDb.bookDao.insert(book)
             appDb.bookChapterDao.insert(*toc.toTypedArray())
             ReadBook.resetData(book)

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -221,6 +221,9 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
 
     private fun initData() {
         searchScopeAdapter.setItems(viewModel.searchScope.displayNames)
+        viewModel.searchScope.stateLiveData.observe(this) {
+            searchScopeAdapter.setItems(viewModel.searchScope.displayNames)
+        }
         viewModel.isSearchLiveData.observe(this) {
             if (it) {
                 startSearch()
@@ -400,6 +403,14 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
         }
     }
 
+    override fun finish() {
+        if (searchView.hasFocus()) {
+            searchView.clearFocus()
+            return
+        }
+        super.finish()
+    }
+
     companion object {
 
         fun start(context: Context, key: String?) {

+ 20 - 5
app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.search
 
+import androidx.lifecycle.MutableLiveData
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.help.config.AppConfig
@@ -13,12 +14,18 @@ data class SearchScope(private var scope: String) {
 
     constructor(groups: List<String>) : this(groups.joinToString(","))
 
-    constructor(source: BookSource) : this("${source.bookSourceName}::${source.bookSourceUrl}")
+    constructor(source: BookSource) : this(
+        "${
+            source.bookSourceName.replace("::", "")
+        }::${source.bookSourceUrl}"
+    )
 
     override fun toString(): String {
         return scope
     }
 
+    val stateLiveData = MutableLiveData("")
+
     fun update(scope: String) {
         this.scope = scope
     }
@@ -54,9 +61,9 @@ data class SearchScope(private var scope: String) {
             }
             if (list.isEmpty()) {
                 list.add("全部书源")
+            }
+            return list
         }
-        return list
-    }
 
     /**
      * 搜索范围书源
@@ -70,12 +77,20 @@ data class SearchScope(private var scope: String) {
                 }
             }
         } else {
-            scope.splitNotBlank(",").forEach {
-                list.addAll(appDb.bookSourceDao.getByGroup(it))
+            val oldScope = scope.splitNotBlank(",")
+            val newScope = oldScope.filter {
+                val bookSources = appDb.bookSourceDao.getEnabledByGroup(it)
+                list.addAll(bookSources)
+                bookSources.isNotEmpty()
+            }
+            if (oldScope.size != newScope.size) {
+                update(newScope)
+                stateLiveData.postValue("")
             }
         }
         if (list.isEmpty()) {
             scope = ""
+            stateLiveData.postValue("")
             return appDb.bookSourceDao.allEnabled
         }
         return list.sortedBy { it.customOrder }

+ 4 - 5
app/src/main/java/io/legado/app/ui/book/search/SearchScopeDialog.kt

@@ -14,6 +14,7 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.DialogSearchScopeBinding
 import io.legado.app.databinding.ItemCheckBoxBinding
 import io.legado.app.databinding.ItemRadioButtonBinding
+import io.legado.app.help.source.contains
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.utils.applyTint
 import io.legado.app.utils.setLayout
@@ -72,7 +73,7 @@ class SearchScopeDialog : BaseDialogFragment(R.layout.dialog_search_scope) {
     }
 
     private fun initOtherView() {
-        binding.rgScope.setOnCheckedChangeListener { group, checkedId ->
+        binding.rgScope.setOnCheckedChangeListener { _, checkedId ->
             binding.toolBar.menu.findItem(R.id.menu_screen)?.isVisible = checkedId == R.id.rb_source
             upData()
         }
@@ -101,7 +102,7 @@ class SearchScopeDialog : BaseDialogFragment(R.layout.dialog_search_scope) {
     private fun initData() {
         launch {
             groups = withContext(IO) {
-                appDb.bookSourceDao.allGroups
+                appDb.bookSourceDao.allEnabledGroups
             }
             sources = withContext(IO) {
                 appDb.bookSourceDao.allEnabled
@@ -116,9 +117,7 @@ class SearchScopeDialog : BaseDialogFragment(R.layout.dialog_search_scope) {
             withContext(IO) {
                 if (binding.rbSource.isChecked) {
                     sources.filter { source ->
-                        screenText?.let { screenText ->
-                            source.bookSourceName.contains(screenText)
-                        } ?: true
+                        source.contains(screenText)
                     }.let {
                         screenSources.clear()
                         screenSources.addAll(it)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt

@@ -193,7 +193,7 @@ class SearchContentActivity :
         }
     }
 
-    val isLocalBook: Boolean
+    private val isLocalBook: Boolean
         get() = viewModel.book?.isLocal == true
 
     override fun openSearchResult(searchResult: SearchResult, index: Int) {

+ 13 - 21
app/src/main/java/io/legado/app/ui/font/FontSelectDialog.kt

@@ -24,6 +24,9 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.launch
 import java.io.File
 
+/**
+ * 字体选择对话框
+ */
 class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select),
     Toolbar.OnMenuItemClickListener,
     FontAdapter.CallBack {
@@ -35,11 +38,11 @@ class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select),
     }
     private val selectFontDir = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { uri ->
-            if (uri.toString().isContentScheme()) {
+            if (uri.isContentScheme()) {
                 putPrefString(PreferKey.fontFolder, uri.toString())
                 val doc = DocumentFile.fromTreeUri(requireContext(), uri)
                 if (doc != null) {
-                    loadFontFiles(doc)
+                    loadFontFiles(FileDoc.fromDocumentFile(doc))
                 } else {
                     RealPathUtil.getPath(requireContext(), uri)?.let { path ->
                         loadFontFilesByPermission(path)
@@ -75,7 +78,7 @@ class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select),
             if (fontPath.isContentScheme()) {
                 val doc = DocumentFile.fromTreeUri(requireContext(), Uri.parse(fontPath))
                 if (doc?.canRead() == true) {
-                    loadFontFiles(doc)
+                    loadFontFiles(FileDoc.fromDocumentFile(doc))
                 } else {
                     openFolder()
                 }
@@ -117,40 +120,29 @@ class FontSelectDialog : BaseDialogFragment(R.layout.dialog_font_select),
 
     private fun getLocalFonts(): ArrayList<FileDoc> {
         val path = FileUtils.getPath(requireContext().externalFiles, "font")
-        return DocumentUtils.listFiles(path) {
+        return File(path).listFileDocs {
             it.name.matches(fontRegex)
         }
     }
 
-    private fun loadFontFiles(doc: DocumentFile) {
-        execute {
-            val fontItems = DocumentUtils.listFiles(doc.uri) {
-                it.name.matches(fontRegex)
-            }
-            mergeFontItems(fontItems, getLocalFonts())
-        }.onSuccess {
-            adapter.setItems(it)
-        }.onError {
-            toastOnUi("getFontFiles:${it.localizedMessage}")
-        }
-    }
-
     private fun loadFontFilesByPermission(path: String) {
         PermissionsCompat.Builder(this@FontSelectDialog)
             .addPermissions(*Permissions.Group.STORAGE)
             .rationale(R.string.tip_perm_request_storage)
             .onGranted {
-                loadFontFiles(path)
+                loadFontFiles(
+                    FileDoc.fromFile(File(path))
+                )
             }
             .request()
     }
 
-    private fun loadFontFiles(path: String) {
+    private fun loadFontFiles(fileDoc: FileDoc) {
         execute {
-            val fontItems = DocumentUtils.listFiles(path) {
+            val fontItems = fileDoc.list {
                 it.name.matches(fontRegex)
             }
-            mergeFontItems(fontItems, getLocalFonts())
+            mergeFontItems(fontItems!!, getLocalFonts())
         }.onSuccess {
             adapter.setItems(it)
         }.onError {

+ 6 - 0
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
@@ -12,7 +13,9 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.DefaultData
 import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.addType
 import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.model.CacheBook
@@ -126,6 +129,7 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
                 WebBook.getBookInfoAwait(source, book)
             }
             val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
+            book.removeType(BookType.updateError)
             if (book.bookUrl == bookUrl) {
                 appDb.bookDao.update(book)
             } else {
@@ -137,6 +141,8 @@ class MainViewModel(application: Application) : BaseViewModel(application) {
             appDb.bookChapterDao.insert(*toc.toTypedArray())
             addDownload(source, book)
         }.onError(upTocPool) {
+            book.addType(BookType.updateError)
+            appDb.bookDao.update(book)
             AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it)
         }.onCancel(upTocPool) {
             upTocCancel(bookUrl)

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt

@@ -118,6 +118,7 @@ class BooksFragment() : BaseFragment(R.layout.fragment_books),
                 AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio()
                 AppConst.bookGroupNetNoneId -> appDb.bookDao.flowNetNoGroup()
                 AppConst.bookGroupLocalNoneId -> appDb.bookDao.flowLocalNoGroup()
+                AppConst.bookGroupErrorId -> appDb.bookDao.flowUpdateError()
                 else -> appDb.bookDao.flowByGroup(groupId)
             }.conflate().map { list ->
                 when (getPrefInt(PreferKey.bookshelfSort)) {

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt

@@ -130,6 +130,7 @@ class BookshelfFragment2 : BaseBookshelfFragment(R.layout.fragment_bookshelf1),
                 AppConst.bookGroupAudioId -> appDb.bookDao.flowAudio()
                 AppConst.bookGroupNetNoneId -> appDb.bookDao.flowNetNoGroup()
                 AppConst.bookGroupLocalNoneId -> appDb.bookDao.flowLocalNoGroup()
+                AppConst.bookGroupErrorId -> appDb.bookDao.flowUpdateError()
                 else -> appDb.bookDao.flowByGroup(groupId)
             }.conflate().map { list ->
                 when (getPrefInt(PreferKey.bookshelfSort)) {

+ 8 - 1
app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt

@@ -15,6 +15,7 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.databinding.FragmentRssBinding
 import io.legado.app.databinding.ItemRssBinding
+import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.lib.theme.primaryTextColor
 import io.legado.app.ui.rss.article.RssSortActivity
@@ -179,7 +180,13 @@ class RssFragment : VMBaseFragment<RssSourceViewModel>(R.layout.fragment_rss),
     }
 
     override fun del(rssSource: RssSource) {
-        viewModel.del(rssSource)
+        alert(R.string.draw) {
+            setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
+            noButton()
+            yesButton {
+                viewModel.del(rssSource)
+            }
+        }
     }
 
     override fun disable(rssSource: RssSource) {

+ 0 - 225
app/src/main/java/io/legado/app/utils/DocumentExtensions.kt

@@ -1,225 +0,0 @@
-package io.legado.app.utils
-
-import android.content.Context
-import android.database.Cursor
-import android.net.Uri
-import android.provider.DocumentsContract
-import androidx.documentfile.provider.DocumentFile
-import io.legado.app.exception.NoStackTraceException
-import splitties.init.appCtx
-import java.io.File
-import java.nio.charset.Charset
-
-
-@Suppress("MemberVisibilityCanBePrivate")
-object DocumentUtils {
-
-    fun exists(root: DocumentFile, fileName: String, vararg subDirs: String): Boolean {
-        val parent = getDirDocument(root, *subDirs) ?: return false
-        return parent.findFile(fileName)?.exists() ?: false
-    }
-
-    fun delete(root: DocumentFile, fileName: String, vararg subDirs: String) {
-        val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs)
-        parent?.findFile(fileName)?.delete()
-    }
-
-    fun createFileIfNotExist(
-        root: DocumentFile,
-        fileName: String,
-        mimeType: String = "",
-        vararg subDirs: String
-    ): DocumentFile? {
-        val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs)
-        return parent?.findFile(fileName) ?: parent?.createFile(mimeType, fileName)
-    }
-
-    fun createFolderIfNotExist(root: DocumentFile, vararg subDirs: String): DocumentFile? {
-        var parent: DocumentFile? = root
-        for (subDirName in subDirs) {
-            val subDir = parent?.findFile(subDirName)
-                ?: parent?.createDirectory(subDirName)
-            parent = subDir
-        }
-        return parent
-    }
-
-    fun getDirDocument(root: DocumentFile, vararg subDirs: String): DocumentFile? {
-        var parent = root
-        for (subDirName in subDirs) {
-            val subDir = parent.findFile(subDirName)
-            parent = subDir ?: return null
-        }
-        return parent
-    }
-
-    @JvmStatic
-    @Throws(Exception::class)
-    fun writeText(
-        context: Context,
-        data: String,
-        fileUri: Uri,
-        charset: Charset = Charsets.UTF_8
-    ): Boolean {
-        return writeBytes(context, data.toByteArray(charset), fileUri)
-    }
-
-    @JvmStatic
-    @Throws(Exception::class)
-    fun writeBytes(context: Context, data: ByteArray, fileUri: Uri): Boolean {
-        context.contentResolver.openOutputStream(fileUri)?.let {
-            it.write(data)
-            it.close()
-            return true
-        }
-        return false
-    }
-
-    @JvmStatic
-    @Throws(Exception::class)
-    fun readText(context: Context, uri: Uri): String {
-        return String(readBytes(context, uri))
-    }
-
-    @JvmStatic
-    @Throws(Exception::class)
-    fun readBytes(context: Context, uri: Uri): ByteArray {
-        context.contentResolver.openInputStream(uri)?.let {
-            val len: Int = it.available()
-            val buffer = ByteArray(len)
-            it.read(buffer)
-            it.close()
-            return buffer
-        } ?: throw NoStackTraceException("打开文件失败\n${uri}")
-    }
-
-    @Throws(Exception::class)
-    fun listFiles(uri: Uri, filter: ((file: FileDoc) -> Boolean)? = null): ArrayList<FileDoc> {
-        if (!uri.isContentScheme()) {
-            return listFiles(uri.path!!, filter)
-        }
-        val childrenUri = DocumentsContract
-            .buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri))
-        val docList = arrayListOf<FileDoc>()
-        var cursor: Cursor? = null
-        try {
-            cursor = appCtx.contentResolver.query(
-                childrenUri, arrayOf(
-                    DocumentsContract.Document.COLUMN_DOCUMENT_ID,
-                    DocumentsContract.Document.COLUMN_DISPLAY_NAME,
-                    DocumentsContract.Document.COLUMN_LAST_MODIFIED,
-                    DocumentsContract.Document.COLUMN_SIZE,
-                    DocumentsContract.Document.COLUMN_MIME_TYPE
-                ), null, null, DocumentsContract.Document.COLUMN_DISPLAY_NAME
-            )
-            cursor?.let {
-                val ici = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
-                val nci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
-                val sci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
-                val mci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
-                val dci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
-                if (cursor.moveToFirst()) {
-                    do {
-                        val item = FileDoc(
-                            name = cursor.getString(nci),
-                            isDir = cursor.getString(mci) == DocumentsContract.Document.MIME_TYPE_DIR,
-                            size = cursor.getLong(sci),
-                            lastModified = cursor.getLong(dci),
-                            uri = DocumentsContract
-                                .buildDocumentUriUsingTree(uri, cursor.getString(ici))
-                        )
-                        if (filter == null || filter.invoke(item)) {
-                            docList.add(item)
-                        }
-                    } while (cursor.moveToNext())
-                }
-            }
-        } finally {
-            cursor?.close()
-        }
-        return docList
-    }
-
-    @Throws(Exception::class)
-    fun listFiles(path: String, filter: ((file: FileDoc) -> Boolean)? = null): ArrayList<FileDoc> {
-        val docList = arrayListOf<FileDoc>()
-        val file = File(path)
-        file.listFiles()?.forEach {
-            val item = FileDoc(
-                it.name,
-                it.isDirectory,
-                it.length(),
-                it.lastModified(),
-                Uri.fromFile(it)
-            )
-            if (filter == null || filter.invoke(item)) {
-                docList.add(item)
-            }
-        }
-        return docList
-    }
-
-}
-
-data class FileDoc(
-    val name: String,
-    val isDir: Boolean,
-    val size: Long,
-    val lastModified: Long,
-    val uri: Uri
-) {
-
-    override fun toString(): String {
-        return if (uri.isContentScheme()) uri.toString() else uri.path!!
-    }
-
-    val isContentScheme get() = uri.isContentScheme()
-
-    fun readBytes(): ByteArray {
-        return uri.readBytes(appCtx)
-    }
-
-    companion object {
-
-        fun fromDocumentFile(doc: DocumentFile): FileDoc {
-            return FileDoc(
-                name = doc.name ?: "",
-                isDir = doc.isDirectory,
-                size = doc.length(),
-                lastModified = doc.lastModified(),
-                uri = doc.uri
-            )
-        }
-
-        fun fromFile(file: File): FileDoc {
-            return FileDoc(
-                name = file.name,
-                isDir = file.isDirectory,
-                size = file.length(),
-                lastModified = file.lastModified(),
-                uri = Uri.fromFile(file)
-            )
-        }
-
-    }
-}
-
-@Throws(Exception::class)
-fun DocumentFile.writeText(context: Context, data: String, charset: Charset = Charsets.UTF_8) {
-    DocumentUtils.writeText(context, data, this.uri, charset)
-}
-
-@Throws(Exception::class)
-fun DocumentFile.writeBytes(context: Context, data: ByteArray) {
-    DocumentUtils.writeBytes(context, data, this.uri)
-}
-
-@Throws(Exception::class)
-fun DocumentFile.readText(context: Context): String {
-    return DocumentUtils.readText(context, this.uri)
-}
-
-@Throws(Exception::class)
-fun DocumentFile.readBytes(context: Context): ByteArray {
-    return DocumentUtils.readBytes(context, this.uri)
-}

+ 47 - 0
app/src/main/java/io/legado/app/utils/DocumentUtils.kt

@@ -0,0 +1,47 @@
+package io.legado.app.utils
+
+import androidx.documentfile.provider.DocumentFile
+
+@Suppress("MemberVisibilityCanBePrivate")
+object DocumentUtils {
+
+    fun exists(root: DocumentFile, fileName: String, vararg subDirs: String): Boolean {
+        val parent = getDirDocument(root, *subDirs) ?: return false
+        return parent.findFile(fileName)?.exists() ?: false
+    }
+
+    fun delete(root: DocumentFile, fileName: String, vararg subDirs: String) {
+        val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs)
+        parent?.findFile(fileName)?.delete()
+    }
+
+    fun createFileIfNotExist(
+        root: DocumentFile,
+        fileName: String,
+        mimeType: String = "",
+        vararg subDirs: String
+    ): DocumentFile? {
+        val parent: DocumentFile? = createFolderIfNotExist(root, *subDirs)
+        return parent?.findFile(fileName) ?: parent?.createFile(mimeType, fileName)
+    }
+
+    fun createFolderIfNotExist(root: DocumentFile, vararg subDirs: String): DocumentFile? {
+        var parent: DocumentFile? = root
+        for (subDirName in subDirs) {
+            val subDir = parent?.findFile(subDirName)
+                ?: parent?.createDirectory(subDirName)
+            parent = subDir
+        }
+        return parent
+    }
+
+    fun getDirDocument(root: DocumentFile, vararg subDirs: String): DocumentFile? {
+        var parent = root
+        for (subDirName in subDirs) {
+            val subDir = parent.findFile(subDirName)
+            parent = subDir ?: return null
+        }
+        return parent
+    }
+
+}

+ 255 - 0
app/src/main/java/io/legado/app/utils/FileDocExtensions.kt

@@ -0,0 +1,255 @@
+@file:Suppress("unused")
+
+package io.legado.app.utils
+
+import android.app.DownloadManager
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.documentfile.provider.DocumentFile
+import io.legado.app.exception.NoStackTraceException
+import splitties.init.appCtx
+import splitties.systemservices.downloadManager
+import java.io.File
+import java.nio.charset.Charset
+
+
+data class FileDoc(
+    val name: String,
+    val isDir: Boolean,
+    val size: Long,
+    val lastModified: Long,
+    val uri: Uri
+) {
+
+    override fun toString(): String {
+        return if (uri.isContentScheme()) uri.toString() else uri.path!!
+    }
+
+    val isContentScheme get() = uri.isContentScheme()
+
+    fun readBytes(): ByteArray {
+        return uri.readBytes(appCtx)
+    }
+
+    companion object {
+
+        fun fromUri(uri: Uri, isDir: Boolean): FileDoc {
+            if (uri.isContentScheme()) {
+                val doc = if (isDir) {
+                    DocumentFile.fromTreeUri(appCtx, uri)!!
+                } else if (uri.host == "downloads") {
+                    val query = DownloadManager.Query()
+                    query.setFilterById(uri.lastPathSegment!!.toLong())
+                    downloadManager.query(query).use {
+                        if (it.moveToFirst()) {
+                            val lUriColum = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
+                            val lUri = it.getString(lUriColum)
+                            DocumentFile.fromSingleUri(appCtx, Uri.parse(lUri))!!
+                        } else {
+                            DocumentFile.fromSingleUri(appCtx, uri)!!
+                        }
+                    }
+                } else {
+                    DocumentFile.fromSingleUri(appCtx, uri)!!
+                }
+                return FileDoc(doc.name ?: "", isDir, doc.length(), doc.lastModified(), doc.uri)
+            }
+            val file = File(uri.path!!)
+            return FileDoc(file.name, isDir, file.length(), file.lastModified(), uri)
+        }
+
+        fun fromDocumentFile(doc: DocumentFile): FileDoc {
+            return FileDoc(
+                name = doc.name ?: "",
+                isDir = doc.isDirectory,
+                size = doc.length(),
+                lastModified = doc.lastModified(),
+                uri = doc.uri
+            )
+        }
+
+        fun fromFile(file: File): FileDoc {
+            return FileDoc(
+                name = file.name,
+                isDir = file.isDirectory,
+                size = file.length(),
+                lastModified = file.lastModified(),
+                uri = Uri.fromFile(file)
+            )
+        }
+
+    }
+}
+
+/**
+ * 过滤器
+ */
+typealias FileDocFilter = (file: FileDoc) -> Boolean
+
+private val projection by lazy {
+    arrayOf(
+        DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+        DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+        DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+        DocumentsContract.Document.COLUMN_SIZE,
+        DocumentsContract.Document.COLUMN_MIME_TYPE
+    )
+}
+
+/**
+ * 返回子文件列表,如果不是文件夹则返回null
+ */
+fun FileDoc.list(filter: FileDocFilter? = null): ArrayList<FileDoc>? {
+    if (isDir) {
+        if (uri.isContentScheme()) {
+            /**
+             * DocumentFile 的 listFiles() 非常的慢,所以这里直接从数据库查询
+             */
+            val childrenUri = DocumentsContract
+                .buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri))
+            val docList = arrayListOf<FileDoc>()
+            var cursor: Cursor? = null
+            try {
+                cursor = appCtx.contentResolver.query(
+                    childrenUri,
+                    projection,
+                    null,
+                    null,
+                    DocumentsContract.Document.COLUMN_DISPLAY_NAME
+                )
+                cursor?.let {
+                    val ici = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+                    val nci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+                    val sci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
+                    val mci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
+                    val dci = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
+                    if (cursor.moveToFirst()) {
+                        do {
+                            val item = FileDoc(
+                                name = cursor.getString(nci),
+                                isDir = cursor.getString(mci) == DocumentsContract.Document.MIME_TYPE_DIR,
+                                size = cursor.getLong(sci),
+                                lastModified = cursor.getLong(dci),
+                                uri = DocumentsContract.buildDocumentUriUsingTree(
+                                    uri,
+                                    cursor.getString(ici)
+                                )
+                            )
+                            if (filter == null || filter.invoke(item)) {
+                                docList.add(item)
+                            }
+                        } while (cursor.moveToNext())
+                    }
+                }
+            } finally {
+                cursor?.close()
+            }
+            return docList
+        } else {
+            return File(uri.path!!).listFileDocs(filter)
+        }
+    }
+    return null
+}
+
+/**
+ * 查找文档, 如果存在则返回文档,如果不存在返回空
+ * @param name 文件名
+ * @param depth 查找文件夹深度
+ */
+fun FileDoc.find(name: String, depth: Int = 0): FileDoc? {
+    val list = list()
+    list?.forEach {
+        if (it.name == name) {
+            return it
+        }
+    }
+    if (depth > 0) {
+        list?.forEach {
+            if (it.isDir) {
+                val fileDoc = it.find(name, depth - 1)
+                if (fileDoc != null) {
+                    return fileDoc
+                }
+            }
+        }
+    }
+    return null
+}
+
+fun FileDoc.createFileIfNotExist(
+    fileName: String,
+    mimeType: String = "",
+    vararg subDirs: String
+): FileDoc {
+    return if (uri.isContentScheme()) {
+        val documentFile = DocumentFile.fromTreeUri(appCtx, uri)!!
+        val tmp = DocumentUtils.createFileIfNotExist(documentFile, fileName, mimeType, *subDirs)!!
+        FileDoc.fromDocumentFile(tmp)
+    } else {
+        val path = FileUtils.getPath(uri.path!!, *subDirs) + File.separator + fileName
+        val tmp = FileUtils.createFileIfNotExist(path)
+        FileDoc.fromFile(tmp)
+    }
+}
+
+fun FileDoc.createFolderIfNotExist(
+    vararg subDirs: String
+): FileDoc {
+    return if (uri.isContentScheme()) {
+        val documentFile = DocumentFile.fromTreeUri(appCtx, uri)!!
+        val tmp = DocumentUtils.createFolderIfNotExist(documentFile, *subDirs)!!
+        FileDoc.fromDocumentFile(tmp)
+    } else {
+        val path = FileUtils.getPath(uri.path!!, *subDirs)
+        val tmp = FileUtils.createFolderIfNotExist(path)
+        FileDoc.fromFile(tmp)
+    }
+}
+
+fun FileDoc.exists(
+    fileName: String,
+    vararg subDirs: String
+): Boolean {
+    return if (uri.isContentScheme()) {
+        DocumentUtils.exists(DocumentFile.fromTreeUri(appCtx, uri)!!, fileName, *subDirs)
+    } else {
+        val path = FileUtils.getPath(uri.path!!, *subDirs) + File.separator + fileName
+        FileUtils.exist(path)
+    }
+}
+
+/**
+ * DocumentFile 的 listFiles() 非常的慢,尽量不要使用
+ */
+fun DocumentFile.listFileDocs(filter: FileDocFilter? = null): ArrayList<FileDoc>? {
+    return FileDoc.fromDocumentFile(this).list(filter)
+}
+
+@Throws(Exception::class)
+fun DocumentFile.writeText(context: Context, data: String, charset: Charset = Charsets.UTF_8) {
+    uri.writeText(context, data, charset)
+}
+
+@Throws(Exception::class)
+fun DocumentFile.writeBytes(context: Context, data: ByteArray) {
+    uri.writeBytes(context, data)
+}
+
+@Throws(Exception::class)
+fun DocumentFile.readText(context: Context): String {
+    return String(readBytes(context))
+}
+
+@Throws(Exception::class)
+fun DocumentFile.readBytes(context: Context): ByteArray {
+    return context.contentResolver.openInputStream(uri)?.let {
+        val len: Int = it.available()
+        val buffer = ByteArray(len)
+        it.read(buffer)
+        it.close()
+        return buffer
+    } ?: throw NoStackTraceException("打开文件失败\n${uri}")
+}

+ 21 - 0
app/src/main/java/io/legado/app/utils/FileExtensions.kt

@@ -1,5 +1,6 @@
 package io.legado.app.utils
 
+import android.net.Uri
 import java.io.File
 
 fun File.getFile(vararg subDirFiles: String): File {
@@ -11,3 +12,23 @@ fun File.exists(vararg subDirFiles: String): Boolean {
     return getFile(*subDirFiles).exists()
 }
 
+@Throws(Exception::class)
+fun File.listFileDocs(filter: FileDocFilter? = null): ArrayList<FileDoc> {
+    val docList = arrayListOf<FileDoc>()
+    listFiles()
+    listFiles()?.forEach {
+        val item = FileDoc(
+            it.name,
+            it.isDirectory,
+            it.length(),
+            it.lastModified(),
+            Uri.fromFile(it)
+        )
+        if (filter == null || filter.invoke(item)) {
+            docList.add(item)
+        }
+    }
+    return docList
+}
+
+

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

@@ -4,7 +4,6 @@ import android.os.Environment
 import android.webkit.MimeTypeMap
 import androidx.annotation.IntDef
 import splitties.init.appCtx
-
 import java.io.*
 import java.nio.charset.Charset
 import java.text.SimpleDateFormat
@@ -115,7 +114,7 @@ object FileUtils {
     const val BY_EXTENSION_DESC = 7
 
     @IntDef(value = [BY_NAME_ASC, BY_NAME_DESC, BY_TIME_ASC, BY_TIME_DESC, BY_SIZE_ASC, BY_SIZE_DESC, BY_EXTENSION_ASC, BY_EXTENSION_DESC])
-    @kotlin.annotation.Retention(AnnotationRetention.SOURCE)
+    @Retention(AnnotationRetention.SOURCE)
     annotation class SortType
 
     /**

+ 48 - 19
app/src/main/java/io/legado/app/utils/RegexExtensions.kt

@@ -1,35 +1,64 @@
 package io.legado.app.utils
 
+import android.util.Log
+import androidx.core.os.postDelayed
 import com.script.SimpleBindings
 import io.legado.app.constant.AppConst
 import io.legado.app.exception.RegexTimeoutException
+import io.legado.app.help.CrashHandler
+import io.legado.app.help.coroutine.Coroutine
+import kotlinx.coroutines.suspendCancellableCoroutine
+import splitties.init.appCtx
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+private val handler by lazy { buildMainHandler() }
 
 /**
  * 带有超时检测的正则替换
  */
-fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): String {
-    val startTime = System.currentTimeMillis()
-    val charSequence = this
+suspend fun CharSequence.replace(regex: Regex, replacement: String, timeout: Long): String {
+    val charSequence = this@replace
     val isJs = replacement.startsWith("@js:")
     val replacement1 = if (isJs) replacement.substring(4) else replacement
-    val pattern = regex.toPattern()
-    val matcher = pattern.matcher(charSequence)
-    val stringBuffer = StringBuffer()
-    while (matcher.find()) {
-        if (System.currentTimeMillis() - startTime > timeout) {
-            val timeoutMsg = "替换超时,将禁用替换规则"
-            throw RegexTimeoutException(timeoutMsg)
+    return suspendCancellableCoroutine { block ->
+        val coroutine = Coroutine.async {
+            try {
+                val pattern = regex.toPattern()
+                val matcher = pattern.matcher(charSequence)
+                val stringBuffer = StringBuffer()
+                while (matcher.find()) {
+                    if (isJs) {
+                        val bindings = SimpleBindings()
+                        bindings["result"] = matcher.group()
+                        val jsResult =
+                            AppConst.SCRIPT_ENGINE.eval(replacement1, bindings).toString()
+                        matcher.appendReplacement(stringBuffer, jsResult)
+                    } else {
+                        matcher.appendReplacement(stringBuffer, replacement1)
+                    }
+                }
+                matcher.appendTail(stringBuffer)
+                Log.e("regex", "end")
+                block.resume(stringBuffer.toString())
+            } catch (e: Exception) {
+                block.resumeWithException(e)
+            }
         }
-        if (isJs) {
-            val bindings = SimpleBindings()
-            bindings["result"] = matcher.group()
-            val jsResult = AppConst.SCRIPT_ENGINE.eval(replacement1, bindings).toString()
-            matcher.appendReplacement(stringBuffer, jsResult)
-        } else {
-            matcher.appendReplacement(stringBuffer, replacement1)
+        handler.postDelayed(timeout) {
+            if (coroutine.isActive) {
+                val timeoutMsg = "替换超时,3秒后还未结束将重启应用\n替换规则$regex\n替换内容:${this}"
+                val exception = RegexTimeoutException(timeoutMsg)
+                block.cancel(exception)
+                appCtx.longToastOnUi(timeoutMsg)
+                CrashHandler.saveCrashInfo2File(exception)
+                handler.postDelayed(3000) {
+                    if (coroutine.isActive) {
+                        appCtx.restart()
+                    }
+                }
+            }
         }
     }
-    matcher.appendTail(stringBuffer)
-    return stringBuffer.toString()
 }
 

+ 1 - 3
app/src/main/java/io/legado/app/utils/StringExtensions.kt

@@ -19,9 +19,7 @@ fun String?.isContentScheme(): Boolean = this?.startsWith("content://") == true
 fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
 
 fun String.parseToUri(): Uri {
-    return if (isContentScheme()) {
-        Uri.parse(this)
-    } else {
+    return if (isUri()) Uri.parse(this) else {
         Uri.fromFile(File(this))
     }
 }

+ 18 - 4
app/src/main/java/io/legado/app/utils/UriExtensions.kt

@@ -13,9 +13,12 @@ import io.legado.app.lib.permission.PermissionsCompat
 import java.io.File
 import java.io.FileInputStream
 import java.io.InputStream
+import java.nio.charset.Charset
 
 fun Uri.isContentScheme() = this.scheme == "content"
 
+fun Uri.isFileScheme() = this.scheme == "file"
+
 /**
  * 读取URI
  */
@@ -100,7 +103,13 @@ fun Fragment.readUri(uri: Uri?, success: (fileDoc: FileDoc, inputStream: InputSt
 @Throws(Exception::class)
 fun Uri.readBytes(context: Context): ByteArray {
     return if (this.isContentScheme()) {
-        DocumentUtils.readBytes(context, this)
+        context.contentResolver.openInputStream(this)?.let {
+            val len: Int = it.available()
+            val buffer = ByteArray(len)
+            it.read(buffer)
+            it.close()
+            return buffer
+        } ?: throw NoStackTraceException("打开文件失败\n${this}")
     } else {
         val path = RealPathUtil.getPath(context, this)
         if (path?.isNotEmpty() == true) {
@@ -124,7 +133,12 @@ fun Uri.writeBytes(
     byteArray: ByteArray
 ): Boolean {
     if (this.isContentScheme()) {
-        return DocumentUtils.writeBytes(context, byteArray, this)
+        context.contentResolver.openOutputStream(this)?.let {
+            it.write(byteArray)
+            it.close()
+            return true
+        }
+        return false
     } else {
         val path = RealPathUtil.getPath(context, this)
         if (path?.isNotEmpty() == true) {
@@ -136,8 +150,8 @@ fun Uri.writeBytes(
 }
 
 @Throws(Exception::class)
-fun Uri.writeText(context: Context, text: String): Boolean {
-    return writeBytes(context, text.toByteArray())
+fun Uri.writeText(context: Context, text: String, charset: Charset = Charsets.UTF_8): Boolean {
+    return writeBytes(context, text.toByteArray(charset))
 }
 
 fun Uri.writeBytes(

+ 1 - 1
gradle.properties

@@ -6,7 +6,7 @@
 # http://www.gradle.org/docs/current/userguide/build_environment.html
 # Specifies the JVM arguments used for the daemon process.
 # The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m
+org.gradle.jvmargs=-Xmx3000m
 # When configured, Gradle will run in incubating parallel mode.
 # This option should only be used with decoupled projects. More details, visit
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects