This commit is contained in:
Horis
2025-02-22 15:37:21 +08:00
parent 3d9744ed2e
commit f6bd4a1704
7 changed files with 139 additions and 274 deletions
@@ -12,7 +12,6 @@ import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.isDataUrl
import io.legado.app.utils.lifecycle
import splitties.init.appCtx
import java.io.File
//https://bumptech.github.io/glide/doc/generatedapi.html
@@ -37,8 +36,8 @@ object ImageLoader {
}
}
fun load(lifecycle: Lifecycle, path: String?): RequestBuilder<Drawable> {
val requestManager = Glide.with(appCtx).lifecycle(lifecycle)
fun load(context: Context, lifecycle: Lifecycle, path: String?): RequestBuilder<Drawable> {
val requestManager = Glide.with(context).lifecycle(lifecycle)
return when {
path.isNullOrEmpty() -> requestManager.load(path)
path.isDataUrl() -> requestManager.load(path)
@@ -2,12 +2,7 @@ package io.legado.app.service
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.ArraySet
import androidx.core.app.NotificationCompat
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import io.legado.app.R
@@ -31,7 +26,6 @@ import io.legado.app.help.book.isLocalModified
import io.legado.app.help.config.AppConfig
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.utils.DocumentUtils
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.HtmlFormatter
@@ -39,21 +33,19 @@ import io.legado.app.utils.MD5Utils
import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.cnCompare
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.delete
import io.legado.app.utils.find
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.list
import io.legado.app.utils.mapAsync
import io.legado.app.utils.mapAsyncIndexed
import io.legado.app.utils.outputStream
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.postEvent
import io.legado.app.utils.servicePendingIntent
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.writeBytes
import kotlinx.coroutines.Deferred
import io.legado.app.utils.writeFile
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collectIndexed
@@ -65,6 +57,7 @@ import me.ag2s.epublib.domain.Date
import me.ag2s.epublib.domain.EpubBook
import me.ag2s.epublib.domain.FileResourceProvider
import me.ag2s.epublib.domain.LazyResource
import me.ag2s.epublib.domain.LazyResourceProvider
import me.ag2s.epublib.domain.Metadata
import me.ag2s.epublib.domain.Resource
import me.ag2s.epublib.domain.TOCReference
@@ -73,8 +66,6 @@ import me.ag2s.epublib.epub.EpubWriterProcessor
import me.ag2s.epublib.util.ResourceUtil
import splitties.init.appCtx
import splitties.systemservices.notificationManager
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.coroutineContext
@@ -197,15 +188,15 @@ class ExportBookService : BaseService() {
upExportNotification()
if (exportConfig.type == "epub") {
if (exportConfig.epubScope.isNullOrBlank()) {
exportEPUB(exportConfig.path, book)
exportEpub(exportConfig.path, book)
} else {
CustomExporter(
paresScope(exportConfig.epubScope),
exportConfig.epubScope,
exportConfig.epubSize
).export(exportConfig.path, book)
}
} else {
export(exportConfig.path, book)
exportTxt(exportConfig.path, book)
}
exportMsg[book.bookUrl] = getString(R.string.export_success)
} catch (e: Throwable) {
@@ -239,40 +230,33 @@ class ExportBookService : BaseService() {
val src: String
)
private suspend fun export(path: String, book: Book) {
private suspend fun exportTxt(path: String, book: Book) {
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
if (path.isContentScheme()) {
val uri = Uri.parse(path)
val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
?: throw NoStackTraceException("获取导出文档失败")
export(doc, book)
} else {
export(File(path).createFolderIfNotExist(), book)
}
val fileDoc = FileDoc.fromDir(path)
exportTxt(fileDoc, book)
}
private suspend fun export(doc: DocumentFile, book: Book) {
private suspend fun exportTxt(fileDoc: FileDoc, book: Book) {
val filename = book.getExportFileName("txt")
DocumentUtils.delete(doc, filename)
val bookDoc = DocumentUtils.createFileIfNotExist(doc, filename)
?: throw NoStackTraceException("创建文档失败,请尝试重新设置导出文件夹")
fileDoc.find(filename)?.delete()
val bookDoc = fileDoc.createFileIfNotExist(filename)
val charset = Charset.forName(AppConfig.exportCharset)
contentResolver.openOutputStream(bookDoc.uri, "wa")?.bufferedWriter(charset)?.use { bw ->
bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw ->
getAllContents(book) { text, srcList ->
bw.write(text)
srcList?.forEach {
val vFile = BookHelp.getImage(book, it.src)
if (vFile.exists()) {
DocumentUtils.createFileIfNotExist(
doc,
fileDoc.createFileIfNotExist(
"${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
subDirs = arrayOf(
"${book.name}_${book.author}",
"images",
it.chapterTitle
)
)?.writeBytes(this, vFile.readBytes())
).writeFile(vFile)
}
}
}
@@ -283,34 +267,6 @@ class ExportBookService : BaseService() {
}
}
private suspend fun export(file: File, book: Book) {
val filename = book.getExportFileName("txt")
val bookPath = FileUtils.getPath(file, filename)
val bookFile = FileUtils.createFileWithReplace(bookPath)
val charset = Charset.forName(AppConfig.exportCharset)
val bw = bookFile.outputStream(true).bufferedWriter(charset)
bw.use {
getAllContents(book) { text, srcList ->
it.write(text)
srcList?.forEach {
val vFile = BookHelp.getImage(book, it.src)
if (vFile.exists()) {
FileUtils.createFileIfNotExist(
file,
"${book.name}_${book.author}",
"images",
it.chapterTitle,
"${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg"
).writeBytes(vFile.readBytes())
}
}
}
}
if (AppConfig.exportToWebDav) {
AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename) // 导出到webdav
}
}
private suspend fun getAllContents(
book: Book,
append: (text: String, srcList: ArrayList<SrcData>?) -> Unit
@@ -381,62 +337,20 @@ class ExportBookService : BaseService() {
}
}
/**
* 解析范围字符串
*
* @param scope 范围字符串
* @return 范围
*
* @since 2023/5/22
* @author Discut
*/
@SuppressLint("ObsoleteSdkInt")
private fun paresScope(scope: String): Set<Int> {
val split = scope.split(",")
@Suppress("RemoveExplicitTypeArguments")
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ArraySet<Int>()
} else {
HashSet<Int>()
}
for (s in split) {
val v = s.split("-")
if (v.size != 2) {
result.add(s.toInt() - 1)
continue
}
val left = v[0].toInt()
val right = v[1].toInt()
if (left > right) {
AppLog.put("Error expression : $s; left > right")
continue
}
for (i in left..right)
result.add(i - 1)
}
return result
}
/**
* 导出Epub
*/
private suspend fun exportEPUB(path: String, book: Book) {
private suspend fun exportEpub(path: String, book: Book) {
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
if (path.isContentScheme()) {
val uri = Uri.parse(path)
val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
?: throw NoStackTraceException("获取导出文档失败")
exportEpub(doc, book)
} else {
exportEpub(File(path).createFolderIfNotExist(), book)
}
val fileDoc = FileDoc.fromDir(path)
exportEpub(fileDoc, book)
}
private suspend fun exportEpub(doc: DocumentFile, book: Book) {
private suspend fun exportEpub(fileDoc: FileDoc, book: Book) {
val filename = book.getExportFileName("epub")
DocumentUtils.delete(doc, filename)
fileDoc.find(filename)?.delete()
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
@@ -444,54 +358,22 @@ class ExportBookService : BaseService() {
//set cover
setCover(book, epubBook)
//set css
val contentModel = setAssets(doc, book, epubBook)
val contentModel = setAssets(fileDoc, book, epubBook)
//设置正文
setEpubContent(contentModel, book, epubBook)
DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
contentResolver.openOutputStream(bookDoc.uri, "wa")?.buffered().use { bookOs ->
EpubWriter().write(epubBook, bookOs)
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
val bookDoc = fileDoc.createFileIfNotExist(filename)
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
EpubWriter().write(epubBook, bookOs)
}
}
private suspend fun exportEpub(file: File, book: Book) {
val filename = book.getExportFileName("epub")
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
setEpubMetadata(book, epubBook)
//set cover
setCover(book, epubBook)
//set css
val contentModel = setAssets(file, book, epubBook)
val bookPath = FileUtils.getPath(file, filename)
val bookFile = FileUtils.createFileWithReplace(bookPath)
//设置正文
setEpubContent(contentModel, book, epubBook)
bookFile.outputStream().buffered().use {
EpubWriter().write(epubBook, it)
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String {
return setAssets(FileDoc.fromDocumentFile(doc), book, epubBook)
}
private fun setAssets(file: File, book: Book, epubBook: EpubBook): String {
return setAssets(FileDoc.fromFile(file), book, epubBook)
}
private fun setAssets(doc: FileDoc, book: Book, epubBook: EpubBook): String {
val customPath = doc.find("Asset")
val contentModel = if (customPath == null) {//使用内置模板
@@ -618,16 +500,15 @@ class ExportBookService : BaseService() {
private fun setCover(book: Book, epubBook: EpubBook) {
kotlin.runCatching {
val bitmap = Glide.with(this)
.asBitmap()
val file = Glide.with(this)
.asFile()
.load(book.getDisplayCover())
.submit()
.get()
val byteArray = ByteArrayOutputStream().use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it)
it.toByteArray()
val provider = LazyResourceProvider { _ ->
file.inputStream()
}
epubBook.coverImage = Resource(byteArray, "Images/cover.jpg")
epubBook.coverImage = LazyResource(provider, "Images/cover.jpg")
}.onFailure {
AppLog.put("获取书籍封面出错\n${it.localizedMessage}", it)
}
@@ -758,7 +639,9 @@ class ExportBookService : BaseService() {
* @param scope 导出范围
* @param size epub 文件包含最大章节数
*/
inner class CustomExporter(private var scope: Set<Int>, private val size: Int) {
inner class CustomExporter(scopeStr: String, private val size: Int) {
private var scope = parseScope(scopeStr)
/**
* 导出Epub
@@ -775,78 +658,35 @@ class ExportBookService : BaseService() {
val currentTimeMillis = System.currentTimeMillis()
val count = appDb.bookChapterDao.getChapterCount(book.bookUrl)
scope = scope.filter { it < count }.toHashSet()
when (path.isContentScheme()) {
true -> {
val uri = Uri.parse(path)
val doc = DocumentFile.fromTreeUri(this@ExportBookService, uri)
?: throw NoStackTraceException("获取导出文档失败")
val (contentModel, epubList) = createEpubs(book, doc)
val asyncBlocks = ArrayList<Deferred<Unit>>(epubList.size)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
coroutineScope {
val asyncBlock = async {
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
// 将章节写入内存时更新进度条
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
progressBar += book.totalChapterNum.toDouble() / scope.size / 2
exportProgress[book.bookUrl] = progressBar.toInt()
}
save2Drive(filename, epubBook, doc) { total, _ ->
//写入硬盘时更新进度条
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = progressBar.toInt()
}
}
asyncBlocks.add(asyncBlock)
}
}
asyncBlocks.forEach { it.await() }
}
false -> {
val file = File(path).createFolderIfNotExist()
val (contentModel, epubList) = createEpubs(book, null)
val asyncBlocks = ArrayList<Deferred<Unit>>(epubList.size)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
coroutineScope {
val asyncBlock = async {
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] =
exportProgress[book.bookUrl]?.plus(book.totalChapterNum / scope.size)
?: 1
}
save2Drive(filename, epubBook, file) { total, _ ->
//设置进度
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = progressBar.toInt()
}
}
asyncBlocks.add(asyncBlock)
}
}
asyncBlocks.forEach { it.await() }
val fileDoc = FileDoc.fromDir(path)
val (contentModel, epubList) = createEpubs(book, fileDoc)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
// 将章节写入内存时更新进度条
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
progressBar += book.totalChapterNum.toDouble() / scope.size / 2
exportProgress[book.bookUrl] = progressBar.toInt()
}
save2Drive(filename, epubBook, fileDoc) { total, _ ->
//写入硬盘时更新进度条
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = progressBar.toInt()
}
}
AppLog.put("分割导出书籍 ${book.name} 一共耗时 ${System.currentTimeMillis() - currentTimeMillis}")
val elapsed = System.currentTimeMillis() - currentTimeMillis
AppLog.put("分割导出书籍 ${book.name} 一共耗时 $elapsed")
}
@@ -878,7 +718,7 @@ class ExportBookService : BaseService() {
}
}
// val totalChapterNum = book.totalChapterNum / scope.size
if (chapterList.size == 0) {
if (chapterList.isEmpty()) {
throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
}
chapterList = chapterList.subList(
@@ -930,23 +770,22 @@ class ExportBookService : BaseService() {
* 创建多个epub 对象
*
* 分割epub时,一个书籍需要创建多个epub对象
* @param doc 导出文档
* @param book 书籍
* @param fileDoc 导出文件夹文档
*
* @return <内容模板字符串, <epub文件名, epub对象>>
*/
private fun createEpubs(
book: Book,
doc: DocumentFile?,
fileDoc: FileDoc
): Pair<String, List<Pair<String, EpubBook>>> {
val paresNumOfEpub = paresNumOfEpub(scope.size, size)
val result: MutableList<Pair<String, EpubBook>> = ArrayList(paresNumOfEpub)
var contentModel = ""
for (i in 1..paresNumOfEpub) {
val filename = book.getExportFileName("epub", i)
doc?.let {
DocumentUtils.delete(it, filename)
}
fileDoc.find(filename)?.delete()
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
@@ -954,9 +793,7 @@ class ExportBookService : BaseService() {
//set cover
setCover(book, epubBook)
//set css
contentModel = doc?.let {
setAssets(it, book, epubBook)
} ?: setAssets(book, epubBook)
contentModel = setAssets(fileDoc, book, epubBook)
// add epubBook
result.add(Pair(filename, epubBook))
@@ -970,47 +807,23 @@ class ExportBookService : BaseService() {
private suspend fun save2Drive(
filename: String,
epubBook: EpubBook,
doc: DocumentFile,
fileDoc: FileDoc,
callback: (total: Int, progress: Int) -> Unit
) {
DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
contentResolver.openOutputStream(bookDoc.uri, "wa")?.buffered().use { bookOs ->
EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, bookOs)
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
val bookDoc = fileDoc.createFileIfNotExist(filename)
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, bookOs)
}
}
/**
* 保存文件到 设备
*/
private suspend fun save2Drive(
filename: String,
epubBook: EpubBook,
file: File,
callback: (total: Int, progress: Int) -> Unit
) {
val bookPath = FileUtils.getPath(file, filename)
val bookFile = FileUtils.createFileWithReplace(bookPath)
EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, bookFile.outputStream().buffered())
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
@@ -1028,5 +841,36 @@ class ExportBookService : BaseService() {
}
return result
}
/**
* 解析范围字符串
*
* @param scope 范围字符串
* @return 范围
*
* @since 2023/5/22
* @author Discut
*/
private fun parseScope(scope: String): Set<Int> {
val split = scope.split(",")
val result = linkedSetOf<Int>()
for (s in split) {
val v = s.split("-")
if (v.size != 2) {
result.add(s.toInt() - 1)
continue
}
val left = v[0].toInt()
val right = v[1].toInt()
if (left > right) {
AppLog.put("Error expression : $s; left > right")
continue
}
for (i in left..right)
result.add(i - 1)
}
return result
}
}
}
@@ -89,7 +89,7 @@ class TextChapterLayout(
var exception: Throwable? = null
var channel = Channel<TextPage>(Int.MAX_VALUE)
var channel = Channel<TextPage>(Channel.UNLIMITED)
init {
@@ -32,7 +32,7 @@ class RssAdapter(context: Context, val callBack: CallBack, val lifecycle: Lifecy
tvName.text = item.sourceName
val options = RequestOptions()
.set(OkHttpModelLoader.sourceOriginOption, item.sourceUrl)
ImageLoader.load(lifecycle, item.sourceIcon)
ImageLoader.load(context, lifecycle, item.sourceIcon)
.apply(options)
.centerCrop()
.placeholder(R.drawable.image_rss)
@@ -198,7 +198,7 @@ class CoverImageView @JvmOverloads constructor(
options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
}
val builder = if (lifecycle != null) {
ImageLoader.load(lifecycle, path)
ImageLoader.load(context, lifecycle, path)
} else {
ImageLoader.load(context, path)//Glide自动识别http://,content://和file://
}
@@ -8,6 +8,7 @@ import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import io.legado.app.exception.NoStackTraceException
import splitties.init.appCtx
@@ -67,6 +68,10 @@ data class FileDoc(
companion object {
fun fromDir(path: String): FileDoc {
return fromUri(path.toUri(), true)
}
fun fromUri(uri: Uri, isDir: Boolean): FileDoc {
if (uri.isContentScheme()) {
val doc = if (isDir) {
@@ -284,6 +289,14 @@ fun FileDoc.writeText(text: String) {
}
}
fun FileDoc.writeFile(file: File) {
openOutputStream().getOrThrow().use { out ->
file.inputStream().use {
it.copyTo(out)
}
}
}
fun FileDoc.delete() {
asFile()?.let {
FileUtils.delete(it, true)
@@ -3,9 +3,18 @@ package io.legado.app.utils
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import splitties.init.appCtx
private val applicationRequestManager by lazy {
Glide.with(appCtx)
}
fun RequestManager.lifecycle(lifecycle: Lifecycle): RequestManager {
if (this === applicationRequestManager) {
return this
}
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) = onStart()