Horis 4 лет назад
Родитель
Сommit
9c9acda0e0

+ 1 - 1
app/src/main/assets/epub/main.css

@@ -203,7 +203,7 @@ h2.head {
     text-align: left;
     font-weight: bold;
     font-size: 1.1em;
-    margin: -3em 2em 2em 0;
+    margin: 1em 2em 2em 0;
     color: #3f83e8;
     line-height: 140%;
 }

+ 1 - 1
app/src/main/java/io/legado/app/api/controller/BookController.kt

@@ -82,7 +82,7 @@ object BookController {
         this.bookUrl = bookUrl
         val bitmap = runBlocking {
             ImageProvider.cacheImage(book, src, bookSource)
-            ImageProvider.getImage(book, src, width)
+            ImageProvider.getImage(book, src, width)!!
         }
         return returnData.setData(bitmap)
     }

+ 14 - 4
app/src/main/java/io/legado/app/help/AppWebDav.kt

@@ -17,6 +17,7 @@ import io.legado.app.lib.webdav.Authorization
 import io.legado.app.lib.webdav.WebDav
 import io.legado.app.lib.webdav.WebDavException
 import io.legado.app.lib.webdav.WebDavFile
+import io.legado.app.ui.widget.dialog.WaitDialog
 import io.legado.app.utils.*
 import kotlinx.coroutines.*
 import kotlinx.coroutines.Dispatchers.IO
@@ -109,10 +110,19 @@ object AppWebDav {
                     items = names
                 ) { _, index ->
                     if (index in 0 until names.size) {
-                        Coroutine.async {
+                        val waitDialog = WaitDialog(context)
+                        waitDialog.setText("恢复中…")
+                        waitDialog.show()
+                        val task = Coroutine.async {
                             restoreWebDav(names[index])
                         }.onError {
+                            AppLog.put("WebDav恢复出错\n${it.localizedMessage}", it)
                             appCtx.toastOnUi("WebDav恢复出错\n${it.localizedMessage}")
+                        }.onFinally(Main) {
+                            waitDialog.dismiss()
+                        }
+                        waitDialog.setOnCancelListener {
+                            task.cancel()
                         }
                     }
                 }
@@ -186,7 +196,7 @@ object AppWebDav {
             }
         } catch (e: Exception) {
             val msg = "WebDav导出\n${e.localizedMessage}"
-            AppLog.put(msg)
+            AppLog.put(msg, e)
             appCtx.toastOnUi(msg)
         }
     }
@@ -201,7 +211,7 @@ object AppWebDav {
             val url = getProgressUrl(book.name, book.author)
             WebDav(url, authorization).upload(json.toByteArray(), "application/json")
         }.onError {
-            AppLog.put("上传进度失败\n${it.localizedMessage}")
+            AppLog.put("上传进度失败\n${it.localizedMessage}", it)
         }
     }
 
@@ -214,7 +224,7 @@ object AppWebDav {
             val url = getProgressUrl(bookProgress.name, bookProgress.author)
             WebDav(url, authorization).upload(json.toByteArray(), "application/json")
         }.onError {
-            AppLog.put("上传进度失败\n${it.localizedMessage}")
+            AppLog.put("上传进度失败\n${it.localizedMessage}", it)
         }
     }
 

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

@@ -232,7 +232,7 @@ open class WebDav(val path: String, val authorization: Authorization) {
                 }
             }
         }.onFailure {
-            AppLog.put("WebDav创建目录失败\n${it.localizedMessage}")
+            AppLog.put("WebDav创建目录失败\n${it.localizedMessage}", it)
         }.isSuccess
     }
 
@@ -286,6 +286,7 @@ open class WebDav(val path: String, val authorization: Authorization) {
                 checkResult(it)
             }
         }.onFailure {
+            AppLog.put("WebDav上传失败\n${it.localizedMessage}", it)
             throw WebDavException("WebDav上传失败\n${it.localizedMessage}")
         }
     }
@@ -303,12 +304,13 @@ open class WebDav(val path: String, val authorization: Authorization) {
                 checkResult(it)
             }
         }.onFailure {
+            AppLog.put("WebDav上传失败\n${it.localizedMessage}", it)
             throw WebDavException("WebDav上传失败\n${it.localizedMessage}")
         }
     }
 
     @Throws(WebDavException::class)
-    private suspend fun downloadInputStream(): InputStream {
+    suspend fun downloadInputStream(): InputStream {
         val url = httpUrl ?: throw WebDavException("WebDav下载出错\nurl为空")
         val byteStream = webDavClient.newCallResponse {
             url(url)
@@ -332,7 +334,7 @@ open class WebDav(val path: String, val authorization: Authorization) {
                 checkResult(it)
             }
         }.onFailure {
-            AppLog.put("WebDav删除失败\n${it.localizedMessage}")
+            AppLog.put("WebDav删除失败\n${it.localizedMessage}", it)
         }.isSuccess
     }
 

+ 48 - 0
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -23,6 +23,8 @@ import kotlinx.coroutines.runBlocking
 import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.RequestBody.Companion.toRequestBody
 import okhttp3.Response
+import java.io.ByteArrayInputStream
+import java.io.InputStream
 import java.net.URLEncoder
 import java.util.regex.Pattern
 
@@ -497,6 +499,52 @@ class AnalyzeUrl(
         }
     }
 
+    /**
+     * 访问网站,返回InputStream
+     */
+    suspend fun getInputStreamAwait(): InputStream {
+        val concurrentRecord = fetchStart()
+
+        @Suppress("RegExpRedundantEscape")
+        val dataUriFindResult = dataUriRegex.find(urlNoQuery)
+        @Suppress("BlockingMethodInNonBlockingContext")
+        if (dataUriFindResult != null) {
+            val dataUriBase64 = dataUriFindResult.groupValues[1]
+            val byteArray = Base64.decode(dataUriBase64, Base64.DEFAULT)
+            fetchEnd(concurrentRecord)
+            return ByteArrayInputStream(byteArray)
+        } else {
+            setCookie(source?.getKey())
+            val inputStream = getProxyClient(proxy).newCallResponseBody(retry) {
+                addHeaders(headerMap)
+                when (method) {
+                    RequestMethod.POST -> {
+                        url(urlNoQuery)
+                        val contentType = headerMap["Content-Type"]
+                        val body = body
+                        if (fieldMap.isNotEmpty() || body.isNullOrBlank()) {
+                            postForm(fieldMap, true)
+                        } else if (!contentType.isNullOrBlank()) {
+                            val requestBody = body.toRequestBody(contentType.toMediaType())
+                            post(requestBody)
+                        } else {
+                            postJson(body)
+                        }
+                    }
+                    else -> get(urlNoQuery, fieldMap, true)
+                }
+            }.byteStream()
+            fetchEnd(concurrentRecord)
+            return inputStream
+        }
+    }
+
+    fun getInputStream(): InputStream {
+        return runBlocking {
+            getInputStreamAwait()
+        }
+    }
+
     /**
      * 上传文件
      */

+ 3 - 0
app/src/main/java/io/legado/app/model/localBook/EpubFile.kt

@@ -3,6 +3,7 @@ package io.legado.app.model.localBook
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.text.TextUtils
+import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.help.BookHelp
@@ -95,6 +96,7 @@ class EpubFile(var book: Book) {
                 }
             }
         } catch (e: Exception) {
+            AppLog.put("加载书籍封面失败\n${e.localizedMessage}", e)
             e.printOnDebug()
         }
     }
@@ -108,6 +110,7 @@ class EpubFile(var book: Book) {
             val zipFile = BookHelp.getEpubFile(book)
             EpubReader().readEpubLazy(zipFile, "utf-8")
         }.onFailure {
+            AppLog.put("读取Epub文件失败\n${it.localizedMessage}", it)
             it.printOnDebug()
         }.getOrNull()
     }

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

@@ -235,8 +235,8 @@ object LocalBook {
         AppConfig.defaultBookTreeUri
             ?: throw NoStackTraceException("没有设置书籍保存位置!")
         val bytes = when {
-            str.isAbsUrl() -> AnalyzeUrl(str, source = source).getByteArray()
-            str.isDataUrl() -> Base64.decode(str.substringAfter("base64,"), Base64.DEFAULT)
+            str.isAbsUrl() -> AnalyzeUrl(str, source = source).getInputStream()
+            str.isDataUrl() -> ByteArrayInputStream(Base64.decode(str.substringAfter("base64,"), Base64.DEFAULT))
             else -> throw NoStackTraceException("在线导入书籍支持http/https/DataURL")
         }
         return saveBookFile(bytes, fileName)
@@ -257,7 +257,7 @@ object LocalBook {
     }
 
     fun saveBookFile(
-        bytes: ByteArray,
+        inputStream: InputStream,
         fileName: String
     ): Uri {
         val defaultBookTreeUri = AppConfig.defaultBookTreeUri
@@ -271,14 +271,14 @@ object LocalBook {
                     ?: throw SecurityException("Permission Denial")
             }
             appCtx.contentResolver.openOutputStream(doc.uri)!!.use { oStream ->
-                oStream.write(bytes)
+                inputStream.copyTo(oStream)
             }
             doc.uri
         } else {
             val treeFile = File(treeUri.path!!)
             val file = treeFile.getFile(fileName)
             FileOutputStream(file).use { oStream ->
-                oStream.write(bytes)
+                inputStream.copyTo(oStream)
             }
             Uri.fromFile(file)
         }

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

@@ -199,6 +199,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
                 alertSync?.invoke(progress)
             } else {
                 ReadBook.setProgress(progress)
+                context.toastOnUi("自动同步阅读进度成功")
             }
         }
 

+ 36 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -7,6 +7,7 @@ import android.graphics.RectF
 import android.util.AttributeSet
 import android.view.View
 import io.legado.app.R
+import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.help.config.AppConfig
@@ -44,6 +45,10 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     val selectEnd = TextPos(0, 0, 0)
     var textPage: TextPage = TextPage()
         private set
+    private var drawVisibleImageOnly = false
+    private var cacheIncreased = false
+    private val increaseSize = 8 * 1024 * 1024
+    private val maxCacheSize = 256 * 1024 * 1024
 
     //滚动参数
     private val pageFactory: TextPageFactory get() = callBack.pageFactory
@@ -86,6 +91,8 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
         super.onDraw(canvas)
         canvas.clipRect(visibleRect)
         drawPage(canvas)
+        drawVisibleImageOnly = false
+        cacheIncreased = false
     }
 
     /**
@@ -173,12 +180,40 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     ) {
 
         val book = ReadBook.book ?: return
+        val isVisible = when {
+            lineTop > 0 -> lineTop < height
+            lineTop < 0 -> lineBottom > 0
+            else -> true
+        }
+        if (drawVisibleImageOnly && !isVisible) {
+            return
+        }
+        if (drawVisibleImageOnly &&
+            isVisible &&
+            !cacheIncreased &&
+            ImageProvider.isTriggerRecycled() &&
+            !ImageProvider.isImageAlive(book, textChar.charData)
+        ) {
+            val newSize = ImageProvider.bitmapLruCache.maxSize() + increaseSize
+            if (newSize < maxCacheSize) {
+                ImageProvider.bitmapLruCache.resize(newSize)
+                AppLog.put("图片缓存不够大,自动扩增至${(newSize / 1024 / 1024)}MB。")
+                cacheIncreased = true
+            }
+            return
+        }
         val bitmap = ImageProvider.getImage(
             book,
             textChar.charData,
             (textChar.end - textChar.start).toInt(),
             (lineBottom - lineTop).toInt()
-        )
+        ) {
+            if (!drawVisibleImageOnly && isVisible) {
+                drawVisibleImageOnly = true
+                invalidate()
+            }
+        } ?: return
+
         val rectF = if (textLine.isImage) {
             RectF(textChar.start, lineTop, textChar.end, lineBottom)
         } else {

+ 47 - 4
app/src/main/java/io/legado/app/ui/book/read/page/provider/ImageProvider.kt

@@ -6,15 +6,18 @@ import android.util.Size
 import androidx.collection.LruCache
 import io.legado.app.R
 import io.legado.app.constant.AppLog.putDebug
+import io.legado.app.constant.PageAnim
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.BookHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.ReadBook
 import io.legado.app.model.localBook.EpubFile
 import io.legado.app.utils.*
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import java.io.File
@@ -32,6 +35,7 @@ object ImageProvider {
      */
     private const val M = 1024 * 1024
     val cacheSize get() = AppConfig.bitmapCacheSize * M
+    var triggerRecycled = false
     val bitmapLruCache = object : LruCache<String, Bitmap>(cacheSize) {
 
         override fun sizeOf(filePath: String, bitmap: Bitmap): Int {
@@ -47,6 +51,7 @@ object ImageProvider {
             //错误图片不能释放,占位用,防止一直重复获取图片
             if (oldBitmap != errorBitmap) {
                 oldBitmap.recycle()
+                triggerRecycled = true
                 putDebug("ImageProvider: trigger bitmap recycle. URI: $filePath")
                 putDebug("ImageProvider : cacheUsage ${size()}bytes / ${maxSize()}bytes")
             }
@@ -110,12 +115,13 @@ object ImageProvider {
         book: Book,
         src: String,
         width: Int,
-        height: Int? = null
-    ): Bitmap {
+        height: Int? = null,
+        block: (() -> Unit)? = null
+    ): Bitmap? {
         //src为空白时 可能被净化替换掉了 或者规则失效
         if (book.getUseReplaceRule() && src.isBlank()) {
-           book.setUseReplaceRule(false)
-           appCtx.toastOnUi(R.string.error_image_url_empty)
+            book.setUseReplaceRule(false)
+            appCtx.toastOnUi(R.string.error_image_url_empty)
         }
         val vFile = BookHelp.getImage(book, src)
         if (!vFile.exists()) return errorBitmap
@@ -123,6 +129,30 @@ object ImageProvider {
         //bitmapLruCache的key同一改成缓存文件的路径
         val cacheBitmap = bitmapLruCache.get(vFile.absolutePath)
         if (cacheBitmap != null) return cacheBitmap
+        if (height != null && ReadBook.pageAnim() == PageAnim.scrollPageAnim) {
+            Coroutine.async {
+                kotlin.runCatching {
+                    val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
+                        ?: throw NoStackTraceException(appCtx.getString(R.string.error_decode_bitmap))
+                    withContext(Main) {
+                        bitmapLruCache.put(vFile.absolutePath, bitmap)
+                    }
+                }.onFailure {
+                    //错误图片占位,防止重复获取
+                    withContext(Main) {
+                        bitmapLruCache.put(vFile.absolutePath, errorBitmap)
+                    }
+                    putDebug(
+                        "ImageProvider: decode bitmap failed. path: ${vFile.absolutePath}\n$it",
+                        it
+                    )
+                }
+                withContext(Main) {
+                    block?.invoke()
+                }
+            }
+            return null
+        }
         @Suppress("BlockingMethodInNonBlockingContext")
         return kotlin.runCatching {
             val bitmap = BitmapUtils.decodeBitmap(vFile.absolutePath, width, height)
@@ -139,4 +169,17 @@ object ImageProvider {
         }.getOrDefault(errorBitmap)
     }
 
+    fun isImageAlive(book: Book, src: String): Boolean {
+        val vFile = BookHelp.getImage(book, src)
+        if (!vFile.exists()) return true // 使用 errorBitmap
+        val cacheBitmap = bitmapLruCache.get(vFile.absolutePath)
+        return cacheBitmap != null
+    }
+
+    fun isTriggerRecycled(): Boolean {
+        val tmp = triggerRecycled
+        triggerRecycled = false
+        return tmp
+    }
+
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/remote/manager/RemoteBookWebDav.kt

@@ -76,8 +76,8 @@ object RemoteBookWebDav : RemoteBookManager() {
     override suspend fun getRemoteBook(remoteBook: RemoteBook): Uri? {
         return AppWebDav.authorization?.let {
             val webdav = WebDav(remoteBook.path, it)
-            webdav.download().let { bytes ->
-                LocalBook.saveBookFile(bytes, remoteBook.filename)
+            webdav.downloadInputStream().let { inputStream ->
+                LocalBook.saveBookFile(inputStream, remoteBook.filename)
             }
         }
     }

+ 1 - 1
app/src/main/java/io/legado/app/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[^>]*src *= *\"([^\"{]*\\{(?:[^{}]|\\{[^}]+\\})+\\})\"[^>]*>|<img[^>]*data-[^=]*= *\"([^\"]*)\"[^>]*>|<img[^>]*src *= *\"([^\"]*)\"[^>]*>",
+        "<img[^>]*src *= *\"([^\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})\"[^>]*>|<img[^>]*data-[^=>]*= *\"([^\">]*)\"[^>]*>|<img[^>]*src *= *\"([^\">]*)\"[^>]*>",
         Pattern.CASE_INSENSITIVE
     )
 

+ 24 - 3
app/src/main/java/io/legado/app/web/HttpServer.kt

@@ -7,9 +7,11 @@ import io.legado.app.api.ReturnData
 import io.legado.app.api.controller.BookController
 import io.legado.app.api.controller.BookSourceController
 import io.legado.app.api.controller.RssSourceController
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.externalFiles
 import io.legado.app.web.utils.AssetsWeb
-import java.io.ByteArrayInputStream
-import java.io.ByteArrayOutputStream
+import splitties.init.appCtx
+import java.io.*
 
 
 class HttpServer(port: Int) : NanoHTTPD(port) {
@@ -91,7 +93,26 @@ class HttpServer(port: Int) : NanoHTTPD(port) {
                     byteArray.size.toLong()
                 )
             } else {
-                newFixedLengthResponse(Gson().toJson(returnData))
+                try {
+                    newFixedLengthResponse(Gson().toJson(returnData))
+                } catch (e: OutOfMemoryError) {
+                    val path = FileUtils.getPath(
+                        appCtx.externalFiles,
+                        "book_cache",
+                        "bookSources.json"
+                    )
+                    val file = FileUtils.createFileIfNotExist(path)
+                    BufferedWriter(FileWriter(file)).use {
+                        Gson().toJson(returnData, it)
+                    }
+                    val fis = FileInputStream(file)
+                    newFixedLengthResponse(
+                        Response.Status.OK,
+                        "application/json",
+                        fis,
+                        fis.available().toLong()
+                    )
+                }
             }
             response.addHeader("Access-Control-Allow-Methods", "GET, POST")
             response.addHeader("Access-Control-Allow-Origin", session.headers["origin"])