kunfei 3 years ago
parent
commit
aed91e2dd0

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

@@ -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"

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

@@ -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"
 }

+ 4 - 0
app/src/main/java/io/legado/app/service/CacheBookService.kt

@@ -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()
             }

+ 930 - 0
app/src/main/java/io/legado/app/service/ExportBookService.kt

@@ -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
+            }
+            for (i in left..right)
+                result.add(i - 1)
+        }
+        return result.toIntArray()
+    }
+
+    /**
+     * 导出Epub
+     */
+    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)
+        }
+    }
+
+    private suspend fun exportEpub(doc: DocumentFile, book: Book) {
+        val filename = book.getExportFileName("epub")
+        DocumentUtils.delete(doc, filename)
+        val epubBook = EpubBook()
+        epubBook.version = "2.0"
+        //set metadata
+        setEpubMetadata(book, epubBook)
+        //set cover
+        setCover(book, epubBook)
+        //set css
+        val contentModel = setAssets(doc, book, epubBook)
+
+        //设置正文
+        setEpubContent(contentModel, book, epubBook)
+        DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
+            contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
+                EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
+            }
+            if (AppConfig.exportToWebDav) {
+                // 导出到webdav
+                AppWebDav.exportWebDav(bookDoc.uri, filename)
+            }
+        }
+    }
+
+
+    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(book, epubBook)
+
+        val bookPath = FileUtils.getPath(file, filename)
+        val bookFile = FileUtils.createFileWithReplace(bookPath)
+        //设置正文
+        setEpubContent(contentModel, book, epubBook)
+        @Suppress("BlockingMethodInNonBlockingContext")
+        EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
+        if (AppConfig.exportToWebDav) {
+            // 导出到webdav
+            AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
+        }
+    }
+
+    private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String {
+        var contentModel = ""
+        DocumentUtils.getDirDocument(doc, "Asset").let { customPath ->
+            if (customPath == null) {//使用内置模板
+                contentModel = setAssets(book, epubBook)
+            } else {//外部模板
+                customPath.listFiles().forEach { folder ->
+                    if (folder.isDirectory && folder.name == "Text") {
+                        folder.listFiles().sortedWith { o1, o2 ->
+                            val name1 = o1.name ?: ""
+                            val name2 = o2.name ?: ""
+                            name1.cnCompare(name2)
+                        }.forEach { file ->
+                            if (file.isFile) {
+                                when {
+                                    //正文模板
+                                    file.name.equals("chapter.html", true)
+                                            || file.name.equals("chapter.xhtml", true) -> {
+                                        contentModel = file.readText(this)
+                                    }
+                                    //封面等其他模板
+                                    true == file.name?.endsWith("html", true) -> {
+                                        epubBook.addSection(
+                                            FileUtils.getNameExcludeExtension(
+                                                file.name ?: "Cover.html"
+                                            ),
+                                            ResourceUtil.createPublicResource(
+                                                book.name,
+                                                book.getRealAuthor(),
+                                                book.getDisplayIntro(),
+                                                book.kind,
+                                                book.wordCount,
+                                                file.readText(this),
+                                                "${folder.name}/${file.name}"
+                                            )
+                                        )
+                                    }
+
+                                    else -> {
+                                        //其他格式文件当做资源文件
+                                        folder.listFiles().forEach {
+                                            if (it.isFile)
+                                                epubBook.resources.add(
+                                                    Resource(
+                                                        it.readBytes(this),
+                                                        "${folder.name}/${it.name}"
+                                                    )
+                                                )
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    } else if (folder.isDirectory) {
+                        //资源文件
+                        folder.listFiles().forEach {
+                            if (it.isFile)
+                                epubBook.resources.add(
+                                    Resource(
+                                        it.readBytes(this),
+                                        "${folder.name}/${it.name}"
+                                    )
+                                )
+                        }
+                    } else {//Asset下面的资源文件
+                        epubBook.resources.add(
+                            Resource(
+                                folder.readBytes(this),
+                                "${folder.name}"
+                            )
+                        )
+                    }
+                }
+            }
+        }
+
+        return contentModel
+    }
+
+    private fun setAssets(book: Book, epubBook: EpubBook): String {
+        epubBook.resources.add(
+            Resource(
+                appCtx.assets.open("epub/fonts.css").readBytes(),
+                "Styles/fonts.css"
+            )
+        )
+        epubBook.resources.add(
+            Resource(
+                appCtx.assets.open("epub/main.css").readBytes(),
+                "Styles/main.css"
+            )
+        )
+        epubBook.resources.add(
+            Resource(
+                appCtx.assets.open("epub/logo.png").readBytes(),
+                "Images/logo.png"
+            )
+        )
+        epubBook.addSection(
+            getString(R.string.img_cover),
+            ResourceUtil.createPublicResource(
+                book.name,
+                book.getRealAuthor(),
+                book.getDisplayIntro(),
+                book.kind,
+                book.wordCount,
+                String(appCtx.assets.open("epub/cover.html").readBytes()),
+                "Text/cover.html"
+            )
+        )
+        epubBook.addSection(
+            getString(R.string.book_intro),
+            ResourceUtil.createPublicResource(
+                book.name,
+                book.getRealAuthor(),
+                book.getDisplayIntro(),
+                book.kind,
+                book.wordCount,
+                String(appCtx.assets.open("epub/intro.html").readBytes()),
+                "Text/intro.html"
+            )
+        )
+        return String(appCtx.assets.open("epub/chapter.html").readBytes())
+    }
+
+    private fun setCover(book: Book, epubBook: EpubBook) {
+        Glide.with(this)
+            .asBitmap()
+            .load(book.getDisplayCover())
+            .into(object : CustomTarget<Bitmap>() {
+                override fun onResourceReady(
+                    resource: Bitmap,
+                    transition: Transition<in Bitmap>?
+                ) {
+                    val stream = ByteArrayOutputStream()
+                    resource.compress(Bitmap.CompressFormat.JPEG, 100, stream)
+                    val byteArray: ByteArray = stream.toByteArray()
+                    stream.close()
+                    epubBook.coverImage = Resource(byteArray, "Images/cover.jpg")
+                }
+
+                override fun onLoadCleared(placeholder: Drawable?) {
+                }
+            })
+    }
+
+    private suspend fun setEpubContent(
+        contentModel: String,
+        book: Book,
+        epubBook: EpubBook
+    ) {
+        //正文
+        val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
+        val contentProcessor = ContentProcessor.get(book.name, book.origin)
+        appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
+            kotlin.coroutines.coroutineContext.ensureActive()
+            postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+            exportProgress[book.bookUrl] = index
+            BookHelp.getContent(book, chapter).let { content ->
+                var content1 = fixPic(
+                    epubBook,
+                    book,
+                    content ?: if (chapter.isVolume) "" else "null",
+                    chapter
+                )
+                content1 = contentProcessor
+                    .getContent(
+                        book,
+                        chapter,
+                        content1,
+                        includeTitle = false,
+                        useReplace = useReplace,
+                        chineseConvert = false,
+                        reSegment = false
+                    ).toString()
+                val title = chapter.run {
+                    // 不导出vip标识
+                    isVip = false
+                    getDisplayTitle(
+                        contentProcessor.getTitleReplaceRules(),
+                        useReplace = useReplace
+                    )
+                }
+                epubBook.addSection(
+                    title,
+                    ResourceUtil.createChapterResource(
+                        title.replace("\uD83D\uDD12", ""),
+                        content1,
+                        contentModel,
+                        "Text/chapter_${index}.html"
+                    )
+                )
+            }
+        }
+    }
+
+    private fun fixPic(
+        epubBook: EpubBook,
+        book: Book,
+        content: String,
+        chapter: BookChapter
+    ): String {
+        val data = StringBuilder("")
+        content.split("\n").forEach { text ->
+            var text1 = text
+            val matcher = AppPattern.imgPattern.matcher(text)
+            while (matcher.find()) {
+                matcher.group(1)?.let {
+                    val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
+                    val originalHref =
+                        "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
+                    val href =
+                        "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
+                    val vFile = BookHelp.getImage(book, src)
+                    val fp = FileResourceProvider(vFile.parent)
+                    if (vFile.exists()) {
+                        val img = LazyResource(fp, href, originalHref)
+                        epubBook.resources.add(img)
+                    }
+                    text1 = text1.replace(src, "../${href}")
+                }
+            }
+            data.append(text1).append("\n")
+        }
+        return data.toString()
+    }
+
+    private fun setEpubMetadata(book: Book, epubBook: EpubBook) {
+        val metadata = Metadata()
+        metadata.titles.add(book.name)//书籍的名称
+        metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者
+        metadata.language = "zh"//数据的语言
+        metadata.dates.add(Date())//数据的创建日期
+        metadata.publishers.add("Legado")//数据的创建者
+        metadata.descriptions.add(book.getDisplayIntro())//书籍的简介
+        //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍
+        epubBook.metadata = metadata
+    }
+
+    //////end of EPUB
+
+    //////start of custom exporter
+    /**
+     * 自定义Exporter
+     * @param scope 导出范围
+     * @param size epub 文件包含最大章节数
+     */
+    inner class CustomExporter(private val scope: IntArray, private val size: Int) {
+
+        /**
+         * 导出Epub
+         * @param path 导出的路径
+         * @param book 书籍
+         */
+        suspend fun export(
+            path: String,
+            book: Book
+        ) {
+            exportProgress[book.bookUrl] = 0
+            exportMsg.remove(book.bookUrl)
+            postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+            val currentTimeMillis = System.currentTimeMillis()
+            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
+                        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, progress ->
+                                //写入硬盘时更新进度条
+                                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
+                        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, progress ->
+                                //设置进度
+                                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() }
+                }
+            }
+            AppLog.put("分割导出书籍 ${book.name} 一共耗时 ${System.currentTimeMillis() - currentTimeMillis}")
+        }
+
+
+        /**
+         * 设置epub正文
+         *
+         * @param contentModel 正文模板
+         * @param book 书籍
+         * @param epubBook 分割后的epub
+         * @param epubBookIndex 分割后的epub序号
+         */
+        private fun setEpubContent(
+            contentModel: String,
+            book: Book,
+            epubBook: EpubBook,
+            epubBookIndex: Int,
+            updateProgress: (chapterList: MutableList<BookChapter>, index: Int) -> Unit
+        ) {
+            //正文
+            val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
+            val contentProcessor = ContentProcessor.get(book.name, book.origin)
+            var chapterList: MutableList<BookChapter> = ArrayList()
+            appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
+                if (scope.indexOf(index) >= 0) {
+                    chapterList.add(chapter)
+                }
+                if (scope.size == chapterList.size) {
+                    return@forEachIndexed
+                }
+            }
+            // val totalChapterNum = book.totalChapterNum / scope.size
+            if (chapterList.size == 0) {
+                throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
+            }
+            chapterList = chapterList.subList(
+                epubBookIndex * size,
+                if ((epubBookIndex + 1) * size > scope.size) scope.size else (epubBookIndex + 1) * size
+            )
+            chapterList.forEachIndexed { index, chapter ->
+                coroutineContext.ensureActive()
+                updateProgress(chapterList, index)
+                BookHelp.getContent(book, chapter).let { content ->
+                    var content1 = fixPic(
+                        epubBook,
+                        book,
+                        content ?: if (chapter.isVolume) "" else "null",
+                        chapter
+                    )
+                    content1 = contentProcessor
+                        .getContent(
+                            book,
+                            chapter,
+                            content1,
+                            includeTitle = false,
+                            useReplace = useReplace,
+                            chineseConvert = false,
+                            reSegment = false
+                        ).toString()
+                    val title = chapter.run {
+                        // 不导出vip标识
+                        isVip = false
+                        getDisplayTitle(
+                            contentProcessor.getTitleReplaceRules(),
+                            useReplace = useReplace
+                        )
+                    }
+                    epubBook.addSection(
+                        title,
+                        ResourceUtil.createChapterResource(
+                            title.replace("\uD83D\uDD12", ""),
+                            content1,
+                            contentModel,
+                            "Text/chapter_${index}.html"
+                        )
+                    )
+                }
+            }
+        }
+
+        /**
+         * 创建多个epub 对象
+         *
+         * 分割epub时,一个书籍需要创建多个epub对象
+         * @param doc 导出文档
+         * @param book 书籍
+         *
+         * @return <内容模板字符串, <epub文件名, epub对象>>
+         */
+        private fun createEpubs(
+            book: Book,
+            doc: DocumentFile?,
+        ): 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)
+                }
+                val epubBook = EpubBook()
+                epubBook.version = "2.0"
+                //set metadata
+                setEpubMetadata(book, epubBook)
+                //set cover
+                setCover(book, epubBook)
+                //set css
+                contentModel = doc?.let {
+                    setAssets(it, book, epubBook)
+                } ?: setAssets(book, epubBook)
+
+                // add epubBook
+                result.add(Pair(filename, epubBook))
+            }
+            return Pair(contentModel, result)
+        }
+
+        /**
+         * 保存文件到 设备
+         */
+        private suspend fun save2Drive(
+            filename: String,
+            epubBook: EpubBook,
+            doc: DocumentFile,
+            callback: (total: Int, progress: Int) -> Unit
+        ) {
+            DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
+                contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
+                    EpubWriter()
+                        .setCallback(object : EpubWriterProcessor.Callback {
+                            override fun onProgressing(total: Int, progress: Int) {
+                                callback(total, progress)
+                            }
+                        })
+                        .write(epubBook, BufferedOutputStream(bookOs))
+                }
+                if (AppConfig.exportToWebDav) {
+                    // 导出到webdav
+                    AppWebDav.exportWebDav(bookDoc.uri, filename)
+                }
+            }
+        }
+
+        /**
+         * 保存文件到 设备
+         */
+        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)
+            @Suppress("BlockingMethodInNonBlockingContext")
+            EpubWriter()
+                .setCallback(object : EpubWriterProcessor.Callback {
+                    override fun onProgressing(total: Int, progress: Int) {
+                        callback(total, progress)
+                    }
+                })
+                .write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
+            if (AppConfig.exportToWebDav) {
+                // 导出到webdav
+                AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
+            }
+        }
+
+        /**
+         * 解析 分割epub后的数量
+         *
+         * @param total 章节总数
+         * @param size 每个epub文件包含多少章节
+         */
+        private fun paresNumOfEpub(total: Int, size: Int): Int {
+            val i = total % size
+            var result = total / size
+            if (i > 0) {
+                result++
+            }
+            return result
+        }
+    }
+}

+ 32 - 30
app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt

@@ -15,6 +15,7 @@ import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst.charsets
 import io.legado.app.constant.AppLog
 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
@@ -30,6 +31,7 @@ import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.model.CacheBook
+import io.legado.app.service.ExportBookService
 import io.legado.app.ui.about.AppLogDialog
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.utils.*
@@ -239,6 +241,9 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
         viewModel.upAdapterLiveData.observe(this) {
             notifyItemChanged(it)
         }
+        observeEvent<String>(EventBus.EXPORT_BOOK) {
+            notifyItemChanged(it)
+        }
         observeEvent<String>(EventBus.UP_DOWNLOAD) {
             if (!CacheBook.isRun) {
                 menu?.findItem(R.id.menu_download)?.let { item ->
@@ -291,7 +296,6 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
      */
     private fun configExportSection(path: String, position: Int) {
 
-
         val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
             .apply {
                 fun verifyExportFileNameJsStr(js: String): Boolean {
@@ -391,16 +395,22 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
                 }
                 etInputScope.error = null
                 val toInt = etEpubSize.text.toString().toInt()
-                startExport(path, position, toInt, text.toString())
+                adapter.getItem(position)?.let { book ->
+                    startService<ExportBookService> {
+                        action = IntentAction.start
+                        putExtra("bookUrl", book.bookUrl)
+                        putExtra("exportType", "epub")
+                        putExtra("exportPath", path)
+                        putExtra("epubSize", toInt)
+                        putExtra("epubScope", text.toString())
+                    }
+                }
                 alertDialog.hide()
-
             }
 
         }
     }
 
-
-
     private fun selectExportFolder(exportPosition: Int) {
         val default = arrayListOf<SelectItem<Int>>()
         val path = ACache.get().getAsString(exportBookPathKey)
@@ -413,25 +423,19 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
         }
     }
 
-    private fun startExport(path: String, exportPosition: Int, size: Int, scope: String) {
-        if (exportPosition >= 0) {
-            adapter.getItem(exportPosition)?.let { book ->
-                when (AppConfig.exportType) {
-                    1 -> viewModel.exportEPUBs(path, book, size, scope)
-                    // 目前仅支持 epub
-                    //else -> viewModel.export(path, book)
-                }
-            }
-        }
-    }
-
     private fun startExport(path: String, exportPosition: Int) {
+        val exportType = when (AppConfig.exportType) {
+            1 -> "epub"
+            else -> "txt"
+        }
         if (exportPosition == -10) {
             if (adapter.getItems().isNotEmpty()) {
                 adapter.getItems().forEach { book ->
-                    when (AppConfig.exportType) {
-                        1 -> viewModel.exportEPUB(path, book)
-                        else -> viewModel.export(path, book)
+                    startService<ExportBookService> {
+                        action = IntentAction.start
+                        putExtra("bookUrl", book.bookUrl)
+                        putExtra("exportType", exportType)
+                        putExtra("exportPath", path)
                     }
                 }
             } else {
@@ -439,9 +443,11 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
             }
         } else if (exportPosition >= 0) {
             adapter.getItem(exportPosition)?.let { book ->
-                when (AppConfig.exportType) {
-                    1 -> viewModel.exportEPUB(path, book)
-                    else -> viewModel.export(path, book)
+                startService<ExportBookService> {
+                    action = IntentAction.start
+                    putExtra("bookUrl", book.bookUrl)
+                    putExtra("exportType", exportType)
+                    putExtra("exportPath", path)
                 }
             }
         }
@@ -450,11 +456,7 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
     @SuppressLint("SetTextI18n")
     private fun alertExportFileName() {
         alert(R.string.export_file_name) {
-            val message =
-                "Variable: name, author."
-//            if (AppConfig.bookExportFileName.isNullOrBlank()) {
-//                message += "\n例如:\nname+\"-\"+author+(epubIndex?\"(\"+epubIndex+\")\":\"\")"
-//            }
+            val message = "Variable: name, author."
             setMessage(message)
             val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
                 editView.hint = "file name js"
@@ -499,11 +501,11 @@ class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>()
         get() = viewModel.cacheChapters
 
     override fun exportProgress(bookUrl: String): Int? {
-        return viewModel.exportProgress[bookUrl]
+        return ExportBookService.exportProgress[bookUrl]
     }
 
     override fun exportMsg(bookUrl: String): String? {
-        return viewModel.exportMsg[bookUrl]
+        return ExportBookService.exportMsg[bookUrl]
     }
 
 }

+ 12 - 895
app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt

@@ -1,62 +1,30 @@
 package io.legado.app.ui.book.cache
 
 import android.app.Application
-import android.graphics.Bitmap
-import android.graphics.drawable.Drawable
-import android.net.Uri
-import android.util.ArraySet
-import androidx.documentfile.provider.DocumentFile
 import androidx.lifecycle.MutableLiveData
-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.BaseViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.AppPattern
 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.book.isLocal
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.coroutine.OrderCoroutine
-import io.legado.app.utils.*
-import kotlinx.coroutines.*
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import me.ag2s.epublib.domain.*
-import me.ag2s.epublib.domain.Date
-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.nio.file.*
-import java.util.*
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.coroutines.coroutineContext
+import io.legado.app.utils.sendValue
+import kotlinx.coroutines.ensureActive
+import kotlin.collections.HashSet
+import kotlin.collections.List
+import kotlin.collections.contains
+import kotlin.collections.forEach
+import kotlin.collections.hashMapOf
+import kotlin.collections.hashSetOf
+import kotlin.collections.isNotEmpty
+import kotlin.collections.set
 
 
 class CacheViewModel(application: Application) : BaseViewModel(application) {
     val upAdapterLiveData = MutableLiveData<String>()
-    val exportProgress = ConcurrentHashMap<String, Int>()
-    val exportMsg = ConcurrentHashMap<String, String>()
-    private val mutex = Mutex()
-    val cacheChapters = hashMapOf<String, HashSet<String>>()
-    private var loadChapterCoroutine: Coroutine<Unit>? = null
 
-    @Volatile
-    private var exportNumber = 0
+    private var loadChapterCoroutine: Coroutine<Unit>? = null
+    val cacheChapters = hashMapOf<String, HashSet<String>>()
 
     fun loadCacheFiles(books: List<Book>) {
         loadChapterCoroutine?.cancel()
@@ -80,855 +48,4 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
         }
     }
 
-    fun exportFileExist(path: String, book: Book): Boolean {
-        val fileName = book.getExportFileName("txt")
-        return if (path.isContentScheme()) {
-            val uri = Uri.parse(path)
-            val doc = DocumentFile.fromTreeUri(context, uri) ?: return false
-            doc.findFile(fileName) ?: return false
-            return true
-        } else {
-            File(path).exists(fileName)
-        }
-    }
-
-    fun export(path: String, book: Book) {
-        if (exportProgress.contains(book.bookUrl)) return
-        exportProgress[book.bookUrl] = 0
-        exportMsg.remove(book.bookUrl)
-        upAdapterLiveData.sendValue(book.bookUrl)
-        execute {
-            mutex.withLock {
-                while (exportNumber > 0) {
-                    delay(1000)
-                }
-                exportNumber++
-            }
-            if (path.isContentScheme()) {
-                val uri = Uri.parse(path)
-                val doc = DocumentFile.fromTreeUri(context, uri)
-                    ?: throw NoStackTraceException("获取导出文档失败")
-                export(doc, book)
-            } else {
-                export(File(path).createFolderIfNotExist(), book)
-            }
-        }.onError {
-            exportProgress.remove(book.bookUrl)
-            exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
-            upAdapterLiveData.postValue(book.bookUrl)
-            AppLog.put("导出书籍<${book.name}>出错", it)
-        }.onSuccess {
-            exportProgress.remove(book.bookUrl)
-            exportMsg[book.bookUrl] = context.getString(R.string.export_success)
-            upAdapterLiveData.postValue(book.bookUrl)
-        }.onFinally {
-            exportNumber--
-        }
-    }
-
-    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("创建文档失败,请尝试重新设置导出文件夹")
-        context.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(context, 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${
-            context.getString(R.string.author_show, book.getRealAuthor())
-        }\n${
-            context.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 ->
-                upAdapterLiveData.postValue(book.bookUrl)
-                exportProgress[book.bookUrl] = index
-                append.invoke(result.first, result.second)
-            }
-        } else {
-            appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
-                coroutineContext.ensureActive()
-                upAdapterLiveData.postValue(book.bookUrl)
-                exportProgress[book.bookUrl] = index
-                val result = getExportData(book, chapter, contentProcessor, useReplace)
-                append.invoke(result.first, result.second)
-            }
-        }
-
-    }
-
-    private suspend 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
-            }
-            for (i in left..right)
-                result.add(i - 1)
-        }
-        return result.toIntArray()
-    }
-
-
-    //////////////////Start EPUB
-    /**
-     * 导出Epub 根据自定义导出范围
-     *
-     * @param path  导出路径
-     * @param book  书籍
-     * @param size  每本Epub包含的章节
-     * @param scope  导出范围
-     * @since 2023/5/22
-     */
-    fun exportEPUBs(path: String, book: Book, size: Int = 1, scope: String) {
-        if (exportProgress.contains(book.bookUrl)) return
-        CustomExporter(this).let {
-            it.scope = paresScope(scope)
-            it.size = size
-            it.export(path, book)
-        }
-    }
-
-    /**
-     * 导出Epub
-     */
-    fun exportEPUB(path: String, book: Book) {
-        if (exportProgress.contains(book.bookUrl)) return
-        exportProgress[book.bookUrl] = 0
-        exportMsg.remove(book.bookUrl)
-        upAdapterLiveData.sendValue(book.bookUrl)
-        execute {
-            mutex.withLock {
-                while (exportNumber > 0) {
-                    delay(1000)
-                }
-                exportNumber++
-            }
-            if (path.isContentScheme()) {
-                val uri = Uri.parse(path)
-                val doc = DocumentFile.fromTreeUri(context, uri)
-                    ?: throw NoStackTraceException("获取导出文档失败")
-                exportEpub(doc, book)
-            } else {
-                exportEpub(File(path).createFolderIfNotExist(), book)
-            }
-        }.onError {
-            exportProgress.remove(book.bookUrl)
-            exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
-            upAdapterLiveData.postValue(book.bookUrl)
-            it.printStackTrace()
-            AppLog.put("导出epub书籍<${book.name}>出错\n${it.localizedMessage}", it)
-        }.onSuccess {
-            exportProgress.remove(book.bookUrl)
-            exportMsg[book.bookUrl] = context.getString(R.string.export_success)
-            upAdapterLiveData.postValue(book.bookUrl)
-        }.onFinally {
-            exportNumber--
-        }
-    }
-
-    private suspend fun exportEpub(doc: DocumentFile, book: Book) {
-        val filename = book.getExportFileName("epub")
-        DocumentUtils.delete(doc, filename)
-        val epubBook = EpubBook()
-        epubBook.version = "2.0"
-        //set metadata
-        setEpubMetadata(book, epubBook)
-        //set cover
-        setCover(book, epubBook)
-        //set css
-        val contentModel = setAssets(doc, book, epubBook)
-
-        //设置正文
-        setEpubContent(contentModel, book, epubBook)
-        DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
-            context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
-                EpubWriter().write(epubBook, BufferedOutputStream(bookOs))
-            }
-            if (AppConfig.exportToWebDav) {
-                // 导出到webdav
-                AppWebDav.exportWebDav(bookDoc.uri, filename)
-            }
-        }
-    }
-
-
-    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(book, epubBook)
-
-        val bookPath = FileUtils.getPath(file, filename)
-        val bookFile = FileUtils.createFileWithReplace(bookPath)
-        //设置正文
-        setEpubContent(contentModel, book, epubBook)
-        @Suppress("BlockingMethodInNonBlockingContext")
-        EpubWriter().write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
-        if (AppConfig.exportToWebDav) {
-            // 导出到webdav
-            AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
-        }
-    }
-
-    private fun setAssets(doc: DocumentFile, book: Book, epubBook: EpubBook): String {
-        var contentModel = ""
-        DocumentUtils.getDirDocument(doc, "Asset").let { customPath ->
-            if (customPath == null) {//使用内置模板
-                contentModel = setAssets(book, epubBook)
-            } else {//外部模板
-                customPath.listFiles().forEach { folder ->
-                    if (folder.isDirectory && folder.name == "Text") {
-                        folder.listFiles().sortedWith { o1, o2 ->
-                            val name1 = o1.name ?: ""
-                            val name2 = o2.name ?: ""
-                            name1.cnCompare(name2)
-                        }.forEach { file ->
-                            if (file.isFile) {
-                                when {
-                                    //正文模板
-                                    file.name.equals("chapter.html", true)
-                                            || file.name.equals("chapter.xhtml", true) -> {
-                                        contentModel = file.readText(context)
-                                    }
-                                    //封面等其他模板
-                                    true == file.name?.endsWith("html", true) -> {
-                                        epubBook.addSection(
-                                            FileUtils.getNameExcludeExtension(
-                                                file.name ?: "Cover.html"
-                                            ),
-                                            ResourceUtil.createPublicResource(
-                                                book.name,
-                                                book.getRealAuthor(),
-                                                book.getDisplayIntro(),
-                                                book.kind,
-                                                book.wordCount,
-                                                file.readText(context),
-                                                "${folder.name}/${file.name}"
-                                            )
-                                        )
-                                    }
-
-                                    else -> {
-                                        //其他格式文件当做资源文件
-                                        folder.listFiles().forEach {
-                                            if (it.isFile)
-                                                epubBook.resources.add(
-                                                    Resource(
-                                                        it.readBytes(context),
-                                                        "${folder.name}/${it.name}"
-                                                    )
-                                                )
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    } else if (folder.isDirectory) {
-                        //资源文件
-                        folder.listFiles().forEach {
-                            if (it.isFile)
-                                epubBook.resources.add(
-                                    Resource(
-                                        it.readBytes(context),
-                                        "${folder.name}/${it.name}"
-                                    )
-                                )
-                        }
-                    } else {//Asset下面的资源文件
-                        epubBook.resources.add(
-                            Resource(
-                                folder.readBytes(context),
-                                "${folder.name}"
-                            )
-                        )
-                    }
-                }
-            }
-        }
-
-        return contentModel
-    }
-
-    private fun setAssets(book: Book, epubBook: EpubBook): String {
-        epubBook.resources.add(
-            Resource(
-                appCtx.assets.open("epub/fonts.css").readBytes(),
-                "Styles/fonts.css"
-            )
-        )
-        epubBook.resources.add(
-            Resource(
-                appCtx.assets.open("epub/main.css").readBytes(),
-                "Styles/main.css"
-            )
-        )
-        epubBook.resources.add(
-            Resource(
-                appCtx.assets.open("epub/logo.png").readBytes(),
-                "Images/logo.png"
-            )
-        )
-        epubBook.addSection(
-            context.getString(R.string.img_cover),
-            ResourceUtil.createPublicResource(
-                book.name,
-                book.getRealAuthor(),
-                book.getDisplayIntro(),
-                book.kind,
-                book.wordCount,
-                String(appCtx.assets.open("epub/cover.html").readBytes()),
-                "Text/cover.html"
-            )
-        )
-        epubBook.addSection(
-            context.getString(R.string.book_intro),
-            ResourceUtil.createPublicResource(
-                book.name,
-                book.getRealAuthor(),
-                book.getDisplayIntro(),
-                book.kind,
-                book.wordCount,
-                String(appCtx.assets.open("epub/intro.html").readBytes()),
-                "Text/intro.html"
-            )
-        )
-        return String(appCtx.assets.open("epub/chapter.html").readBytes())
-    }
-
-    private fun setCover(book: Book, epubBook: EpubBook) {
-        Glide.with(context)
-            .asBitmap()
-            .load(book.getDisplayCover())
-            .into(object : CustomTarget<Bitmap>() {
-                override fun onResourceReady(
-                    resource: Bitmap,
-                    transition: Transition<in Bitmap>?
-                ) {
-                    val stream = ByteArrayOutputStream()
-                    resource.compress(Bitmap.CompressFormat.JPEG, 100, stream)
-                    val byteArray: ByteArray = stream.toByteArray()
-                    stream.close()
-                    epubBook.coverImage = Resource(byteArray, "Images/cover.jpg")
-                }
-
-                override fun onLoadCleared(placeholder: Drawable?) {
-                }
-            })
-    }
-
-    private suspend fun setEpubContent(
-        contentModel: String,
-        book: Book,
-        epubBook: EpubBook
-    ) {
-        //正文
-        val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
-        val contentProcessor = ContentProcessor.get(book.name, book.origin)
-        appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
-            coroutineContext.ensureActive()
-            upAdapterLiveData.postValue(book.bookUrl)
-            exportProgress[book.bookUrl] = index
-            BookHelp.getContent(book, chapter).let { content ->
-                var content1 = fixPic(
-                    epubBook,
-                    book,
-                    content ?: if (chapter.isVolume) "" else "null",
-                    chapter
-                )
-                content1 = contentProcessor
-                    .getContent(
-                        book,
-                        chapter,
-                        content1,
-                        includeTitle = false,
-                        useReplace = useReplace,
-                        chineseConvert = false,
-                        reSegment = false
-                    ).toString()
-                val title = chapter.run {
-                    // 不导出vip标识
-                    isVip = false
-                    getDisplayTitle(
-                        contentProcessor.getTitleReplaceRules(),
-                        useReplace = useReplace
-                    )
-                }
-                epubBook.addSection(
-                    title,
-                    ResourceUtil.createChapterResource(
-                        title.replace("\uD83D\uDD12", ""),
-                        content1,
-                        contentModel,
-                        "Text/chapter_${index}.html"
-                    )
-                )
-            }
-        }
-    }
-
-    private fun fixPic(
-        epubBook: EpubBook,
-        book: Book,
-        content: String,
-        chapter: BookChapter
-    ): String {
-        val data = StringBuilder("")
-        content.split("\n").forEach { text ->
-            var text1 = text
-            val matcher = AppPattern.imgPattern.matcher(text)
-            while (matcher.find()) {
-                matcher.group(1)?.let {
-                    val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
-                    val originalHref =
-                        "${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
-                    val href =
-                        "Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
-                    val vFile = BookHelp.getImage(book, src)
-                    val fp = FileResourceProvider(vFile.parent)
-                    if (vFile.exists()) {
-                        val img = LazyResource(fp, href, originalHref)
-                        epubBook.resources.add(img)
-                    }
-                    text1 = text1.replace(src, "../${href}")
-                }
-            }
-            data.append(text1).append("\n")
-        }
-        return data.toString()
-    }
-
-    private fun setEpubMetadata(book: Book, epubBook: EpubBook) {
-        val metadata = Metadata()
-        metadata.titles.add(book.name)//书籍的名称
-        metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者
-        metadata.language = "zh"//数据的语言
-        metadata.dates.add(Date())//数据的创建日期
-        metadata.publishers.add("Legado")//数据的创建者
-        metadata.descriptions.add(book.getDisplayIntro())//书籍的简介
-        //metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍
-        epubBook.metadata = metadata
-    }
-
-    //////end of EPUB
-
-    //////start of custom exporter
-    /**
-     * 自定义Exporter
-     *
-     * @since 2023/5/23
-     */
-    class CustomExporter(private val context: CacheViewModel) {
-        var scope: IntArray = IntArray(0)
-
-        /**
-         *  epub 文件包含最大章节数
-         */
-        var size: Int = 1
-
-        /**
-         * 导出Epub
-         *
-         * from [io.legado.app.ui.book.cache.CacheViewModel.exportEPUB]
-         * @param path 导出的路径
-         * @param book 书籍
-         */
-        fun export(
-            path: String,
-            book: Book
-        ) {
-            context.exportProgress[book.bookUrl] = 0
-            context.exportMsg.remove(book.bookUrl)
-            context.upAdapterLiveData.sendValue(book.bookUrl)
-            context.execute {
-                context.mutex.withLock {
-                    while (context.exportNumber > 0) {
-                        delay(1000)
-                    }
-                    context.exportNumber++
-                }
-                val currentTimeMillis = System.currentTimeMillis()
-                when (path.isContentScheme()) {
-                    true -> {
-                        val uri = Uri.parse(path)
-                        val doc = DocumentFile.fromTreeUri(context.context, 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
-                            val asyncBlock = async {
-                                //设置正文
-                                setEpubContent(
-                                    contentModel,
-                                    book,
-                                    epubBook,
-                                    index
-                                ) { _, _ ->
-                                    // 将章节写入内存时更新进度条
-                                    context.upAdapterLiveData.postValue(book.bookUrl)
-                                    progressBar += book.totalChapterNum.toDouble() / scope.size / 2
-                                    context.exportProgress[book.bookUrl] = progressBar.toInt()
-                                }
-                                save2Drive(filename, epubBook, doc) { total, progress ->
-                                    //写入硬盘时更新进度条
-                                    progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
-                                    context.upAdapterLiveData.postValue(book.bookUrl)
-                                    context.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
-                            val asyncBlock = async {
-                                //设置正文
-                                setEpubContent(
-                                    contentModel,
-                                    book,
-                                    epubBook,
-                                    index
-                                ) { _, _ ->
-                                    context.upAdapterLiveData.postValue(book.bookUrl)
-                                    context.exportProgress[book.bookUrl] =
-                                        context.exportProgress[book.bookUrl]?.plus(book.totalChapterNum / scope.size)
-                                            ?: 1
-                                }
-                                save2Drive(filename, epubBook, file) { total, progress ->
-                                    //设置进度
-                                    progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
-                                    context.upAdapterLiveData.postValue(book.bookUrl)
-                                    context.exportProgress[book.bookUrl] = progressBar.toInt()
-                                }
-                            }
-                            asyncBlocks.add(asyncBlock)
-                        }
-                        asyncBlocks.forEach { it.await() }
-                    }
-                }
-                AppLog.put("分割导出书籍 ${book.name} 一共耗时 ${System.currentTimeMillis() - currentTimeMillis}")
-            }.onError {
-                context.exportProgress.remove(book.bookUrl)
-                context.exportMsg[book.bookUrl] = it.localizedMessage ?: "ERROR"
-                context.upAdapterLiveData.postValue(book.bookUrl)
-                it.printStackTrace()
-                AppLog.put("导出epub书籍<${book.name}>出错\n${it.localizedMessage}", it)
-            }.onSuccess {
-                context.exportProgress.remove(book.bookUrl)
-                context.exportMsg[book.bookUrl] = context.context.getString(R.string.export_success)
-                context.upAdapterLiveData.postValue(book.bookUrl)
-            }.onFinally {
-                context.exportNumber--
-            }
-        }
-
-
-        /**
-         * 设置epub正文
-         *
-         * from [io.legado.app.ui.book.cache.CacheViewModel.setEpubContent]
-         *
-         * @param contentModel 正文模板
-         * @param book 书籍
-         * @param epubBook 分割后的epub
-         * @param epubBookIndex 分割后的epub序号
-         */
-        private suspend fun setEpubContent(
-            contentModel: String,
-            book: Book,
-            epubBook: EpubBook,
-            epubBookIndex: Int,
-            updateProgress: (chapterList: MutableList<BookChapter>, index: Int) -> Unit
-        ) {
-            //正文
-            val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
-            val contentProcessor = ContentProcessor.get(book.name, book.origin)
-            var chapterList: MutableList<BookChapter> = ArrayList()
-            appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
-                if (scope.indexOf(index) >= 0) {
-                    chapterList.add(chapter)
-                }
-                if (scope.size == chapterList.size) {
-                    return@forEachIndexed
-                }
-            }
-            // val totalChapterNum = book.totalChapterNum / scope.size
-            if (chapterList.size == 0) {
-                throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
-            }
-            chapterList = chapterList.subList(
-                epubBookIndex * size,
-                if ((epubBookIndex + 1) * size > scope.size) scope.size else (epubBookIndex + 1) * size
-            )
-            chapterList.forEachIndexed { index, chapter ->
-                coroutineContext.ensureActive()
-                updateProgress(chapterList, index)
-                BookHelp.getContent(book, chapter).let { content ->
-                    var content1 = context.fixPic(
-                        epubBook,
-                        book,
-                        content ?: if (chapter.isVolume) "" else "null",
-                        chapter
-                    )
-                    content1 = contentProcessor
-                        .getContent(
-                            book,
-                            chapter,
-                            content1,
-                            includeTitle = false,
-                            useReplace = useReplace,
-                            chineseConvert = false,
-                            reSegment = false
-                        ).toString()
-                    val title = chapter.run {
-                        // 不导出vip标识
-                        isVip = false
-                        getDisplayTitle(
-                            contentProcessor.getTitleReplaceRules(),
-                            useReplace = useReplace
-                        )
-                    }
-                    epubBook.addSection(
-                        title,
-                        ResourceUtil.createChapterResource(
-                            title.replace("\uD83D\uDD12", ""),
-                            content1,
-                            contentModel,
-                            "Text/chapter_${index}.html"
-                        )
-                    )
-                }
-            }
-        }
-
-        /**
-         * 创建多个epub 对象
-         *
-         * 分割epub时,一个书籍需要创建多个epub对象
-         * @param doc 导出文档
-         * @param book 书籍
-         *
-         * @return <内容模板字符串, <epub文件名, epub对象>>
-         */
-        private fun createEpubs(
-            book: Book,
-            doc: DocumentFile?,
-        ): 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)
-                }
-                val epubBook = EpubBook()
-                epubBook.version = "2.0"
-                //set metadata
-                context.setEpubMetadata(book, epubBook)
-                //set cover
-                context.setCover(book, epubBook)
-                //set css
-                contentModel = doc?.let {
-                    context.setAssets(it, book, epubBook)
-                } ?: context.setAssets(book, epubBook)
-
-                // add epubBook
-                result.add(Pair(filename, epubBook))
-            }
-            return Pair(contentModel, result)
-        }
-
-        /**
-         * 保存文件到 设备
-         */
-        private suspend fun save2Drive(
-            filename: String,
-            epubBook: EpubBook,
-            doc: DocumentFile,
-            callback: (total: Int, progress: Int) -> Unit
-        ) {
-            DocumentUtils.createFileIfNotExist(doc, filename)?.let { bookDoc ->
-                context.context.contentResolver.openOutputStream(bookDoc.uri, "wa")?.use { bookOs ->
-                    EpubWriter()
-                        .setCallback(object : EpubWriterProcessor.Callback {
-                            override fun onProgressing(total: Int, progress: Int) {
-                                callback(total, progress)
-                            }
-                        })
-                        .write(epubBook, BufferedOutputStream(bookOs))
-                }
-                if (AppConfig.exportToWebDav) {
-                    // 导出到webdav
-                    AppWebDav.exportWebDav(bookDoc.uri, filename)
-                }
-            }
-        }
-
-        /**
-         * 保存文件到 设备
-         */
-        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)
-            @Suppress("BlockingMethodInNonBlockingContext")
-            EpubWriter()
-                .setCallback(object : EpubWriterProcessor.Callback {
-                    override fun onProgressing(total: Int, progress: Int) {
-                        callback(total, progress)
-                    }
-                })
-                .write(epubBook, BufferedOutputStream(FileOutputStream(bookFile)))
-            if (AppConfig.exportToWebDav) {
-                // 导出到webdav
-                AppWebDav.exportWebDav(Uri.fromFile(bookFile), filename)
-            }
-        }
-
-        /**
-         * 解析 分割epub后的数量
-         *
-         * @param total 章节总数
-         * @param size 每个epub文件包含多少章节
-         */
-        private fun paresNumOfEpub(total: Int, size: Int): Int {
-            val i = total % size
-            var result = total / size
-            if (i > 0) {
-                result++
-            }
-            return result
-        }
-    }
 }

+ 2 - 2
app/src/main/res/drawable/ic_export.xml

@@ -6,9 +6,9 @@
     android:viewportHeight="1024">
 
     <path
-        android:fillColor="#050505"
+        android:fillColor="#4A4B4A"
         android:pathData="M526 780H380.8c-19.2 0-34.8-15.6-34.8-34.8V267.8c0-19.2 15.6-34.8 34.8-34.8h477.4c19.2 0 34.8 15.6 34.8 34.8V413c0 18.8 15.2 34 34 34s34-15.2 34-34V234.1c0-38.2-31-69.1-69.1-69.1H347.1c-38.2 0-69.1 31-69.1 69.1v544.7c0 38.2 31 69.1 69.1 69.1H526c18.8 0 34-15.2 34-34 0-18.7-15.2-33.9-34-33.9z" />
     <path
-        android:fillColor="#000000"
+        android:fillColor="#4A4B4A"
         android:pathData="M950.9 654.8l-0.1-0.1L817.1 521c-13.3-13.3-34.8-13.3-48.1 0-13.3 13.3-13.3 34.8 0 48.1l75.9 75.9H632c-18.8 0-34 15.2-34 34s15.2 34 34 34h212.7L769 788.7c-13.3 13.3-13.3 34.8 0 48.1 13.3 13.3 34.8 13.3 48.1 0l131.6-131.6c7.5-6.2 12.3-15.6 12.3-26.1 0-9.6-3.9-18.1-10.1-24.3z" />
 </vector>