This commit is contained in:
kunfei
2023-07-22 17:59:03 +08:00
parent 20bdcf8e0c
commit aed91e2dd0
7 changed files with 982 additions and 927 deletions
+1
View File
@@ -417,6 +417,7 @@
<service android:name=".service.CheckSourceService" />
<service android:name=".service.CacheBookService" />
<service android:name=".service.ExportBookService" />
<service android:name=".service.WebService" />
<service
android:name=".service.WebTileService"
@@ -30,4 +30,5 @@ object EventBus {
const val UPDATE_READ_ACTION_BAR = "updateReadActionBar"
const val UP_SEEK_BAR = "upSeekBar"
const val READ_ALOUD_PLAY = "readAloudPlay"
const val EXPORT_BOOK = "exportBook"
}
@@ -26,6 +26,9 @@ import splitties.init.appCtx
import java.util.concurrent.Executors
import kotlin.math.min
/**
* 缓存书籍服务
*/
class CacheBookService : BaseService() {
companion object {
@@ -75,6 +78,7 @@ class CacheBookService : BaseService() {
intent.getIntExtra("start", 0),
intent.getIntExtra("end", 0)
)
IntentAction.remove -> removeDownload(intent.getStringExtra("bookUrl"))
IntentAction.stop -> stopSelf()
}
@@ -0,0 +1,930 @@
package io.legado.app.service
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.ArraySet
import androidx.core.app.NotificationCompat
import androidx.documentfile.provider.DocumentFile
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import io.legado.app.R
import io.legado.app.base.BaseService
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.exception.NoStackTraceException
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.getExportFileName
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.OrderCoroutine
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.utils.DocumentUtils
import io.legado.app.utils.FileUtils
import io.legado.app.utils.HtmlFormatter
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.isContentScheme
import io.legado.app.utils.postEvent
import io.legado.app.utils.readBytes
import io.legado.app.utils.readText
import io.legado.app.utils.servicePendingIntent
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.writeBytes
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.ag2s.epublib.domain.Author
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.Metadata
import me.ag2s.epublib.domain.Resource
import me.ag2s.epublib.epub.EpubWriter
import me.ag2s.epublib.epub.EpubWriterProcessor
import me.ag2s.epublib.util.ResourceUtil
import splitties.init.appCtx
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
/**
* 导出书籍服务
*/
class ExportBookService : BaseService() {
companion object {
val exportProgress = ConcurrentHashMap<String, Int>()
val exportMsg = ConcurrentHashMap<String, String>()
}
data class ExportConfig(
val path: String,
val type: String,
val epubSize: Int = 1,
val epubScope: String? = null
)
private val waitExportBooks = linkedMapOf<String, ExportConfig>()
private var exportJob: Job? = null
private var notificationContent = appCtx.getString(R.string.service_starting)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
IntentAction.start -> kotlin.runCatching {
val bookUrl = intent.getStringExtra("bookUrl")!!
if (!exportProgress.contains(bookUrl)) {
val exportConfig = ExportConfig(
path = intent.getStringExtra("exportPath")!!,
type = intent.getStringExtra("exportType")!!,
epubSize = intent.getIntExtra("epubSize", 1),
epubScope = intent.getStringExtra("epubScope")
)
waitExportBooks[bookUrl] = exportConfig
export()
}
}.onFailure {
toastOnUi(it.localizedMessage)
}
IntentAction.stop -> stopSelf()
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
exportProgress.clear()
exportMsg.clear()
}
override fun upNotification() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export)
.setOngoing(true)
.setContentTitle(getString(R.string.export))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity"))
notification.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
servicePendingIntent<ExportBookService>(IntentAction.stop)
)
notification.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
notification.setContentText(notificationContent)
startForeground(AppConst.notificationIdCache, notification.build())
}
private fun export() {
if (exportJob?.isActive == true) {
return
}
val entry = waitExportBooks.firstNotNullOfOrNull { it }
if (entry == null) {
notificationContent = "导出完成"
upNotification()
return
}
val bookUrl = entry.key
val exportConfig = entry.value
if (exportProgress.contains(bookUrl)) return
exportProgress[bookUrl] = 0
waitExportBooks.remove(bookUrl)
exportJob = launch(IO) {
val book = appDb.bookDao.getBook(bookUrl)
try {
book ?: throw NoStackTraceException("获取${bookUrl}书籍出错")
notificationContent = "正在导出(${book.name}),还有${waitExportBooks.size}本待导出"
if (exportConfig.type == "epub") {
if (exportConfig.epubScope.isNullOrBlank()) {
exportEPUB(exportConfig.path, book)
} else {
CustomExporter(paresScope(exportConfig.epubScope), exportConfig.epubSize)
.export(exportConfig.path, book)
}
} else {
export(exportConfig.path, book)
}
exportMsg[book.bookUrl] = getString(R.string.export_success)
} catch (e: Throwable) {
exportMsg[bookUrl] = e.localizedMessage ?: "ERROR"
AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
} finally {
exportProgress.remove(bookUrl)
postEvent(EventBus.EXPORT_BOOK, bookUrl)
}
withContext(Main) {
export()
}
}
}
private suspend fun export(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)
}
}
private suspend fun export(doc: DocumentFile, book: Book) {
val filename = book.getExportFileName("txt")
DocumentUtils.delete(doc, filename)
val bookDoc = DocumentUtils.createFileIfNotExist(doc, filename)
?: throw NoStackTraceException("创建文档失败,请尝试重新设置导出文件夹")
contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
getAllContents(book) { text, srcList ->
bookOs.write(text.toByteArray(Charset.forName(AppConfig.exportCharset)))
srcList?.forEach {
val vFile = BookHelp.getImage(book, it.third)
if (vFile.exists()) {
DocumentUtils.createFileIfNotExist(
doc,
"${it.second}-${MD5Utils.md5Encode16(it.third)}.jpg",
subDirs = arrayOf("${book.name}_${book.author}", "images", it.first)
)?.writeBytes(this, vFile.readBytes())
}
}
}
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
private suspend fun export(file: File, book: Book) {
val filename = book.getExportFileName("txt")
val bookPath = FileUtils.getPath(file, filename)
val bookFile = FileUtils.createFileWithReplace(bookPath)
getAllContents(book) { text, srcList ->
bookFile.appendText(text, Charset.forName(AppConfig.exportCharset))
srcList?.forEach {
val vFile = BookHelp.getImage(book, it.third)
if (vFile.exists()) {
FileUtils.createFileIfNotExist(
file,
"${book.name}_${book.author}",
"images",
it.first,
"${it.second}-${MD5Utils.md5Encode16(it.third)}.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<Triple<String, Int, String>>?) -> Unit
) {
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val qy = "${book.name}\n${
getString(R.string.author_show, book.getRealAuthor())
}\n${
getString(
R.string.intro_show,
"\n" + HtmlFormatter.format(book.getDisplayIntro())
)
}"
append(qy, null)
if (AppConfig.parallelExportBook) {
val oc =
OrderCoroutine<Pair<String, ArrayList<Triple<String, Int, String>>?>>(AppConfig.threadCount)
appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
oc.submit { getExportData(book, chapter, contentProcessor, useReplace) }
}
oc.collect { index, result ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second)
}
} else {
appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
kotlin.coroutines.coroutineContext.ensureActive()
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
val result = getExportData(book, chapter, contentProcessor, useReplace)
append.invoke(result.first, result.second)
}
}
}
private fun getExportData(
book: Book,
chapter: BookChapter,
contentProcessor: ContentProcessor,
useReplace: Boolean
): Pair<String, ArrayList<Triple<String, Int, String>>?> {
BookHelp.getContent(book, chapter).let { content ->
val content1 = contentProcessor
.getContent(
book,
// 不导出vip标识
chapter.apply { isVip = false },
content ?: if (chapter.isVolume) "" else "null",
includeTitle = !AppConfig.exportNoChapterName,
useReplace = useReplace,
chineseConvert = false,
reSegment = false
).toString()
if (AppConfig.exportPictureFile) {
//txt导出图片文件
val srcList = arrayListOf<Triple<String, Int, String>>()
content?.split("\n")?.forEachIndexed { index, text ->
val matcher = AppPattern.imgPattern.matcher(text)
while (matcher.find()) {
matcher.group(1)?.let {
val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
srcList.add(Triple(chapter.title, index, src))
}
}
}
return Pair("\n\n$content1", srcList)
} else {
return Pair("\n\n$content1", null)
}
}
}
/**
* 解析范围字符串
*
* @param scope 范围字符串
* @return 范围
*
* @since 2023/5/22
* @author Discut
*/
private fun paresScope(scope: String): IntArray {
val split = scope.split(",")
val result = ArraySet<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