[优化] 重写缓存界面

This commit is contained in:
HapeLee
2026-04-23 13:08:39 +08:00
parent e6dbabc5e0
commit f8473077dc
26 changed files with 1676 additions and 879 deletions
-5
View File
@@ -283,11 +283,6 @@
android:name=".ui.book.bookmark.AllBookmarkActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 缓存界面 -->
<activity
android:name=".ui.book.cache.CacheActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- WebView界面 -->
<activity
android:name=".ui.browser.WebViewActivity"
@@ -26,6 +26,7 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel
import io.legado.app.ui.book.cache.CacheViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.book.import.local.ImportBookViewModel
@@ -37,6 +38,7 @@ import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.cacheConfig.CacheConfig
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
import io.legado.app.ui.config.readConfig.ReadConfigViewModel
@@ -72,6 +74,7 @@ val appModule = module {
singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository)
singleOf(::ExploreKindUiUseCase)
singleOf(::CacheConfig)
single<UploadRepository> { DirectLinkUploadRepository() }
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
@@ -122,6 +125,7 @@ val appModule = module {
viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel)
viewModelOf(::CacheViewModel)
viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
@@ -29,6 +29,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.association.OpenUrlConfirmActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.ChineseUtils
import io.legado.app.utils.EncoderUtils
@@ -130,7 +131,7 @@ interface JsExtensions : JsEncodeUtils {
}
fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> {
return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
url,
source = getSource(),
@@ -153,7 +154,7 @@ interface JsExtensions : JsEncodeUtils {
skipRateLimit: Boolean
): Array<StrResponse> {
return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
url,
source = getSource(),
@@ -1211,4 +1212,4 @@ interface JsExtensions : JsEncodeUtils {
return OldThemeConfig.getDurConfig(appCtx).toMap()
}
}
}
+155 -152
View File
@@ -11,11 +11,11 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.webBook.WebBook
import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
import io.legado.app.utils.startService
@@ -44,79 +44,111 @@ import kotlin.coroutines.CoroutineContext
object CacheBook {
private data class QueueStats(
val waitingCount: Int,
val downloadingCount: Int
)
private class CacheBookCoordinator {
val taskMap = ConcurrentHashMap<String, CacheBookModel>()
private val processMutex = Mutex()
private val workingState = MutableStateFlow(true)
fun setWorkingState(value: Boolean) {
workingState.value = value
}
suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock {
setWorkingState(true)
flow {
while (currentCoroutineContext().isActive && taskMap.isNotEmpty()) {
if (!workingState.value) {
workingState.first { it }
}
var emitted = false
taskMap.forEach { (_, model) ->
if (!model.isLoading()) {
emit(model)
emitted = true
}
}
if (!emitted) delay(800)
}
}.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.onEachParallel(OtherConfig.threadCount.coerceAtLeast(1)) {
coroutineScope {
it.download(this, context)
}
}.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.collect()
}
}
private val coordinator = CacheBookCoordinator()
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
private val _downloadSummaryFlow = MutableStateFlow("")
val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _downloadingIndicesFlow =
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
private val _downloadErrorFlow =
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
val successDownloadSet = ConcurrentHashMap.newKeySet<String>()
val errorDownloadMap = ConcurrentHashMap<String, Int>()
private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>()
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = coordinator.taskMap
private fun collectQueueStats(): QueueStats {
var waiting = 0
var downloading = 0
cacheBookMap.forEach { (_, model) ->
val (w, d) = model.queueCounts()
waiting += w
downloading += d
}
return QueueStats(waiting, downloading)
}
private fun updateSummary() {
_downloadSummaryFlow.value = downloadSummary
}
val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
private val workingState = MutableStateFlow(true)
private val mutex = Mutex()
val totalCount: Int
get() {
var total = 0
cacheBookMap.forEach { (_, model) ->
total += model.waitCount + model.onDownloadCount
}
total += successDownloadSet.size
total += errorDownloadMap.size
return total
}
val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size
@Synchronized
fun getOrCreate(bookUrl: String): CacheBookModel? {
val book = appDb.bookDao.getBook(bookUrl) ?: return null
val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
updateBookSource(bookSource)
var cacheBook = cacheBookMap[bookUrl]
if (cacheBook != null) {
//存在时更新,书源可能会变化,必须更新
cacheBook.bookSource = bookSource
cacheBook.book = book
return cacheBook
}
cacheBook = CacheBookModel(bookSource, book)
cacheBookMap[bookUrl] = cacheBook
return cacheBook
val source = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
return getOrCreate(source, book)
}
@Synchronized
fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel {
updateBookSource(bookSource)
var cacheBook = cacheBookMap[book.bookUrl]
if (cacheBook != null) {
//存在时更新,书源可能会变化,必须更新
cacheBook.bookSource = bookSource
cacheBook.book = book
return cacheBook
cacheBookMap[book.bookUrl]?.let { model ->
model.bookSource = bookSource
model.book = book
return model
}
cacheBook = CacheBookModel(bookSource, book)
cacheBookMap[book.bookUrl] = cacheBook
return cacheBook
val model = CacheBookModel(bookSource, book)
cacheBookMap[book.bookUrl] = model
updateSummary()
return model
}
private fun updateBookSource(newBookSource: BookSource) {
cacheBookMap.forEach {
val model = it.value
cacheBookMap.forEach { (_, model) ->
if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) {
model.bookSource = newBookSource
}
@@ -149,100 +181,60 @@ object CacheBook {
}
fun close() {
cacheBookMap.forEach { it.value.stop() }
cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear()
successDownloadSet.clear()
errorDownloadMap.clear()
errorIndexMap.clear()
updateSummary()
}
fun setWorkingState(value: Boolean) {
workingState.value = value
coordinator.setWorkingState(value)
}
suspend fun startProcessJob(context: CoroutineContext) = mutex.withLock {
setWorkingState(true)
flow {
while (currentCoroutineContext().isActive && cacheBookMap.isNotEmpty()) {
var emitted = false
cacheBookMap.forEach { (_, model) ->
if (!model.isLoading()) {
emit(model)
emitted = true
}
workingState.first { it }
}
if (!emitted) {
delay(1000)
}
}
}.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.onEachParallel(AppConfig.threadCount) {
coroutineScope {
it.download(this, context)
}
}.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.collect()
suspend fun startProcessJob(context: CoroutineContext) {
coordinator.startProcessJob(context)
}
val totalCount: Int
get() {
val stats = collectQueueStats()
return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
}
val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size
val downloadSummary: String
get() {
return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}"
val stats = collectQueueStats()
return "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
}
val isRun: Boolean
get() {
cacheBookMap.forEach {
if (it.value.isRun()) {
return true
}
cacheBookMap.forEach { (_, model) ->
if (model.isRun()) return true
}
return false
}
private val waitCount: Int
get() {
var count = 0
cacheBookMap.forEach {
count += it.value.waitCount
}
return count
}
private fun onTaskQueuesChanged(bookUrl: String) {
updateSummary()
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
}
val onDownloadCount: Int
get() {
var count = 0
cacheBookMap.forEach {
count += it.value.onDownloadCount
}
return count
}
private fun onTaskRemoved(bookUrl: String) {
cacheBookMap.remove(bookUrl)
updateSummary()
postEvent(EventBus.UP_DOWNLOAD, bookUrl)
}
val successDownloadSet = linkedSetOf<String>()
val errorDownloadMap = hashMapOf<String, Int>()
class CacheBookModel(var bookSource: BookSource, var book: Book) {
private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
}
private fun notifyErrorChanged() {
val errorIndices = errorDownloadMap
.filterKeys { it.startsWith(book.bookUrl) }
.mapNotNull {
val chapterIndex = it.key.substringAfterLast("_").toIntOrNull()
chapterIndex
}
.toSet()
_downloadErrorFlow.tryEmit(book.bookUrl to errorIndices)
}
class CacheBookModel(
@Volatile var bookSource: BookSource,
@Volatile var book: Book
) {
private val waitDownloadSet = linkedSetOf<Int>()
private val onDownloadSet = linkedSetOf<Int>()
@@ -251,13 +243,22 @@ object CacheBook {
private var waitingRetry = false
private var isLoading = false
val waitCount get() = waitDownloadSet.size
val onDownloadCount get() = onDownloadSet.size
init {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
}
private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
}
private fun notifyErrorChanged() {
val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet()
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
}
@Synchronized
fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size
@Synchronized
fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
@@ -269,13 +270,12 @@ object CacheBook {
}
@Synchronized
fun isLoading(): Boolean {
return isLoading
}
fun isLoading(): Boolean = isLoading
@Synchronized
fun setLoading() {
isLoading = true
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
@@ -284,7 +284,9 @@ object CacheBook {
tasks.clear()
isStopped = true
isLoading = false
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
onDownloadSet.clear()
notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
@@ -297,7 +299,8 @@ object CacheBook {
}
cacheBookMap[book.bookUrl] = this
isLoading = false
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
fun addDownload(index: Int) {
@@ -309,7 +312,9 @@ object CacheBook {
onDownloadSet.remove(chapter.index)
successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr())
errorIndexMap[book.bookUrl]?.remove(chapter.index)
notifyDownloadSetChanged()
notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter)
}
@@ -317,22 +322,20 @@ object CacheBook {
private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true
if (error !is ConcurrentException) {
errorDownloadMap[chapter.primaryStr()] =
(errorDownloadMap[chapter.primaryStr()] ?: 0) + 1
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index)
}
onDownloadSet.remove(chapter.index)
}
@Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) {
//重试3次
if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) {
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
if (retryCount < 3 && !isStopped) {
waitDownloadSet.add(chapter.index)
} else {
AppLog.put(
"下载${book.name}-${chapter.title}失败\n${error.localizedMessage}",
error
)
AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
}
waitingRetry = false
}
@@ -354,12 +357,13 @@ object CacheBook {
@Synchronized
private fun onFinally() {
val bookUrl = book.bookUrl
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
cacheBookMap.remove(book.bookUrl)
CacheBook.onTaskRemoved(bookUrl)
} else {
CacheBook.onTaskQueuesChanged(bookUrl)
}
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
notifyDownloadSetChanged()
updateSummary()
}
/**
@@ -370,7 +374,7 @@ object CacheBook {
val chapterIndex = waitDownloadSet.firstOrNull()
if (chapterIndex == null) {
if (!isLoading && onDownloadSet.isEmpty()) {
cacheBookMap.remove(book.bookUrl)
CacheBook.onTaskRemoved(book.bookUrl)
}
return
}
@@ -378,12 +382,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex)
return
}
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let {
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
waitDownloadSet.remove(chapterIndex)
return
}
if (chapter.isVolume) {
/** 修正下载计数 */
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
_cacheSuccessFlow.tryEmit(chapter)
waitDownloadSet.remove(chapterIndex)
@@ -393,9 +396,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex)
return
}
waitDownloadSet.remove(chapterIndex)
onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged()
if (BookHelp.hasContent(book, chapter)) {
Coroutine.async(scope, context, executeContext = context) {
BookHelp.getContent(book, chapter)?.let {
@@ -405,7 +410,6 @@ object CacheBook {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
//出现错误等待一秒后重新加入待下载列表
delay(1000)
onPostError(chapter, it)
}.onCancel {
@@ -415,6 +419,7 @@ object CacheBook {
}
return
}
WebBook.getContent(
scope,
bookSource,
@@ -428,7 +433,6 @@ object CacheBook {
downloadFinish(chapter, content)
}.onError {
onPreError(chapter, it)
//出现错误等待一秒后重新加入待下载列表
delay(1000)
onPostError(chapter, it)
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}")
@@ -445,6 +449,7 @@ object CacheBook {
synchronized(this) {
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
notifyDownloadSetChanged()
}
try {
val content = WebBook.getContentAwait(bookSource, book, chapter)
@@ -453,15 +458,13 @@ object CacheBook {
ReadBook.downloadFailChapters.remove(chapter.index)
return content
} catch (e: Exception) {
if (e is CancellationException) {
onCancel(chapter.index)
}
if (e is CancellationException) onCancel(chapter.index)
onError(chapter, e)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
return "获取正文失败\n${e.localizedMessage}"
} finally {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
CacheBook.onTaskQueuesChanged(book.bookUrl)
}
}
@@ -472,11 +475,11 @@ object CacheBook {
semaphore: Semaphore?,
resetPageOffset: Boolean = false
) {
if (onDownloadSet.contains(chapter.index)) {
return
}
if (onDownloadSet.contains(chapter.index)) return
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
notifyDownloadSetChanged()
WebBook.getContent(
scope,
bookSource,
@@ -499,7 +502,7 @@ object CacheBook {
onCancel(chapter.index)
downloadFinish(chapter, "download canceled", resetPageOffset, true)
}.onFinally {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
CacheBook.onTaskQueuesChanged(book.bookUrl)
}.start()
}
@@ -511,13 +514,13 @@ object CacheBook {
) {
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.contentLoadFinish(
book, chapter, content,
book = book,
chapter = chapter,
content = content,
resetPageOffset = resetPageOffset,
canceled = canceled
)
}
}
}
}
}
@@ -7,7 +7,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.mapParallelSafe
@@ -33,7 +33,7 @@ import kotlin.coroutines.coroutineContext
import kotlin.math.min
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
val threadCount = AppConfig.threadCount
val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var mSearchId = 0L
private var searchPage = 1
@@ -210,4 +210,4 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
fun onSearchCancel(exception: Throwable? = null)
}
}
}
@@ -12,10 +12,10 @@ import io.legado.app.constant.IntentAction
import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb
import io.legado.app.help.book.update
import io.legado.app.help.config.AppConfig
import io.legado.app.model.CacheBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.postEvent
import io.legado.app.utils.servicePendingIntent
@@ -41,7 +41,7 @@ class CacheBookService : BaseService() {
private set
}
private val threadCount = AppConfig.threadCount
private val threadCount = OtherConfig.threadCount
private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null
@@ -53,7 +53,12 @@ class CacheBookService : BaseService() {
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentTitle(getString(R.string.offline_cache))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity"))
.setContentIntent(
activityPendingIntent(
MainActivity.createCacheIntent(this),
"cacheActivity"
)
)
builder.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
@@ -223,4 +228,4 @@ class CacheBookService : BaseService() {
startForeground(NotificationId.CacheBookService, notification)
}
}
}
@@ -18,12 +18,12 @@ import io.legado.app.exception.ContentEmptyException
import io.legado.app.exception.NoStackTraceException
import io.legado.app.exception.TocEmptyException
import io.legado.app.help.IntentData
import io.legado.app.help.config.AppConfig
import io.legado.app.help.source.exploreKinds
import io.legado.app.model.CheckSource
import io.legado.app.model.Debug
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent
@@ -51,7 +51,7 @@ import kotlin.math.min
* 校验书源
*/
class CheckSourceService : BaseService() {
private var threadCount = AppConfig.threadCount
private var threadCount = OtherConfig.threadCount
private var searchCoroutine =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var notificationMsg = appCtx.getString(R.string.service_starting)
@@ -258,4 +258,4 @@ class CheckSourceService : BaseService() {
startForeground(NotificationId.CheckSourceService, notificationBuilder.build())
}
}
}
@@ -25,7 +25,7 @@ import io.legado.app.help.book.isLocalModified
import io.legado.app.help.config.AppConfig
import io.legado.app.model.ReadBook
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.book.cache.CacheActivity
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.HtmlFormatter
@@ -48,6 +48,8 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
@@ -79,6 +81,8 @@ class ExportBookService : BaseService() {
companion object {
val exportProgress = ConcurrentHashMap<String, Int>()
val exportMsg = ConcurrentHashMap<String, String>()
private val _exportBookUpdateFlow = MutableSharedFlow<String>(extraBufferCapacity = 64)
val exportBookUpdateFlow = _exportBookUpdateFlow.asSharedFlow()
}
data class ExportConfig(
@@ -107,7 +111,7 @@ class ExportBookService : BaseService() {
)
waitExportBooks[bookUrl] = exportConfig
exportMsg[bookUrl] = getString(R.string.export_wait)
postEvent(EventBus.EXPORT_BOOK, bookUrl)
notifyExportBookChanged(bookUrl)
export()
}
}.onFailure {
@@ -127,7 +131,7 @@ class ExportBookService : BaseService() {
exportProgress.clear()
exportMsg.clear()
waitExportBooks.keys.forEach {
postEvent(EventBus.EXPORT_BOOK, it)
notifyExportBookChanged(it)
}
}
@@ -146,7 +150,12 @@ class ExportBookService : BaseService() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export)
.setSubText(getString(R.string.export_book))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity"))
.setContentIntent(
activityPendingIntent(
MainActivity.createCacheIntent(this),
"cacheActivity"
)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentText(notificationContentText)
.setDeleteIntent(servicePendingIntent<ExportBookService>(IntentAction.stop))
@@ -206,7 +215,7 @@ class ExportBookService : BaseService() {
AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
} finally {
exportProgress.remove(bookUrl)
postEvent(EventBus.EXPORT_BOOK, bookUrl)
notifyExportBookChanged(bookUrl)
}
}
}
@@ -234,7 +243,7 @@ class ExportBookService : BaseService() {
private suspend fun exportTxt(path: String, book: Book) {
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
notifyExportBookChanged(book.bookUrl)
val fileDoc = FileDoc.fromDir(path)
exportTxt(fileDoc, book)
}
@@ -296,13 +305,18 @@ class ExportBookService : BaseService() {
}.mapAsync(threads) { chapter ->
getExportData(book, chapter, contentProcessor, useReplace)
}.collectIndexed { index, result ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
notifyExportBookChanged(book.bookUrl)
exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second)
}
}
private fun notifyExportBookChanged(bookUrl: String) {
postEvent(EventBus.EXPORT_BOOK, bookUrl)
_exportBookUpdateFlow.tryEmit(bookUrl)
}
private fun getExportData(
book: Book,
chapter: BookChapter,
@@ -875,4 +889,4 @@ class ExportBookService : BaseService() {
return result
}
}
}
}
@@ -1,599 +0,0 @@
package io.legado.app.ui.book.cache
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.textfield.TextInputLayout
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
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.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ActivityCacheBookBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogSelectSectionExportBinding
import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.help.config.AppConfig
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.ACache
import io.legado.app.utils.FileDoc
import io.legado.app.utils.applyOpenTint
import io.legado.app.utils.applyTint
import io.legado.app.utils.checkWrite
import io.legado.app.utils.cnCompare
import io.legado.app.utils.enableCustomExport
import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
import io.legado.app.utils.iconItemOnLongClick
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.observeEvent
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startService
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.verificationField
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import kotlin.math.max
/**
* cache/download 缓存界面
*/
class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>(),
PopupMenu.OnMenuItemClickListener,
CacheAdapter.CallBack {
override val binding by viewBinding(ActivityCacheBookBinding::inflate)
override val viewModel by viewModels<CacheViewModel>()
private val exportBookPathKey = "exportBookPath"
private val exportTypes = arrayListOf("txt", "epub")
private val layoutManager by lazy { LinearLayoutManager(this) }
private val adapter by lazy { CacheAdapter(this, this) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val groupList: ArrayList<BookGroup> = arrayListOf()
private var groupId: Long = -1
private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) {
return@registerForActivityResult
}
if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(dirPath, result.requestCode)
} else {
startExport(dirPath, result.requestCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
binding.titleBar.subtitle = withContext(IO) {
appDb.bookGroupDao.getByID(groupId)?.groupName
?: getString(R.string.no_group)
}
}
initRecyclerView()
initGroupData()
initBookData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_cache, menu)
menu.iconItemOnLongClick(R.id.menu_download) {
PopupMenu(this, it).apply {
inflate(R.menu.book_cache_download)
this.menu.applyOpenTint(this@CacheActivity)
setOnMenuItemClickListener(this@CacheActivity)
}.show()
}
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_replace)?.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace)
// 菜单打开时读取状态[enableCustomExport]
menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport
menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName
menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav
menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile
menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook
menu.findItem(R.id.menu_export_type)?.title =
"${getString(R.string.export_type)}(${getTypeName()})"
menu.findItem(R.id.menu_export_charset)?.title =
"${getString(R.string.export_charset)}(${AppConfig.exportCharset})"
return super.onMenuOpened(featureId, menu)
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
/**
* 菜单按下回调
*/
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_download,
R.id.menu_download_after -> {
if (!CacheBook.isRun) sureCacheBook {
adapter.getItems().forEach { book ->
val indices = (book.durChapterIndex..book.lastChapterIndex).toList()
CacheBook.start(this@CacheActivity, book, indices)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_download_all -> {
if (!CacheBook.isRun) sureCacheBook {
adapter.getItems().forEach { book ->
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(this@CacheActivity, book, indices)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_export_all -> exportAll()
R.id.menu_enable_replace -> {
AppConfig.exportUseReplace = !AppConfig.exportUseReplace
item.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace)
val message = if (AppConfig.exportUseReplace) {
"替换净化功能已开启"
} else {
"替换净化功能已关闭"
}
toastOnUi(message)
}
// 更改菜单状态[enableCustomExport]
R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked
R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked
R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked
R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked
R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked
R.id.menu_export_folder -> {
selectExportFolder(-1)
}
R.id.menu_export_file_name -> alertExportFileName()
R.id.menu_export_type -> showExportTypeConfig()
R.id.menu_export_charset -> showCharsetConfig()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
else -> if (item.groupId == R.id.menu_group) {
binding.titleBar.subtitle = item.title
groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
initBookData()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return onCompatOptionsItemSelected(item)
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
}
private fun initBookData() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
appDb.bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter {
!it.isAudio
}
when (AppConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChange(
lifecycle, table = AppDatabase.BOOK_TABLE_NAME
).catch {
AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect { books ->
adapter.setItems(books)
viewModel.loadCacheFiles(books)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun notifyItemChanged(bookUrl: String) {
kotlin.runCatching {
adapter.getItems().forEachIndexed { index, book ->
if (bookUrl == book.bookUrl) {
adapter.notifyItemChanged(index, true)
return
}
}
}
}
override fun observeLiveBus() {
viewModel.upAdapterLiveData.observe(this) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.EXPORT_BOOK) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD_STATE) {
if (!CacheBook.isRun) {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_play)
item.setTitle(R.string.download_start)
}
menu?.applyTint(this)
} else {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
menu?.applyTint(this)
}
}
observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
viewModel.cacheChapters[book.bookUrl]?.add(chapter.url)
notifyItemChanged(book.bookUrl)
}
}
override fun export(position: Int) {
val path = ACache.get().getAsString(exportBookPathKey)
lifecycleScope.launch {
if (path.isNullOrEmpty() ||
withContext(IO) { !FileDoc.fromDir(path).checkWrite() }
) {
selectExportFolder(position)
} else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(path, position)
} else {
startExport(path, position)
}
}
}
private fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(-10)
} else {
startExport(path, -10)
}
}
override fun deleteDownload(book: Book) {
viewModel.clearCacheForBook(book)
notifyItemChanged(book.bookUrl)
}
/**
* 配置自定义导出对话框
*
* @param path 导出路径
* @param position book位置
* @author Discut
* @since 1.0.0
*/
private fun configExportSection(path: String, position: Int) {
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
return tryParesExportFileName(js) && etEpubFilename.text.toString()
.isNotEmpty()
}
fun enableLyEtEpubFilenameIcon() {
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM
lyEtEpubFilename.setEndIconOnClickListener {
adapter.getItem(position)?.run {
lyEtEpubFilename.helperText =
if (verifyExportFileNameJsStr(etEpubFilename.text.toString()))
"${resources.getString(R.string.result_analyzed)}: ${
getExportFileName(
"epub",
1,
etEpubFilename.text.toString()
)
}"
else "Error"
} ?: run {
lyEtEpubFilename.helperText = "Error"
AppLog.put("未找到书籍,position is $position")
}
}
}
etEpubSize.setText("1")
// lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
etEpubFilename.text?.append(AppConfig.episodeExportFileName)
// 存储解析文件名的jsStr
etEpubFilename.let {
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus)
return@setOnFocusChangeListener
it.text?.run {
if (verifyExportFileNameJsStr(toString())) {
AppConfig.episodeExportFileName = toString()
}
}
}
}
tvAllExport.setOnClickListener {
cbAllExport.callOnClick()
}
tvSelectExport.setOnClickListener {
cbSelectExport.callOnClick()
}
cbSelectExport.setOnCheckedChangeListener{ _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = true
etInputScope.isEnabled = true
etEpubFilename.isEnabled = true
enableLyEtEpubFilenameIcon()
cbAllExport.isChecked = false
}
}
cbAllExport.setOnCheckedChangeListener{ _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = false
etInputScope.isEnabled = false
etEpubFilename.isEnabled = false
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
cbSelectExport.isChecked = false
}
}
etInputScope.onFocusChangeListener =
View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
etInputScope.hint = "1-5,8,10-18"
} else {
etInputScope.hint = ""
}
}
// 默认选择自定义导出
cbSelectExport.callOnClick()
}
val alertDialog = alert(titleResource = R.string.select_section_export) {
customView { alertBinding.root }
positiveButton(R.string.ok)
cancelButton()
}
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
alertBinding.apply {
if (cbAllExport.isChecked) {
startExport(path, position)
alertDialog.hide()
return@apply
}
val epubScope = etInputScope.text.toString()
if (!verificationField(epubScope)) {
etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围"
return@apply
}
etInputScope.error = null
val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1
adapter.getItem(position)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", path)
putExtra("epubSize", epubSize)
putExtra("epubScope", epubScope)
}
}
alertDialog.hide()
}
}
}
private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) {
default.add(SelectItem(path, -1))
}
exportDir.launch {
otherActions = default
requestCode = exportPosition
}
}
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 ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
} else {
toastOnUi(R.string.no_book)
}
} else if (exportPosition >= 0) {
adapter.getItem(exportPosition)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
}
}
@SuppressLint("SetTextI18n")
private fun alertExportFileName() {
alert(R.string.export_file_name) {
val message = """
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editLayout.hint = "书名:《{name}》 作者:{author}"
editView.setText(AppConfig.bookExportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookExportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
private fun sureCacheBook(action: () -> Unit) {
alert(R.string.draw) {
setMessage(R.string.sure_cache_book)
noButton()
yesButton {
action.invoke()
}
}
}
private fun getTypeName(): String {
return exportTypes.getOrElse(AppConfig.exportType) {
exportTypes[0]
}
}
private fun showExportTypeConfig() {
selector(R.string.export_type, exportTypes) { _, i ->
AppConfig.exportType = i
}
}
private fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset name"
editView.setFilterValues(charsets)
editView.setText(AppConfig.exportCharset)
}
customView { alertBinding.root }
okButton {
AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8"
}
cancelButton()
}
}
override val cacheChapters: HashMap<String, HashSet<String>>
get() = viewModel.cacheChapters
override fun exportProgress(bookUrl: String): Int? {
return ExportBookService.exportProgress[bookUrl]
}
override fun exportMsg(bookUrl: String): String? {
return ExportBookService.exportMsg[bookUrl]
}
}
+12 -38
View File
@@ -1,8 +1,6 @@
package io.legado.app.ui.book.cache
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
@@ -15,7 +13,6 @@ import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemDownloadBinding
import io.legado.app.help.book.isLocal
import io.legado.app.model.CacheBook
import io.legado.app.utils.gone
import io.legado.app.utils.visible
@@ -72,8 +69,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
} else {
val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0
tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum)
val cacheTask = CacheBook.cacheBookMap[item.bookUrl]
if (cacheTask != null && !cacheTask.isStop()) {
if (callBack.isBookDownloading(item.bookUrl)) {
val progress = if (item.totalChapterNum > 0) cacheSize * 100 / item.totalChapterNum else 0
progressDownload.progress = progress
progressDownload.visible()
@@ -90,17 +86,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
binding.run {
ivDownload.setOnClickListener {
getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
} else {
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(context, book, indices)
}
} ?: let {
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(context, book, indices)
}
callBack.toggleBookDownload(book)
}
}
tvExport.setOnClickListener {
@@ -108,18 +94,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
}
ivDelete.setOnClickListener {
getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
Handler(Looper.getMainLooper()).postDelayed({
callBack.deleteDownload(book)
}, 10)
} else {
callBack.deleteDownload(book)
}
} ?: run {
callBack.deleteDownload(book)
}
callBack.deleteDownload(book)
}
}
@@ -131,15 +106,12 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
button.gone()
} else {
button.visible()
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp)
} else {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play)
}
} ?: run {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play)
val iconRes = if (callBack.isBookDownloading(book.bookUrl)) {
R.drawable.ic_stop_black_24dp
} else {
R.drawable.ic_play
}
button.icon = ContextCompat.getDrawable(context, iconRes)
}
}
@@ -162,11 +134,13 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
}
interface CallBack {
val cacheChapters: HashMap<String, HashSet<String>>
val cacheChapters: Map<String, Set<String>>
fun isBookDownloading(bookUrl: String): Boolean
fun toggleBookDownload(book: Book)
fun export(position: Int)
fun exportProgress(bookUrl: String): Int?
fun exportMsg(bookUrl: String): String?
fun deleteDownload(book: Book)
}
}
}
@@ -0,0 +1,769 @@
package io.legado.app.ui.book.cache
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.constant.IntentAction
import io.legado.app.data.entities.Book
import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.utils.ACache
import io.legado.app.utils.FileDoc
import io.legado.app.utils.checkWrite
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.startService
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.verificationField
import org.koin.androidx.compose.koinViewModel
@Composable
fun CacheRouteScreen(
groupId: Long,
onBackClick: () -> Unit,
viewModel: CacheViewModel = koinViewModel()
) {
LaunchedEffect(groupId) {
viewModel.dispatch(CacheIntent.Initialize(groupId))
}
CacheScreen(viewModel = viewModel, onBackClick = onBackClick)
}
@Composable
private fun CacheScreen(
viewModel: CacheViewModel,
onBackClick: () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var showMenu by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var showDownloadConfirmDialog by remember { mutableStateOf(false) }
var showDownloadAllConfirmDialog by remember { mutableStateOf(false) }
var showExportTypeDialog by remember { mutableStateOf(false) }
var showExportFileNameDialog by remember { mutableStateOf(false) }
var showCharsetDialog by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) }
var showCustomExportDialog by remember { mutableStateOf(false) }
var pendingExportBookUrl by remember { mutableStateOf<String?>(null) }
var pendingExportAll by remember { mutableStateOf(false) }
var customExportPath by remember { mutableStateOf("") }
var customExportBook by remember { mutableStateOf<Book?>(null) }
var customExportAllChapter by remember { mutableStateOf(false) }
var customEpubScopeInput by remember { mutableStateOf("") }
var customEpubScopeError by remember { mutableStateOf<String?>(null) }
var customEpubSizeInput by remember { mutableStateOf("1") }
var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) }
var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) }
var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
val exportBookPathKey = remember { "exportBookPath" }
val exportTypes = remember { arrayListOf("txt", "epub") }
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val exportFolderText = stringResource(R.string.export_folder)
val exportAllText = stringResource(R.string.export_all)
val exportChapterIndexText = stringResource(R.string.export_chapter_index)
val fileContainsNumberText = stringResource(R.string.file_contains_number)
val exportFileNameText = stringResource(R.string.export_file_name)
val resultAnalyzedText = stringResource(R.string.result_analyzed)
val errorScopeInputText = stringResource(R.string.error_scope_input)
val exportFileNameHintText = "书名:《{name}》 作者:{author}"
val exportFileNameHelpText = """
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
val exportDir = rememberLauncherForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) return@rememberLauncherForActivityResult
if (pendingExportAll) {
state.books.forEach { book ->
startExport(context, dirPath, book, state.exportConfig.exportType)
}
return@rememberLauncherForActivityResult
}
val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
val book = state.books.firstOrNull { it.bookUrl == bookUrl } ?: return@rememberLauncherForActivityResult
if (state.exportConfig.enableCustomExport) {
customExportPath = dirPath
customExportBook = book
customExportAllChapter = false
customEpubScopeInput = ""
customEpubScopeError = null
customEpubSizeInput = "1"
customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
showCustomExportDialog = true
} else {
startExport(context, dirPath, book, state.exportConfig.exportType)
}
}
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CacheEffect.ShowMessage -> context.toastOnUi(effect.message)
is CacheEffect.NotifyBookChanged -> Unit
}
}
}
fun selectExportFolder(bookUrl: String? = null, forAll: Boolean = false) {
pendingExportBookUrl = bookUrl
pendingExportAll = forAll
showFilePickerSheet = true
}
fun exportBook(book: Book) {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
selectExportFolder(book.bookUrl)
} else if (state.exportConfig.enableCustomExport) {
customExportPath = path
customExportBook = book
customExportAllChapter = false
customEpubScopeInput = ""
customEpubScopeError = null
customEpubSizeInput = "1"
customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
showCustomExportDialog = true
} else {
startExport(context, path, book, state.exportConfig.exportType)
}
}
fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(forAll = true)
} else {
state.books.forEach { book ->
startExport(context, path, book, state.exportConfig.exportType)
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
GlassMediumFlexibleTopAppBar(
title = state.groupName ?: stringResource(R.string.offline_cache),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
},
actions = {
if (state.groupList.isNotEmpty()) {
TopBarActionButton(
onClick = { showGroupMenu = true },
imageVector = AppIcons.Filter,
contentDescription = null
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
state.groupList.forEach { group ->
RoundDropdownMenuItem(
text = "${stringResource(R.string.group)}: ${group.groupName}",
isSelected = group.groupId == state.groupId,
onClick = {
dismiss()
viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId))
}
)
}
}
}
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
RoundDropdownMenuItem(
text = stringResource(R.string.download_all),
onClick = {
showMenu = false
if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload)
} else {
showDownloadAllConfirmDialog = true
}
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_all),
onClick = { showMenu = false; exportAll() }
)
PillDivider()
RoundDropdownMenuItem(
text = stringResource(R.string.export_folder),
onClick = { showMenu = false; selectExportFolder() }
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_file_name),
onClick = {
showMenu = false
exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
showExportFileNameDialog = true
}
)
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
onClick = {
showMenu = false
showExportTypeDialog = true
}
)
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
onClick = {
showMenu = false
exportCharsetInput = state.exportConfig.exportCharset
showCharsetDialog = true
}
)
PillDivider()
RoundDropdownMenuItem(
text = "替换净化",
isSelected = state.exportConfig.exportUseReplace,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
)
}
)
RoundDropdownMenuItem(
text = "自定义导出",
isSelected = state.exportConfig.enableCustomExport,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
)
}
)
RoundDropdownMenuItem(
text = "导出包含章节名",
isSelected = !state.exportConfig.exportNoChapterName,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
)
}
)
RoundDropdownMenuItem(
text = "导出到WebDav",
isSelected = state.exportConfig.exportToWebDav,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
)
}
)
RoundDropdownMenuItem(
text = "导出插图文件",
isSelected = state.exportConfig.exportPictureFile,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
)
}
)
RoundDropdownMenuItem(
text = "并行导出",
isSelected = state.exportConfig.parallelExportBook,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
)
}
)
PillDivider()
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showMenu = false
showLogSheet = true
}
)
}
}
)
},
floatingActionButton = {
AppFloatingActionButton(
onClick = {
if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload)
} else {
showDownloadConfirmDialog = true
}
}
) {
Icon(
imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null
)
}
}
) { paddingValues ->
val renderVersion by rememberUpdatedState(state.cacheVersion)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.books, key = { it.bookUrl }) { book ->
val cacheCount = remember(renderVersion, book.bookUrl) {
viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
}
val isDownloading = remember(renderVersion, book.bookUrl) {
viewModel.isBookDownloading(book.bookUrl)
}
val exportMsg = remember(renderVersion, book.bookUrl) {
ExportBookService.exportMsg[book.bookUrl]
}
val exportProgress = remember(renderVersion, book.bookUrl) {
ExportBookService.exportProgress[book.bookUrl]
}
GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
AppText(
text = book.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum))
}
AppText(
text = stringResource(R.string.author_show, book.getRealAuthor()),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppText(
text = if (book.isLocal) {
stringResource(R.string.local_book)
} else {
stringResource(R.string.download_count, cacheCount, book.totalChapterNum)
}
)
if (isDownloading && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
}
if (exportMsg != null) {
AppText(text = exportMsg, modifier = Modifier.padding(top = 6.dp))
} else if (exportProgress != null && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (exportProgress.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
SmallTonalIconButton(
onClick = {
if (!book.isLocal) {
viewModel.dispatch(CacheIntent.ToggleBookDownload(book))
}
},
imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null
)
SmallTonalIconButton(
onClick = { exportBook(book) },
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Upload,
contentDescription = null
)
SmallTonalIconButton(
onClick = {
viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl))
viewModel.dispatch(CacheIntent.ClearBookCache(book))
},
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Delete,
contentDescription = null
)
}
}
}
}
}
}
FilePickerSheet(
show = showFilePickerSheet,
onDismissRequest = { showFilePickerSheet = false },
title = exportFolderText,
onSelectSysDir = {
showFilePickerSheet = false
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) default.add(SelectItem(path, -1))
exportDir.launch {
mode = HandleFileContract.DIR_SYS
title = exportFolderText
otherActions = default
requestCode = 0
}
}
)
AppAlertDialog(
show = showDownloadConfirmDialog,
onDismissRequest = { showDownloadConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDownloadConfirmDialog = false
viewModel.dispatch(
CacheIntent.StartDownloadForVisibleBooks(
books = state.books,
downloadAllChapters = false
)
)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDownloadConfirmDialog = false }
)
AppAlertDialog(
show = showDownloadAllConfirmDialog,
onDismissRequest = { showDownloadAllConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDownloadAllConfirmDialog = false
viewModel.dispatch(
CacheIntent.StartDownloadForVisibleBooks(
books = state.books,
downloadAllChapters = true
)
)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDownloadAllConfirmDialog = false }
)
OptionSheet(
show = showExportTypeDialog,
onDismissRequest = { showExportTypeDialog = false },
title = stringResource(R.string.export_type)
) {
exportTypes.forEachIndexed { index, type ->
OptionCard(
icon = if (type == "epub") Icons.Default.Upload else Icons.Default.Download,
text = type,
onClick = {
viewModel.dispatch(CacheIntent.SetExportType(index))
showExportTypeDialog = false
}
)
}
}
AppAlertDialog(
show = showExportFileNameDialog,
onDismissRequest = { showExportFileNameDialog = false },
title = stringResource(R.string.export_file_name),
content = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AppText(text = exportFileNameHelpText)
AppTextField(
value = exportFileNameInput,
onValueChange = { exportFileNameInput = it },
modifier = Modifier.fillMaxWidth(),
label = exportFileNameText,
placeholder = { AppText(exportFileNameHintText) }
)
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
viewModel.dispatch(CacheIntent.SetBookExportFileName(exportFileNameInput))
showExportFileNameDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showExportFileNameDialog = false }
)
AppAlertDialog(
show = showCharsetDialog,
onDismissRequest = { showCharsetDialog = false },
title = stringResource(R.string.set_charset),
content = {
AppTextField(
value = exportCharsetInput,
onValueChange = { exportCharsetInput = it },
modifier = Modifier.fillMaxWidth(),
label = stringResource(R.string.set_charset)
)
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
viewModel.dispatch(
CacheIntent.SetExportCharset(
exportCharsetInput.ifBlank { "UTF-8" }
)
)
showCharsetDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showCharsetDialog = false }
)
AppLogSheet(
show = showLogSheet,
onDismissRequest = { showLogSheet = false }
)
val currentCustomBook = customExportBook
AppAlertDialog(
show = showCustomExportDialog && currentCustomBook != null,
onDismissRequest = {
showCustomExportDialog = false
customEpubScopeError = null
},
title = stringResource(R.string.select_section_export),
content = {
val episodeTemplateValid = customEpisodeExportNameInput.isNotBlank()
&& tryParesExportFileName(customEpisodeExportNameInput)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(
selected = customExportAllChapter,
onClick = { customExportAllChapter = true }
)
AppText(
text = exportAllText,
modifier = Modifier.padding(top = 12.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(
selected = !customExportAllChapter,
onClick = { customExportAllChapter = false }
)
AppText(
text = exportChapterIndexText,
modifier = Modifier.padding(top = 12.dp)
)
}
if (!customExportAllChapter) {
AppTextField(
value = customEpubScopeInput,
onValueChange = {
customEpubScopeInput = it
customEpubScopeError = null
},
modifier = Modifier.fillMaxWidth(),
label = exportChapterIndexText,
placeholder = { AppText("1-5,8,10-18") },
supportingText = {
customEpubScopeError?.let { msg ->
AppText(text = msg)
}
},
isError = customEpubScopeError != null
)
AppTextField(
value = customEpubSizeInput,
onValueChange = { customEpubSizeInput = it },
modifier = Modifier.fillMaxWidth(),
label = fileContainsNumberText
)
AppTextField(
value = customEpisodeExportNameInput,
onValueChange = { customEpisodeExportNameInput = it },
modifier = Modifier.fillMaxWidth(),
label = exportFileNameText,
placeholder = { AppText(exportFileNameHintText) }
)
if (episodeTemplateValid) {
AppText(
text = "$resultAnalyzedText: ${
currentCustomBook?.getExportFileName(
"epub",
1,
customEpisodeExportNameInput
).orEmpty()
}"
)
} else if (customEpisodeExportNameInput.isNotBlank()) {
AppText(text = "Error")
}
}
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
val book = customExportBook ?: return@AppAlertDialog
if (customExportAllChapter) {
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", customExportPath)
}
showCustomExportDialog = false
return@AppAlertDialog
}
if (!verificationField(customEpubScopeInput)) {
customEpubScopeError = errorScopeInputText
return@AppAlertDialog
}
customEpubScopeError = null
if (customEpisodeExportNameInput.isNotBlank() && tryParesExportFileName(
customEpisodeExportNameInput
)
) {
viewModel.dispatch(CacheIntent.SetEpisodeExportFileName(customEpisodeExportNameInput))
}
val epubSize = customEpubSizeInput.toIntOrNull()?.coerceAtLeast(1) ?: 1
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", customExportPath)
putExtra("epubSize", epubSize)
putExtra("epubScope", customEpubScopeInput)
}
showCustomExportDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = {
showCustomExportDialog = false
customEpubScopeError = null
}
)
}
private fun startExport(
context: android.content.Context,
path: String,
book: Book,
exportTypeIndex: Int
) {
val exportType = if (exportTypeIndex == 1) "epub" else "txt"
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
@@ -1,32 +1,299 @@
package io.legado.app.ui.book.cache
import android.app.Application
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.utils.sendValue
import io.legado.app.model.CacheBook
import io.legado.app.service.ExportBookService
import io.legado.app.ui.config.cacheConfig.CacheConfig
import io.legado.app.utils.cnCompare
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.math.max
data class CacheExportConfig(
val exportUseReplace: Boolean = true,
val enableCustomExport: Boolean = false,
val exportNoChapterName: Boolean = false,
val exportToWebDav: Boolean = false,
val exportPictureFile: Boolean = false,
val parallelExportBook: Boolean = false,
val exportType: Int = 0,
val exportCharset: String = "UTF-8",
val bookExportFileName: String? = null,
val episodeExportFileName: String = ""
)
class CacheViewModel(application: Application) : BaseViewModel(application) {
val upAdapterLiveData = MutableLiveData<String>()
data class CacheUiState(
val groupId: Long = -1,
val groupName: String? = null,
val groupList: List<BookGroup> = emptyList(),
val books: List<Book> = emptyList(),
val isDownloadRunning: Boolean = false,
val cacheVersion: Long = 0,
val exportConfig: CacheExportConfig = CacheExportConfig()
)
private var loadChapterCoroutine: Coroutine<Unit>? = null
val cacheChapters = hashMapOf<String, HashSet<String>>()
sealed interface CacheIntent {
data class Initialize(val groupId: Long) : CacheIntent
data class ChangeGroup(val groupId: Long) : CacheIntent
data class StartDownloadForVisibleBooks(
val books: List<Book>,
val downloadAllChapters: Boolean
) : CacheIntent
data object StopDownload : CacheIntent
data class ToggleBookDownload(val book: Book) : CacheIntent
data class DeleteBookDownload(val bookUrl: String) : CacheIntent
data class ClearBookCache(val book: Book) : CacheIntent
data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
data class SetExportToWebDav(val enabled: Boolean) : CacheIntent
data class SetExportPictureFile(val enabled: Boolean) : CacheIntent
data class SetParallelExportBook(val enabled: Boolean) : CacheIntent
data class SetExportType(val type: Int) : CacheIntent
data class SetExportCharset(val charset: String) : CacheIntent
data class SetBookExportFileName(val fileName: String?) : CacheIntent
data class SetEpisodeExportFileName(val fileName: String) : CacheIntent
}
fun loadCacheFiles(books: List<Book>) {
loadChapterCoroutine?.cancel()
loadChapterCoroutine = execute {
sealed interface CacheEffect {
data class NotifyBookChanged(val bookUrl: String) : CacheEffect
data class ShowMessage(val message: String) : CacheEffect
}
class CacheViewModel(
application: Application,
private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao,
private val bookChapterDao: BookChapterDao,
val cacheConfig: CacheConfig
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(CacheUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<CacheEffect>(extraBufferCapacity = 32)
val effects = _effects.asSharedFlow()
private val cacheChapters = hashMapOf<String, HashSet<String>>()
private var booksJob: Job? = null
private var groupsJob: Job? = null
private var observersStarted = false
fun dispatch(intent: CacheIntent) {
when (intent) {
is CacheIntent.Initialize -> initialize(intent.groupId)
is CacheIntent.ChangeGroup -> changeGroup(intent.groupId)
is CacheIntent.StartDownloadForVisibleBooks -> startDownloadForVisibleBooks(
intent.books,
intent.downloadAllChapters
)
CacheIntent.StopDownload -> CacheBook.stop(context)
is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book)
is CacheIntent.SetExportUseReplace -> {
cacheConfig.exportUseReplace = intent.enabled
syncExportConfig()
val msg = if (intent.enabled) "替换净化功能已开启" else "替换净化功能已关闭"
_effects.tryEmit(CacheEffect.ShowMessage(msg))
}
is CacheIntent.SetEnableCustomExport -> {
cacheConfig.enableCustomExport = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportNoChapterName -> {
cacheConfig.exportNoChapterName = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportToWebDav -> {
cacheConfig.exportToWebDav = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportPictureFile -> {
cacheConfig.exportPictureFile = intent.enabled
syncExportConfig()
}
is CacheIntent.SetParallelExportBook -> {
cacheConfig.parallelExportBook = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportType -> {
cacheConfig.exportType = intent.type
syncExportConfig()
}
is CacheIntent.SetExportCharset -> {
cacheConfig.exportCharset = intent.charset
syncExportConfig()
}
is CacheIntent.SetBookExportFileName -> {
cacheConfig.bookExportFileName = intent.fileName
syncExportConfig()
}
is CacheIntent.SetEpisodeExportFileName -> {
cacheConfig.episodeExportFileName = intent.fileName
syncExportConfig()
}
}
}
fun getCacheChapters(bookUrl: String): Set<String>? = cacheChapters[bookUrl]
fun getAllCacheChapters(): Map<String, Set<String>> = cacheChapters
fun isBookDownloading(bookUrl: String): Boolean {
return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
}
private fun initialize(groupId: Long) {
_uiState.update { it.copy(groupId = groupId) }
syncExportConfig()
observeGroups()
observeBooks(groupId)
observeDownloadAndExportChanges()
refreshGroupName(groupId)
}
private fun changeGroup(groupId: Long) {
_uiState.update { it.copy(groupId = groupId) }
observeBooks(groupId)
refreshGroupName(groupId)
}
private fun observeGroups() {
groupsJob?.cancel()
groupsJob = viewModelScope.launch {
bookGroupDao.flowAll().collect { groups ->
_uiState.update { it.copy(groupList = groups) }
}
}
}
private fun observeBooks(groupId: Long) {
booksJob?.cancel()
booksJob = viewModelScope.launch {
bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter { !it.isAudio }
when (cacheConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.collect { books ->
_uiState.update { it.copy(books = books) }
loadCacheFiles(books)
}
}
}
private fun observeDownloadAndExportChanges() {
if (observersStarted) return
observersStarted = true
viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter ->
onChapterCached(chapter)
}
}
viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
}
}
viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
}
}
viewModelScope.launch {
CacheBook.downloadSummaryFlow.collect {
syncDownloadRunning()
}
}
viewModelScope.launch {
ExportBookService.exportBookUpdateFlow.collect { bookUrl ->
emitBookChanged(bookUrl)
}
}
}
private fun syncExportConfig() {
_uiState.update {
it.copy(
exportConfig = CacheExportConfig(
exportUseReplace = cacheConfig.exportUseReplace,
enableCustomExport = cacheConfig.enableCustomExport,
exportNoChapterName = cacheConfig.exportNoChapterName,
exportToWebDav = cacheConfig.exportToWebDav,
exportPictureFile = cacheConfig.exportPictureFile,
parallelExportBook = cacheConfig.parallelExportBook,
exportType = cacheConfig.exportType,
exportCharset = cacheConfig.exportCharset,
bookExportFileName = cacheConfig.bookExportFileName,
episodeExportFileName = cacheConfig.episodeExportFileName
)
)
}
}
private fun syncDownloadRunning() {
_uiState.update { it.copy(isDownloadRunning = CacheBook.isRun) }
}
private fun refreshGroupName(groupId: Long) {
execute {
val title = bookGroupDao.getByID(groupId)?.groupName
title ?: context.getString(io.legado.app.R.string.no_group)
}.onSuccess { groupName ->
_uiState.update { it.copy(groupName = groupName) }
}
}
private fun loadCacheFiles(books: List<Book>) {
execute {
books.forEach { book ->
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
val chapterCaches = hashSetOf<String>()
val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isNotEmpty()) {
appDb.bookChapterDao.getChapterList(book.bookUrl).also {
bookChapterDao.getChapterList(book.bookUrl).also {
book.totalChapterNum = it.size
}.forEach { chapter ->
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
@@ -35,17 +302,51 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
}
}
cacheChapters[book.bookUrl] = chapterCaches
upAdapterLiveData.sendValue(book.bookUrl)
emitBookChanged(book.bookUrl)
}
ensureActive()
}
}
}
fun clearCacheForBook(book: Book) {
BookHelp.clearCache(book)
cacheChapters[book.bookUrl] = hashSetOf()
upAdapterLiveData.sendValue(book.bookUrl)
private fun onChapterCached(chapter: BookChapter) {
val bookUrl = chapter.bookUrl
val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() }
chapterSet.add(chapter.url)
emitBookChanged(bookUrl)
}
}
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
books.forEach { book ->
val indices = if (downloadAllChapters) {
(0..book.lastChapterIndex).toList()
} else {
(book.durChapterIndex..book.lastChapterIndex).toList()
}
CacheBook.start(context, book, indices)
}
syncDownloadRunning()
}
private fun toggleBookDownload(book: Book) {
if (book.isLocal) return
if (isBookDownloading(book.bookUrl)) {
CacheBook.remove(context, book.bookUrl)
} else {
CacheBook.start(context, book, (0..book.lastChapterIndex).toList())
}
syncDownloadRunning()
}
private fun clearCacheForBook(book: Book) {
BookHelp.clearCache(book)
cacheChapters[book.bookUrl] = hashSetOf()
emitBookChanged(book.bookUrl)
}
private fun emitBookChanged(bookUrl: String) {
_uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
_effects.tryEmit(CacheEffect.NotifyBookChanged(bookUrl))
}
}
@@ -12,8 +12,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.config.AppConfig
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.mapParallelSafe
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
@@ -36,7 +36,7 @@ import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var searchSuccess: ((SearchBook) -> Unit)? = null
private var upAdapter: (() -> Unit)? = null
@@ -25,6 +25,7 @@ import io.legado.app.help.config.SourceConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.SourceHelp
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.internString
import io.legado.app.utils.mapParallel
import io.legado.app.utils.mapParallelSafe
@@ -59,7 +60,7 @@ import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount
private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData<Boolean>()
private val _isSearching = MutableStateFlow(false)
@@ -28,6 +28,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.ReadManga
import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.postEvent
@@ -201,7 +202,7 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
}.onStart {
// 自动换源
}.mapParallelSafe(AppConfig.threadCount) { source ->
}.mapParallelSafe(OtherConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
@@ -395,4 +396,4 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
context.toastOnUi("添加书籍失败")
}
}
}
}
@@ -31,6 +31,7 @@ import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.postEvent
@@ -307,7 +308,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
}
}.onStart {
ReadBook.upMsg(context.getString(R.string.source_auto_changing))
}.mapParallelSafe(AppConfig.threadCount) { source ->
}.mapParallelSafe(OtherConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
@@ -242,20 +242,15 @@ fun SearchScreen(
onSearch = submitSearch,
placeholder = searchLabel,
trailingIcon = {
Row(
modifier = Modifier.padding(end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (queryInput.isNotEmpty()) {
TopBarActionButton(
onClick = {
queryInput = ""
viewModel.onIntent(SearchIntent.UpdateQuery(""))
},
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.clear)
)
}
if (queryInput.isNotEmpty()) {
TopBarActionButton(
onClick = {
queryInput = ""
viewModel.onIntent(SearchIntent.UpdateQuery(""))
},
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.clear)
)
}
},
)
@@ -0,0 +1,36 @@
package io.legado.app.ui.config.cacheConfig
import io.legado.app.constant.PreferKey
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.ui.config.prefDelegate
class CacheConfig(
private val bookGroupDao: BookGroupDao
) {
var bookExportFileName by prefDelegate<String?>(PreferKey.bookExportFileName, null)
var episodeExportFileName by prefDelegate(PreferKey.episodeExportFileName, "")
var exportCharset by prefDelegate(PreferKey.exportCharset, "UTF-8")
var exportUseReplace by prefDelegate(PreferKey.exportUseReplace, true)
var exportToWebDav by prefDelegate(PreferKey.exportToWebDav, false)
var exportNoChapterName by prefDelegate(PreferKey.exportNoChapterName, false)
var enableCustomExport by prefDelegate(PreferKey.enableCustomExport, false)
var exportType by prefDelegate(PreferKey.exportType, 0)
var exportPictureFile by prefDelegate(PreferKey.exportPictureFile, false)
var parallelExportBook by prefDelegate(PreferKey.parallelExportBook, false)
var bookshelfSort by prefDelegate(PreferKey.bookshelfSort, 0)
fun getBookSortByGroupId(groupId: Long): Int {
return bookGroupDao.getByID(groupId)?.getRealBookSort() ?: bookshelfSort
}
}
@@ -13,9 +13,13 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@@ -37,8 +41,14 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog
import io.legado.app.ui.about.UpdateDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.import.local.ImportBookScreen
import io.legado.app.ui.book.import.remote.RemoteBookScreen
import io.legado.app.ui.book.search.SearchIntent
import io.legado.app.ui.book.search.SearchScreen
import io.legado.app.ui.book.search.SearchViewModel
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.book.cache.CacheRouteScreen
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.config.ConfigNavScreen
import io.legado.app.ui.config.ConfigTag
@@ -62,6 +72,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -82,8 +93,13 @@ open class MainActivity : BaseComposeActivity() {
private const val ROUTE_SETTINGS_BACKUP = "settings/backup"
private const val ROUTE_IMPORT_LOCAL = "import/local"
private const val ROUTE_IMPORT_REMOTE = "import/remote"
private const val ROUTE_CACHE = "cache"
private const val ROUTE_SEARCH = "search"
private const val ROUTE_RSS_SORT = "rss/sort"
private const val ROUTE_RSS_READ = "rss/read"
private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id"
private const val EXTRA_SEARCH_KEY = "extra_search_key"
private const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
@@ -146,6 +162,28 @@ open class MainActivity : BaseComposeActivity() {
}
}
fun createCacheIntent(
context: Context,
groupId: Long = -1L
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_CACHE)
putExtra(EXTRA_CACHE_GROUP_ID, groupId)
}
}
fun createSearchIntent(
context: Context,
key: String? = null,
scopeRaw: String? = null
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_SEARCH)
putExtra(EXTRA_SEARCH_KEY, key)
putExtra(EXTRA_SEARCH_SCOPE, scopeRaw)
}
}
private fun routeForConfigTag(configTag: String?): String {
return when (configTag) {
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
@@ -191,6 +229,15 @@ open class MainActivity : BaseComposeActivity() {
@Serializable
private data object MainRouteImportRemote : MainRoute
@Serializable
private data class MainRouteCache(val groupId: Long) : MainRoute
@Serializable
private data class MainRouteSearch(
val key: String?,
val scopeRaw: String? = null
) : MainRoute
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -332,12 +379,23 @@ open class MainActivity : BaseComposeActivity() {
onOpenSettings = {
navigateToRoute(backStack, MainRouteSettings)
},
onNavigateToSearch = { key ->
navigateToRoute(
backStack,
MainRouteSearch(
key = key?.trim()?.takeIf { it.isNotEmpty() }
)
)
},
onNavigateToRemoteImport = {
navigateToRoute(backStack, MainRouteImportRemote)
},
onNavigateToLocalImport = {
navigateToRoute(backStack, MainRouteImportLocal)
},
onNavigateToCache = { groupId ->
navigateToRoute(backStack, MainRouteCache(groupId))
},
onNavigateToRssSort = { sourceUrl, sortUrl, key ->
navigateToRoute(
backStack,
@@ -405,6 +463,64 @@ open class MainActivity : BaseComposeActivity() {
)
}
entry<MainRouteCache> { route ->
CacheRouteScreen(
groupId = route.groupId,
onBackClick = { navigateBack(backStack) }
)
}
entry<MainRouteSearch> { route ->
val searchViewModel = koinViewModel<SearchViewModel>()
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(route.key, route.scopeRaw, searchViewModel) {
searchViewModel.onIntent(
SearchIntent.Initialize(
key = route.key,
scopeRaw = route.scopeRaw
)
)
}
DisposableEffect(lifecycleOwner, searchViewModel) {
searchViewModel.onIntent(SearchIntent.ResumeEngine)
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
searchViewModel.onIntent(SearchIntent.ResumeEngine)
}
Lifecycle.Event.ON_PAUSE -> {
searchViewModel.onIntent(SearchIntent.PauseEngine)
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
searchViewModel.onIntent(SearchIntent.PauseEngine)
}
}
SearchScreen(
viewModel = searchViewModel,
onBack = { navigateBack(backStack) },
onOpenBookInfo = { name, author, bookUrl ->
this@MainActivity.startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
},
onOpenSourceManage = {
this@MainActivity.startActivity<BookSourceActivity>()
}
)
}
entry<MainRouteRssSort> { route ->
RssSortRouteScreen(
sourceUrl = route.sourceUrl,
@@ -469,7 +585,18 @@ open class MainActivity : BaseComposeActivity() {
}
MainRouteImportLocal,
MainRouteImportRemote -> {
MainRouteImportRemote,
is MainRouteCache -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
is MainRouteSearch -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
@@ -660,15 +787,20 @@ open class MainActivity : BaseComposeActivity() {
private fun resolveStartRoute(route: String?): MainRoute {
return when (route) {
"main" -> MainRouteHome
"settings" -> MainRouteSettings
"settings/other" -> MainRouteSettingsOther
"settings/read" -> MainRouteSettingsRead
"settings/cover" -> MainRouteSettingsCover
"settings/theme" -> MainRouteSettingsTheme
"settings/backup" -> MainRouteSettingsBackup
"import/local" -> MainRouteImportLocal
"import/remote" -> MainRouteImportRemote
ROUTE_MAIN -> MainRouteHome
ROUTE_SETTINGS -> MainRouteSettings
ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther
ROUTE_SETTINGS_READ -> MainRouteSettingsRead
ROUTE_SETTINGS_COVER -> MainRouteSettingsCover
ROUTE_SETTINGS_THEME -> MainRouteSettingsTheme
ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
ROUTE_IMPORT_REMOTE -> MainRouteImportRemote
ROUTE_CACHE -> MainRouteCache(intent?.getLongExtra(EXTRA_CACHE_GROUP_ID, -1L) ?: -1L)
ROUTE_SEARCH -> MainRouteSearch(
key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE)
)
else -> MainRouteHome
}
}
@@ -57,7 +57,6 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -90,8 +89,10 @@ fun MainScreen(
viewModel: MainViewModel = koinViewModel(),
useRail: Boolean,
onOpenSettings: () -> Unit,
onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) {
@@ -174,7 +175,7 @@ fun MainScreen(
ExtendedFloatingActionButton(
modifier = Modifier.padding(start = 20.dp),
onClick = { context.startActivity<SearchActivity>() },
onClick = { onNavigateToSearch(null) },
expanded = expanded,
icon = { Icon(Icons.Default.Search, contentDescription = null) },
text = { AppText(stringResource(R.string.search)) }
@@ -377,8 +378,10 @@ fun MainScreen(
putExtra("bookUrl", book.bookUrl)
}
},
onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport,
onNavigateToLocalImport = onNavigateToLocalImport
onNavigateToLocalImport = onNavigateToLocalImport,
onNavigateToCache = onNavigateToCache
)
MainDestination.Explore -> ExploreScreen()
@@ -1,9 +1,10 @@
package io.legado.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@@ -11,20 +12,41 @@ import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@Composable
fun rememberOpaqueColorScheme(): ColorScheme {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
val currentTheme = LocalLegadoThemeColors.current
val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme)
val isDark = currentTheme.isDark
val isPureBlack = ThemeConfig.isPureBlack
val hasImageBg = ThemeConfig.hasImageBg(isDark)
val paletteStyle = ThemeConfig.paletteStyle
val materialVersion = ThemeConfig.materialVersion
val seedColorInt = currentTheme.seedColor
.takeUnless { it == Color.Unspecified }
?.toArgb()
return remember(context, appThemeMode, isDark, isPureBlack, hasImageBg, paletteStyle) {
getColorScheme(
context = context,
mode = appThemeMode,
darkTheme = isDark,
isAmoled = isPureBlack,
paletteStyle = paletteStyle,
forceOpaque = true
)
return remember(
context,
currentTheme.colorScheme,
appThemeMode,
isDark,
isPureBlack,
hasImageBg,
paletteStyle,
materialVersion,
seedColorInt
) {
if (appThemeMode != AppThemeMode.Transparent) {
currentTheme.colorScheme
} else {
getColorScheme(
context = context,
mode = appThemeMode,
darkTheme = isDark,
isAmoled = isPureBlack,
paletteStyle = paletteStyle,
materialVersion = materialVersion,
forceOpaque = true,
customSeedColor = seedColorInt
)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
@@ -81,3 +82,57 @@ fun SmallTextButton(
}
}
}
@Composable
fun SmallTonalTextButton(
text: String,
imageVector: ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
} else {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme
@@ -70,7 +71,9 @@ fun TextCard(
AnimatedTextLine(
text = text,
style = textStyle,
color = finalContentColor
color = finalContentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -48,6 +48,8 @@ fun <T> ListScaffold(
showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit,
onSearchSubmit: (String) -> Unit = {},
searchTrailingIcon: @Composable (() -> Unit)? = null,
searchPlaceholder: String = "搜索...",
topBarActions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null,
@@ -93,6 +95,8 @@ fun <T> ListScaffold(
showSearchAction = showSearchAction,
onSearchToggle = onSearchToggle,
onSearchQueryChange = onSearchQueryChange,
onSearchSubmit = onSearchSubmit,
searchTrailingIcon = searchTrailingIcon,
searchPlaceholder = searchPlaceholder,
onClearSelection = { selectionActions?.onClearSelection?.invoke() },
topBarActions = topBarActions,
@@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
@@ -86,3 +89,68 @@ fun RoundDropdownMenu(
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RoundDropdownMenuLazy(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.medium,
shadowElevation: Dp = 4.dp,
verticalSpacing: Dp = 8.dp,
maxHeight: Dp = 320.dp,
content: LazyListScope.(dismiss: () -> Unit) -> Unit
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val popupContainerColor = LegadoTheme.colorScheme.surfaceContainer
if (isMiuix) {
val popupContentColor = LegadoTheme.colorScheme.onSurface
WindowListPopup(
show = expanded,
onDismissRequest = onDismissRequest,
popupModifier = modifier
) {
ProvideAppContentColor(popupContentColor) {
ListPopupColumn {
LazyColumn(
modifier = Modifier
.background(popupContainerColor)
.heightIn(max = maxHeight),
verticalArrangement = Arrangement.spacedBy(verticalSpacing)
) {
item { Spacer(Modifier.height(12.dp)) }
content(onDismissRequest)
item { Spacer(Modifier.height(12.dp)) }
}
}
}
}
} else {
val colorScheme = rememberOpaqueColorScheme()
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = modifier,
shape = shape,
shadowElevation = shadowElevation,
containerColor = colorScheme.surfaceContainerLow
) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography(),
motionScheme = MotionScheme.expressive(),
shapes = Shapes()
) {
LazyColumn(
modifier = Modifier.heightIn(max = maxHeight),
verticalArrangement = Arrangement.spacedBy(verticalSpacing)
) {
content(onDismissRequest)
}
}
}
}
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -41,6 +42,7 @@ fun <T> DynamicTopAppBar(
showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit,
onSearchSubmit: (String) -> Unit = {},
searchPlaceholder: String,
searchLeadingIcon: ImageVector = Icons.Default.Search,
searchTrailingIcon: @Composable (() -> Unit)? = null,
@@ -114,7 +116,14 @@ fun <T> DynamicTopAppBar(
SearchBar(
query = state.searchKey,
onQueryChange = onSearchQueryChange,
onSearch = onSearchSubmit,
placeholder = searchPlaceholder,
leadingIcon = {
AppIcon(
imageVector = searchLeadingIcon,
contentDescription = null
)
},
trailingIcon = searchTrailingIcon,
dropdownMenu = searchDropdownMenu
)