[优化] 一些代码优化

This commit is contained in:
HapeLee
2026-04-29 00:21:00 +08:00
parent e995412536
commit 0afed18353
46 changed files with 1041 additions and 1699 deletions
+128
View File
@@ -0,0 +1,128 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build / Test / Run
```bash
# Assemble all variants
./gradlew assembleAppRelease
# Assemble without R8 (for crash debugging — no minification/shrinking)
./gradlew assembleAppNoR8
# Debug build
./gradlew assembleAppDebug
# Run unit tests (JVM, local)
./gradlew test
# Run a single test class
./gradlew test --tests "io.legado.app.model.cache.CacheDownloadQueueTest"
# Run connected Android tests
./gradlew connectedAndroidTest
# Lint
./gradlew lint
# Update Cronet (after changing CronetVersion in gradle.properties)
./gradlew app:downloadCronet
```
The project uses JDK 21 for development (set in `build.gradle.kts` via `jvmToolchain`). CI uses JDK 17 for building.
Gradle properties: 8 GB heap, configuration cache disabled (`gradle.properties:31`), non-transitive R classes, precise resource shrinking enabled.
## Architecture
This is a Material Design 3 fork of [Legado](https://github.com/gedoor/legado). `app/src/main/java/io/legado/app/` uses **Clean Architecture** with three layers:
| Layer | Package | Role |
|---|---|---|
| Data | `data/` | Room DB (`AppDatabase`, version 85, ~22 DAOs, ~25 entities), repository implementations |
| Domain | `domain/` | Gateway interfaces, use cases (14), domain models — no framework dependencies |
| UI | `ui/` | Jetpack Compose screens, Navigation 3 routes, ViewModels |
Additional top-level packages:
- **`help/`** — Infrastructure "glue": HTTP (OkHttp + Cronet), book content processing, backup/WebDAV, JS engine, config
- **`model/`** — Runtime state coordinators (not entities): `ReadBook`, `AudioPlay`, `CacheBook`, `BookCover`, etc.
- **`service/`** — Android foreground/background services (audio playback, TTS, download, web server)
- **`web/`** — Embedded HTTP server (NanoHTTPD) for remote bookshelf/source editing
- **`lib/`** — Third-party library wrappers (MOBI parser, WebDAV client, legacy View theme system, cronet)
- **`base/`** — Abstract Activity/Fragment/ViewModel base classes
- **`utils/`** — Extension functions and utility classes (~70 files)
Modules: `:app`, `:modules:book` (epub/TXT parsing, namespace `me.ag2s`), `:modules:rhino` (Rhino JS wrapper, namespace `com.script`). There is also a Vue 3 web frontend in `modules/web/` (pnpm, separate from the Android build).
## Dependency Injection (Koin)
Two modules loaded in `App.onCreate()`:
```kotlin
startKoin {
modules(appDatabaseModule, appModule)
}
```
- **`di/appDatabaseModule.kt`** — Singleton `AppDatabase` + factory bindings for all 22 DAOs
- **`di/appModule.kt`** — Singletons (repositories, use cases, gateways, Coil `ImageLoader`), `viewModelOf` / `viewModel { }` for all ViewModels, some parameterized definitions
Gateways are bound to their repository implementations explicitly (e.g., `single<LocalBookGateway> { LocalBookRepository(get()) }`), not through `singleOf`.
## Navigation
Uses **Jetpack Navigation 3** (`androidx.navigation3`) with type-safe `@Serializable` sealed interfaces for route keys:
```kotlin
@Serializable
private sealed interface MainRoute : NavKey
@Serializable
private data object MainRouteHome : MainRoute
@Serializable
private data class MainRouteCache(val groupId: Long) : MainRoute
```
`MainActivity` holds a single `NavDisplay` with `entryProvider { ... }` defining all composable entries. `Launcher0` through `LauncherW` extend `MainActivity` to provide multiple launcher icon alias entries. Separate activities handle the reader (`ReadBookActivity` — still View-based), book info, source management, replace rules, file manager, QR scanner, etc.
## Theme System
A multi-engine theming system in `ui/theme/`:
1. **Material 3 Expressive** (default): Uses `MaterialExpressiveTheme` with `MotionScheme.expressive()`
2. **Miuix** (alternative): Uses `top.yukonga.miuix.kmp` theming engine
14 theme modes (`AppThemeMode` enum) — Dynamic (Monet), 12 named presets, Custom (MaterialKolor seed-color generation), Transparent. `CustomColorScheme` wraps `com.materialkolor` with configurable `PaletteStyle` (TonalSpot, Neutral, Vibrant, Expressive, Rainbow, etc.) and `ColorSpec` (2021 vs 2025).
Legacy View-based theme still exists in `lib/theme/` (used by non-migrated screens like `ReadBookActivity`).
## Hybrid Compose + View
The app is mid-migration from Views to Compose. View-based screens (reader, book info, source management) coexist with Compose screens (main tabs, settings, search, RSS, cache management). XML layouts, `viewBinding`, and traditional Activities are still heavily used. The `viewBinding` build feature is enabled but Compose screens are the target.
## Rhino JavaScript Engine
Book sources, RSS sources, and HTTP TTS use JavaScript rules. `initRhino()` in `App.kt` registers `NativeBaseSource` wrappers for `BookSource`, `RssSource`, `HttpTTS` (writable JS objects) and `ReadOnlyJavaObject` wrappers for rule entities. Rule parsing logic lives in `help/source/` and `model/analyzeRule/`.
## Important Constraints
- **Do not update jsoup** beyond 1.16.2 — a breaking change in newer versions (see [jsoup#2017](https://github.com/jhy/jsoup/pull/2017)) affects `AnalyzeByJSoup.kt` and the JsoupXpath library
- **Do not update hutool** beyond 5.8.22 — pinned in `libs.versions.toml:42`
- Package name discrepancy: code namespace is `io.legado.app` but `applicationId` is `io.legato.kazusa`
- Min SDK 26, target SDK 37, compile SDK 37
- Release builds enable R8 minification + resource shrinking; `noR8` variant disables both for crash debugging
- APK is split by ABI (`armeabi-v7a`, `arm64-v8a`, plus universal)
- Firebase Analytics and Performance are included; `google-services` plugin applied
## Web Frontend
Located in `modules/web/` — a Vue 3 + TypeScript + Vite project for remote bookshelf and source editing. Must connect to the app's built-in HTTP server (started via `WebService` in the main activity settings). Commands:
```bash
cd modules/web
pnpm install
pnpm dev # dev server
pnpm build # production build
```
Set `VITE_API` in `.env.development` to the app's web service IP.
-10
View File
@@ -243,11 +243,6 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<!-- 书籍管理 -->
<activity
android:name=".ui.book.manage.BookshelfManageActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 书源调试 --> <!-- 书源调试 -->
<activity <activity
android:name=".ui.book.source.debug.BookSourceDebugActivity" android:name=".ui.book.source.debug.BookSourceDebugActivity"
@@ -263,11 +258,6 @@
android:name=".ui.book.searchContent.SearchContentActivity" android:name=".ui.book.searchContent.SearchContentActivity"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<!-- 发现界面 -->
<activity
android:name=".ui.book.explore.ExploreShowActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 订阅源调试 --> <!-- 订阅源调试 -->
<activity <activity
android:name=".ui.rss.source.debug.RssSourceDebugActivity" android:name=".ui.rss.source.debug.RssSourceDebugActivity"
@@ -32,7 +32,6 @@ data class ChangeSourceMigrationOptions(
data class ChangeBookSourceResult( data class ChangeBookSourceResult(
val oldBookUrl: String, val oldBookUrl: String,
val book: Book, val book: Book,
val chapters: List<BookChapter>,
) )
data class BatchChangeBookSourceResult( data class BatchChangeBookSourceResult(
@@ -102,7 +101,7 @@ class ChangeBookSourceUseCase(
bookChapterDao.insert(*chapters.toTypedArray()) bookChapterDao.insert(*chapters.toTypedArray())
ReadBook.onChapterListUpdated(newBook) ReadBook.onChapterListUpdated(newBook)
} }
return ChangeBookSourceResult(oldBookUrl, newBook, chapters) return ChangeBookSourceResult(oldBookUrl, newBook)
} }
suspend fun batchChangeTo( suspend fun batchChangeTo(
@@ -1,6 +1,5 @@
package io.legado.app.domain.usecase package io.legado.app.domain.usecase
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
@@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.math.min
data class BookSearchRequest( data class BookSearchRequest(
val keyword: String, val keyword: String,
@@ -83,7 +81,7 @@ class SearchBooksUseCase(
} }
val merger = SearchResultMerger(keyword, request.precision) val merger = SearchResultMerger(keyword, request.precision)
val concurrency = min(request.concurrency.coerceAtLeast(1), AppConst.MAX_THREAD) val concurrency = request.concurrency.coerceAtLeast(1)
var hasMore = false var hasMore = false
var processedSources = 0 var processedSources = 0
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.JaccardSimilarity import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx import splitties.init.appCtx
@@ -48,6 +49,7 @@ import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern import java.util.regex.Pattern
import java.util.zip.ZipFile import java.util.zip.ZipFile
@@ -62,6 +64,8 @@ object BookHelp {
private const val cacheImageFolderName = "images" private const val cacheImageFolderName = "images"
private const val cacheEpubFolderName = "epub" private const val cacheEpubFolderName = "epub"
private val downloadImages = ConcurrentHashMap<String, Mutex>() private val downloadImages = ConcurrentHashMap<String, Mutex>()
private val imageDownloadSlots = Semaphore(2)
private val imageDecodeSlots = Semaphore(1)
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
@@ -299,23 +303,39 @@ object BookHelp {
if (isImageExist(book, src)) { if (isImageExist(book, src)) {
return return
} }
val analyzeUrl = AnalyzeUrl( imageDownloadSlots.acquire()
src, source = bookSource, coroutineContext = currentCoroutineContext() try {
) val analyzeUrl = AnalyzeUrl(
val bytes = analyzeUrl.getByteArrayAwait() src, source = bookSource, coroutineContext = currentCoroutineContext()
//某些图片被加密,需要进一步解密
runScriptWithContext {
ImageUtils.decode(
src, bytes, isCover = false, bookSource, book
) )
}?.let { if (ImageUtils.skipDecode(bookSource, isCover = false)) {
if (!checkImage(it)) { analyzeUrl.getInputStreamAwait().use {
// 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据 writeImage(book, src, it)
// 所以无论如何都要将数据写入到文件里 }
// throw NoStackTraceException("数据异常") } else {
AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常") imageDecodeSlots.acquire()
try {
val bytes = analyzeUrl.getByteArrayAwait()
//某些图片被加密,需要进一步解密
runScriptWithContext {
ImageUtils.decode(
src, bytes, isCover = false, bookSource, book
)
}?.let {
if (!checkImage(it)) {
// 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
// 所以无论如何都要将数据写入到文件里
// throw NoStackTraceException("数据异常")
AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
}
writeImage(book, src, it)
}
} finally {
imageDecodeSlots.release()
}
} }
writeImage(book, src, it) } finally {
imageDownloadSlots.release()
} }
} catch (e: Exception) { } catch (e: Exception) {
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
@@ -341,6 +361,35 @@ object BookHelp {
getImage(book, src).createFileIfNotExist().writeBytes(bytes) getImage(book, src).createFileIfNotExist().writeBytes(bytes)
} }
private fun writeImage(book: Book, src: String, inputStream: InputStream) {
val image = getImage(book, src)
val parent = image.parentFile ?: return
parent.mkdirs()
val temp = File(parent, "${image.name}.${System.nanoTime()}.tmp")
try {
FileOutputStream(temp).use { output ->
inputStream.copyTo(output, 16 * 1024)
}
if (!checkImage(temp)) {
AppLog.put("${book.name} 图片 $src 下载错误 数据异常")
}
if (image.exists()) {
image.delete()
}
if (!temp.renameTo(image)) {
image.createFileIfNotExist().outputStream().use { output ->
temp.inputStream().use { input ->
input.copyTo(output, 16 * 1024)
}
}
temp.delete()
}
} catch (e: Exception) {
temp.delete()
throw e
}
}
@Synchronized @Synchronized
fun isImageExist(book: Book, src: String): Boolean { fun isImageExist(book: Book, src: String): Boolean {
return getImage(book, src).exists() return getImage(book, src).exists()
@@ -519,6 +568,16 @@ object BookHelp {
return true return true
} }
private fun checkImage(file: File): Boolean {
val op = BitmapFactory.Options()
op.inJustDecodeBounds = true
BitmapFactory.decodeFile(file.absolutePath, op)
if (op.outWidth < 1 && op.outHeight < 1) {
return SvgUtils.getSize(file.absolutePath) != null
}
return true
}
/** /**
* 读取章节内容 * 读取章节内容
*/ */
@@ -44,15 +44,23 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
object CacheBook { object CacheBook {
const val maxDownloadConcurrency = 8
private data class QueueStats( private data class QueueStats(
val waitingCount: Int, val waitingCount: Int,
val downloadingCount: Int val downloadingCount: Int
) )
private data class ChapterKey(
val bookUrl: String,
val index: Int,
)
private class CacheBookCoordinator { private class CacheBookCoordinator {
val taskMap = ConcurrentHashMap<String, CacheBookModel>() val taskMap = ConcurrentHashMap<String, CacheBookModel>()
private val processMutex = Mutex() private val processMutex = Mutex()
@@ -80,7 +88,7 @@ object CacheBook {
} }
}.onStart { }.onStart {
updateSummary() updateSummary()
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) { }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)) {
coroutineScope { coroutineScope {
it.download(this, context) it.download(this, context)
} }
@@ -92,6 +100,8 @@ object CacheBook {
private val coordinator = CacheBookCoordinator() private val coordinator = CacheBookCoordinator()
private val stateStore = CacheDownloadStateStore() private val stateStore = CacheDownloadStateStore()
private val pendingRequests = ConcurrentHashMap<Long, CacheDownloadRequest>()
private val pendingRequestId = AtomicLong(0)
val downloadStateFlow = stateStore.stateFlow val downloadStateFlow = stateStore.stateFlow
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64) private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
@@ -113,9 +123,9 @@ object CacheBook {
@Volatile @Volatile
private var lastQueueStats = QueueStats(0, 0) private var lastQueueStats = QueueStats(0, 0)
val successDownloadSet = ConcurrentHashMap.newKeySet<String>() @Volatile
val errorDownloadMap = ConcurrentHashMap<String, Int>() private var successDownloadCount = 0
private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>() private val errorRetryMap = ConcurrentHashMap<ChapterKey, Int>()
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel> val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = coordinator.taskMap get() = coordinator.taskMap
@@ -124,6 +134,12 @@ object CacheBook {
return stateStore.bookState(bookUrl)?.failedIndices.orEmpty() return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
} }
fun markBookFailed(bookUrl: String, message: String) {
stateStore.markBookFailed(bookUrl, message)
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
}
private fun collectQueueStats(): QueueStats { private fun collectQueueStats(): QueueStats {
var waiting = 0 var waiting = 0
var downloading = 0 var downloading = 0
@@ -139,7 +155,7 @@ object CacheBook {
val stats = collectQueueStats() val stats = collectQueueStats()
lastQueueStats = stats lastQueueStats = stats
_downloadSummaryFlow.value = _downloadSummaryFlow.value =
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}" "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${stateStore.state.totalFailure}|成功:$successDownloadCount"
} }
@Synchronized @Synchronized
@@ -204,8 +220,11 @@ object CacheBook {
} }
is ChapterSelection.Single -> Unit is ChapterSelection.Single -> Unit
} }
val requestId = pendingRequestId.incrementAndGet()
pendingRequests[requestId] = request
context.startService<CacheBookService> { context.startService<CacheBookService> {
action = IntentAction.start action = IntentAction.start
putExtra("requestId", requestId)
putExtra("bookUrl", request.bookUrl) putExtra("bookUrl", request.bookUrl)
putExtra("source", request.source.name) putExtra("source", request.source.name)
when (val selection = request.selection) { when (val selection = request.selection) {
@@ -213,9 +232,7 @@ object CacheBook {
putExtra("start", selection.start) putExtra("start", selection.start)
putExtra("end", selection.end) putExtra("end", selection.end)
} }
is ChapterSelection.Indices -> { is ChapterSelection.Indices -> Unit
putIntegerArrayListExtra("indices", ArrayList(selection.values))
}
is ChapterSelection.Single -> { is ChapterSelection.Single -> {
putExtra("start", selection.index) putExtra("start", selection.index)
putExtra("end", selection.index) putExtra("end", selection.index)
@@ -224,6 +241,10 @@ object CacheBook {
} }
} }
fun takePendingRequest(requestId: Long): CacheDownloadRequest? {
return pendingRequests.remove(requestId)
}
fun remove(context: Context, bookUrl: String) { fun remove(context: Context, bookUrl: String) {
context.startService<CacheBookService> { context.startService<CacheBookService> {
action = IntentAction.remove action = IntentAction.remove
@@ -232,12 +253,12 @@ object CacheBook {
} }
fun removeBook(bookUrl: String): Boolean { fun removeBook(bookUrl: String): Boolean {
val model = cacheBookMap.remove(bookUrl) ?: return false val model = cacheBookMap.remove(bookUrl)
model.stop() model?.stop()
stateStore.removeBook(bookUrl) stateStore.removeBook(bookUrl)
updateSummary() updateSummary()
_queueChangedFlow.tryEmit(bookUrl) _queueChangedFlow.tryEmit(bookUrl)
return true return model != null
} }
fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean { fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean {
@@ -255,9 +276,9 @@ object CacheBook {
fun close() { fun close() {
cacheBookMap.forEach { (_, model) -> model.stop() } cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear() cacheBookMap.clear()
successDownloadSet.clear() successDownloadCount = 0
errorDownloadMap.clear() errorRetryMap.clear()
errorIndexMap.clear() pendingRequests.clear()
stateStore.clear() stateStore.clear()
updateSummary() updateSummary()
} }
@@ -273,16 +294,16 @@ object CacheBook {
val totalCount: Int val totalCount: Int
get() { get() {
val stats = collectQueueStats() val stats = collectQueueStats()
return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size return stats.waitingCount + stats.downloadingCount + successDownloadCount + stateStore.state.totalFailure
} }
val completedCount: Int val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size get() = successDownloadCount + stateStore.state.totalFailure
val downloadSummary: String val downloadSummary: String
get() { get() {
val stats = collectQueueStats() val stats = collectQueueStats()
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${errorDownloadMap.size} | 成功:${successDownloadSet.size}" return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${stateStore.state.totalFailure} | 成功:$successDownloadCount"
} }
val isRun: Boolean val isRun: Boolean
@@ -351,9 +372,6 @@ object CacheBook {
@Synchronized @Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index) fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun waitingIndices(): Set<Int> = queue.waitingIndices()
@Synchronized @Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet() fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@@ -438,9 +456,9 @@ object CacheBook {
private fun onSuccess(chapter: BookChapter) { private fun onSuccess(chapter: BookChapter) {
onDownloadSet.remove(chapter.index) onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index) chapterTasks.remove(chapter.index)
successDownloadSet.add(chapter.primaryStr()) val chapterKey = ChapterKey(book.bookUrl, chapter.index)
errorDownloadMap.remove(chapter.primaryStr()) successDownloadCount++
errorIndexMap[book.bookUrl]?.remove(chapter.index) errorRetryMap.remove(chapterKey)
stateStore.markSuccess(book.bookUrl, chapter.index) stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged() notifyDownloadSetChanged()
notifyErrorChanged() notifyErrorChanged()
@@ -451,9 +469,7 @@ object CacheBook {
private fun onPreError(chapter: BookChapter, error: Throwable) { private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true waitingRetry = true
if (error !is ConcurrentException) { if (error !is ConcurrentException) {
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc } errorRetryMap.merge(ChapterKey(book.bookUrl, chapter.index), 1) { old, inc -> old + inc }
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index)
stateStore.markFailed(book.bookUrl, chapter.index) stateStore.markFailed(book.bookUrl, chapter.index)
} }
onDownloadSet.remove(chapter.index) onDownloadSet.remove(chapter.index)
@@ -462,7 +478,7 @@ object CacheBook {
@Synchronized @Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) { private fun onPostError(chapter: BookChapter, error: Throwable) {
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0 val retryCount = errorRetryMap[ChapterKey(book.bookUrl, chapter.index)] ?: 0
if (retryCount < 3 && !isStopped) { if (retryCount < 3 && !isStopped) {
queue.enqueue(ChapterSelection.Single(chapter.index)) queue.enqueue(ChapterSelection.Single(chapter.index))
} else { } else {
@@ -41,6 +41,6 @@ data class CacheBookDownloadState(
val waitingCount: Int = 0, val waitingCount: Int = 0,
val runningIndices: Set<Int> = emptySet(), val runningIndices: Set<Int> = emptySet(),
val failedIndices: Set<Int> = emptySet(), val failedIndices: Set<Int> = emptySet(),
val successIndices: Set<Int> = emptySet(),
val successCount: Int = 0, val successCount: Int = 0,
val failureMessage: String? = null,
) )
@@ -2,6 +2,91 @@ package io.legado.app.model.cache
class CacheDownloadQueue { class CacheDownloadQueue {
private class IntRangeSet {
private val ranges = mutableListOf<IntRange>()
fun contains(value: Int): Boolean {
return ranges.any { value in it }
}
fun add(value: Int) {
addRange(value, value)
}
fun addRange(start: Int, end: Int) {
if (end < start) return
var newStart = start
var newEnd = end
var index = 0
while (index < ranges.size) {
val range = ranges[index]
if (newEnd + 1 < range.first) break
if (newStart > range.last + 1) {
index++
continue
}
newStart = minOf(newStart, range.first)
newEnd = maxOf(newEnd, range.last)
ranges.removeAt(index)
}
ranges.add(index, newStart..newEnd)
}
fun remove(value: Int) {
val index = ranges.indexOfFirst { value in it }
if (index < 0) return
val range = ranges.removeAt(index)
if (range.first < value) {
ranges.add(index, range.first until value)
}
if (value < range.last) {
ranges.add(index + if (range.first < value) 1 else 0, value + 1..range.last)
}
}
fun removeRange(start: Int, end: Int) {
if (end < start) return
var index = 0
while (index < ranges.size) {
val range = ranges[index]
if (range.last < start) {
index++
continue
}
if (range.first > end) break
ranges.removeAt(index)
if (range.first < start) {
ranges.add(index, range.first until start)
index++
}
if (end < range.last) {
ranges.add(index, end + 1..range.last)
break
}
}
}
fun clear() {
ranges.clear()
}
fun countInRange(start: Int, end: Int, excluding: IntRangeSet? = null): Int {
if (end < start) return 0
var count = 0
ranges.forEach { range ->
val overlapStart = maxOf(start, range.first)
val overlapEnd = minOf(end, range.last)
if (overlapEnd >= overlapStart) {
count += overlapEnd - overlapStart + 1
if (excluding != null) {
count -= excluding.countInRange(overlapStart, overlapEnd)
}
}
}
return count
}
}
private data class RangeCursor( private data class RangeCursor(
val start: Int, val start: Int,
val end: Int, val end: Int,
@@ -9,13 +94,13 @@ class CacheDownloadQueue {
) { ) {
fun contains(index: Int): Boolean = index in next..end fun contains(index: Int): Boolean = index in next..end
fun remainingCount( fun remainingCount(
emittedIndices: Set<Int>, emittedIndices: IntRangeSet,
removedIndices: Set<Int>, removedIndices: IntRangeSet,
): Int { ): Int {
if (next > end) return 0 if (next > end) return 0
val rawCount = end - next + 1 val rawCount = end - next + 1
val emittedCount = emittedIndices.count { it in next..end } val emittedCount = emittedIndices.countInRange(next, end)
val removedCount = removedIndices.count { it in next..end && it !in emittedIndices } val removedCount = removedIndices.countInRange(next, end, excluding = emittedIndices)
val excludedCount = emittedCount + removedCount val excludedCount = emittedCount + removedCount
return rawCount - excludedCount return rawCount - excludedCount
} }
@@ -23,8 +108,8 @@ class CacheDownloadQueue {
private val ranges = ArrayDeque<RangeCursor>() private val ranges = ArrayDeque<RangeCursor>()
private val indices = linkedSetOf<Int>() private val indices = linkedSetOf<Int>()
private val emittedIndices = hashSetOf<Int>() private val emittedIndices = IntRangeSet()
private val removedIndices = hashSetOf<Int>() private val removedIndices = IntRangeSet()
fun enqueue(request: CacheDownloadRequest) { fun enqueue(request: CacheDownloadRequest) {
enqueue(request.selection) enqueue(request.selection)
@@ -42,7 +127,7 @@ class CacheDownloadQueue {
while (indices.isNotEmpty()) { while (indices.isNotEmpty()) {
val index = indices.first() val index = indices.first()
indices.remove(index) indices.remove(index)
if (index in runningIndices || index in removedIndices) continue if (index in runningIndices || removedIndices.contains(index)) continue
emittedIndices.add(index) emittedIndices.add(index)
return CacheDownloadCandidate(bookUrl, index) return CacheDownloadCandidate(bookUrl, index)
} }
@@ -51,10 +136,15 @@ class CacheDownloadQueue {
val cursor = ranges.first() val cursor = ranges.first()
while (cursor.next <= cursor.end) { while (cursor.next <= cursor.end) {
val index = cursor.next++ val index = cursor.next++
if (index in removedIndices || index in runningIndices) continue if (
if (emittedIndices.add(index)) { removedIndices.contains(index) ||
return CacheDownloadCandidate(bookUrl, index) emittedIndices.contains(index) ||
index in runningIndices
) {
continue
} }
emittedIndices.add(index)
return CacheDownloadCandidate(bookUrl, index)
} }
ranges.removeFirst() ranges.removeFirst()
} }
@@ -79,30 +169,22 @@ class CacheDownloadQueue {
} }
fun waitingCount(): Int { fun waitingCount(): Int {
val indexCount = indices.count { it !in emittedIndices && it !in removedIndices } val indexCount = indices.count {
!emittedIndices.contains(it) && !removedIndices.contains(it)
}
val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) } val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
return indexCount + rangeCount return indexCount + rangeCount
} }
fun isWaiting(index: Int): Boolean { fun isWaiting(index: Int): Boolean {
if (index in emittedIndices || index in removedIndices) return false if (emittedIndices.contains(index) || removedIndices.contains(index)) return false
return indices.contains(index) || ranges.any { it.contains(index) } return indices.contains(index) || ranges.any { it.contains(index) }
} }
fun waitingIndices(): Set<Int> {
return buildSet {
indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
ranges.forEach { cursor ->
for (index in cursor.next..cursor.end) {
if (index !in emittedIndices && index !in removedIndices) add(index)
}
}
}
}
private fun addRange(start: Int, end: Int) { private fun addRange(start: Int, end: Int) {
if (end < start) return if (end < start) return
removedIndices.removeAll { it in start..end } emittedIndices.removeRange(start, end)
removedIndices.removeRange(start, end)
ranges.add(RangeCursor(start, end)) ranges.add(RangeCursor(start, end))
} }
@@ -21,18 +21,22 @@ class CacheDownloadStateStore {
current.copy( current.copy(
waitingCount = waitingCount, waitingCount = waitingCount,
runningIndices = runningIndices, runningIndices = runningIndices,
failureMessage = if (waitingCount > 0 || runningIndices.isNotEmpty()) {
null
} else {
current.failureMessage
},
) )
} }
} }
fun markSuccess(bookUrl: String, chapterIndex: Int) { fun markSuccess(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
val successIndices = current.successIndices + chapterIndex
current.copy( current.copy(
runningIndices = current.runningIndices - chapterIndex, runningIndices = current.runningIndices - chapterIndex,
failedIndices = current.failedIndices - chapterIndex, failedIndices = current.failedIndices - chapterIndex,
successIndices = successIndices, successCount = current.successCount + 1,
successCount = successIndices.size, failureMessage = null,
) )
} }
} }
@@ -46,6 +50,16 @@ class CacheDownloadStateStore {
} }
} }
fun markBookFailed(bookUrl: String, message: String) {
updateBook(bookUrl) { current ->
current.copy(
waitingCount = 0,
runningIndices = emptySet(),
failureMessage = message,
)
}
}
fun clearFailure(bookUrl: String, chapterIndex: Int) { fun clearFailure(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current -> updateBook(bookUrl) { current ->
current.copy(failedIndices = current.failedIndices - chapterIndex) current.copy(failedIndices = current.failedIndices - chapterIndex)
@@ -79,7 +93,8 @@ class CacheDownloadStateStore {
private fun CacheDownloadState.recalculate(): CacheDownloadState { private fun CacheDownloadState.recalculate(): CacheDownloadState {
val totalWaiting = books.values.sumOf { it.waitingCount } val totalWaiting = books.values.sumOf { it.waitingCount }
val totalRunning = books.values.sumOf { it.runningIndices.size } val totalRunning = books.values.sumOf { it.runningIndices.size }
val totalFailure = books.values.sumOf { it.failedIndices.size } val totalFailure = books.values.sumOf { it.failedIndices.size } +
books.values.count { it.failureMessage != null }
val totalSuccess = books.values.sumOf { it.successCount } val totalSuccess = books.values.sumOf { it.successCount }
return copy( return copy(
isRunning = totalWaiting > 0 || totalRunning > 0, isRunning = totalWaiting > 0 || totalRunning > 0,
@@ -35,6 +35,8 @@ import kotlin.coroutines.coroutineContext
*/ */
object BookContent { object BookContent {
private const val maxNextPageConcurrency = 4
@Throws(Exception::class) @Throws(Exception::class)
suspend fun analyzeContent( suspend fun analyzeContent(
bookSource: BookSource, bookSource: BookSource,
@@ -50,14 +52,24 @@ object BookContent {
appCtx.getString(R.string.error_get_web_content, baseUrl) appCtx.getString(R.string.error_get_web_content, baseUrl)
) )
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}") Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
Debug.log(bookSource.bookSourceUrl, body, state = 40) if (!needSave) {
Debug.log(bookSource.bookSourceUrl, body, state = 40)
}
val mNextChapterUrl = if (nextChapterUrl.isNullOrEmpty()) { val mNextChapterUrl = if (nextChapterUrl.isNullOrEmpty()) {
appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url
?: appDb.bookChapterDao.getChapter(book.bookUrl, 0)?.url ?: appDb.bookChapterDao.getChapter(book.bookUrl, 0)?.url
} else { } else {
nextChapterUrl nextChapterUrl
} }
val contentList = arrayListOf<String>() var pageCount = 0
val contentBuilder = StringBuilder()
fun appendContent(content: String) {
if (pageCount > 0) {
contentBuilder.append('\n')
}
contentBuilder.append(content)
pageCount++
}
val nextUrlList = arrayListOf(redirectUrl) val nextUrlList = arrayListOf(redirectUrl)
val contentRule = bookSource.getContentRule() val contentRule = bookSource.getContentRule()
val analyzeRule = AnalyzeRule(book, bookSource) val analyzeRule = AnalyzeRule(book, bookSource)
@@ -70,7 +82,7 @@ object BookContent {
var contentData = analyzeContent( var contentData = analyzeContent(
book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl
) )
contentList.add(contentData.first) appendContent(contentData.first)
if (contentData.second.size == 1) { if (contentData.second.size == 1) {
var nextUrl = contentData.second[0] var nextUrl = contentData.second[0]
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) { while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
@@ -95,8 +107,8 @@ object BookContent {
) )
nextUrl = nextUrl =
if (contentData.second.isNotEmpty()) contentData.second[0] else "" if (contentData.second.isNotEmpty()) contentData.second[0] else ""
contentList.add(contentData.first) appendContent(contentData.first)
Debug.log(bookSource.bookSourceUrl, "${contentList.size}页完成") Debug.log(bookSource.bookSourceUrl, "${pageCount}页完成")
} }
} }
Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}") Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}")
@@ -106,7 +118,7 @@ object BookContent {
for (urlStr in contentData.second) { for (urlStr in contentData.second) {
emit(urlStr) emit(urlStr)
} }
}.mapAsync(OtherConfig.threadCount) { urlStr -> }.mapAsync(OtherConfig.threadCount.coerceIn(1, maxNextPageConcurrency)) { urlStr ->
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
mUrl = urlStr, mUrl = urlStr,
source = bookSource, source = bookSource,
@@ -122,10 +134,10 @@ object BookContent {
).first ).first
}.collect { }.collect {
coroutineContext.ensureActive() coroutineContext.ensureActive()
contentList.add(it) appendContent(it)
} }
} }
var contentStr = contentList.joinToString("\n") var contentStr = contentBuilder.toString()
val titleRule = contentRule.title //先正文再章节名称 val titleRule = contentRule.title //先正文再章节名称
if (!titleRule.isNullOrBlank()) { if (!titleRule.isNullOrBlank()) {
var title = analyzeRule.runCatching { var title = analyzeRule.runCatching {
@@ -160,7 +172,11 @@ object BookContent {
Debug.log(bookSource.bookSourceUrl, "┌获取章节名称") Debug.log(bookSource.bookSourceUrl, "┌获取章节名称")
Debug.log(bookSource.bookSourceUrl, "${bookChapter.title}") Debug.log(bookSource.bookSourceUrl, "${bookChapter.title}")
Debug.log(bookSource.bookSourceUrl, "┌获取正文内容") Debug.log(bookSource.bookSourceUrl, "┌获取正文内容")
Debug.log(bookSource.bookSourceUrl, "\n$contentStr") if (needSave) {
Debug.log(bookSource.bookSourceUrl, "└正文长度:${contentStr.length}")
} else {
Debug.log(bookSource.bookSourceUrl, "\n$contentStr")
}
if (!bookChapter.isVolume && contentStr.isBlank()) { if (!bookChapter.isVolume && contentStr.isBlank()) {
throw ContentEmptyException("内容为空") throw ContentEmptyException("内容为空")
} }
@@ -42,7 +42,7 @@ class CacheBookService : BaseService() {
private set private set
} }
private val threadCount = OtherConfig.cacheBookThreadCount private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency)
private var cachePool = private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null private var downloadJob: Job? = null
@@ -84,11 +84,16 @@ class CacheBookService : BaseService() {
intent?.action?.let { action -> intent?.action?.let { action ->
when (action) { when (action) {
IntentAction.start -> { IntentAction.start -> {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let val requestId = intent.getLongExtra("requestId", -1L)
val indices = intent.getIntegerArrayListExtra("indices") val request = if (requestId >= 0) {
if (indices != null && indices.isNotEmpty()) { CacheBook.takePendingRequest(requestId)
addDownloadData(bookUrl, indices)
} else { } else {
null
}
if (request != null) {
addDownloadRequest(request)
} else {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
addDownloadData( addDownloadData(
bookUrl, bookUrl,
intent.getIntExtra("start", 0), intent.getIntExtra("start", 0),
@@ -113,19 +118,6 @@ class CacheBookService : BaseService() {
super.onDestroy() super.onDestroy()
} }
private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
bookUrl ?: return
val values = indices.toSet()
if (values.isEmpty()) return
addDownloadRequest(
CacheDownloadRequest(
bookUrl = bookUrl,
selection = ChapterSelection.Indices(values),
source = CacheDownloadSource.Manual,
)
)
}
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) { private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
bookUrl ?: return bookUrl ?: return
if (end < start) return if (end < start) return
@@ -140,7 +132,10 @@ class CacheBookService : BaseService() {
private fun addDownloadRequest(request: CacheDownloadRequest) { private fun addDownloadRequest(request: CacheDownloadRequest) {
execute { execute {
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: run {
CacheBook.markBookFailed(request.bookUrl, getString(R.string.error_no_source))
return@execute
}
val book = cacheBook.book val book = cacheBook.book
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl) val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
@@ -154,6 +149,10 @@ class CacheBookService : BaseService() {
WebBook.getBookInfoAwait(cacheBook.bookSource, book) WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure { }.onFailure {
removeDownload(request.bookUrl) removeDownload(request.bookUrl)
CacheBook.markBookFailed(
request.bookUrl,
getString(R.string.error_get_book_info)
)
AppLog.put( AppLog.put(
"$name》目录为空且加载详情页失败\n${it.localizedMessage}", "$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it, it,
@@ -169,6 +168,10 @@ class CacheBookService : BaseService() {
book.update() book.update()
} }
removeDownload(request.bookUrl) removeDownload(request.bookUrl)
CacheBook.markBookFailed(
request.bookUrl,
getString(R.string.error_get_chapter_list)
)
AppLog.put( AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}", "$name》目录为空且加载目录失败\n${it.localizedMessage}",
it, it,
@@ -45,7 +45,6 @@ import splitties.init.appCtx
import splitties.systemservices.notificationManager import splitties.systemservices.notificationManager
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.math.min
/** /**
* 校验书源 * 校验书源
@@ -53,7 +52,7 @@ import kotlin.math.min
class CheckSourceService : BaseService() { class CheckSourceService : BaseService() {
private var threadCount = OtherConfig.threadCount private var threadCount = OtherConfig.threadCount
private var searchCoroutine = private var searchCoroutine =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() Executors.newFixedThreadPool(threadCount).asCoroutineDispatcher()
private var notificationMsg = appCtx.getString(R.string.service_starting) private var notificationMsg = appCtx.getString(R.string.service_starting)
private var checkJob: Job? = null private var checkJob: Job? = null
private var originSize = 0 private var originSize = 0
@@ -20,12 +20,11 @@ import io.legado.app.databinding.DialogAddToBookshelfBinding
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.NetworkUtils import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.setLayout import io.legado.app.utils.setLayout
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -82,11 +81,14 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
} }
viewModel.load(bookUrl) { viewModel.load(bookUrl) {
viewModel.saveSearchBook(it) { viewModel.saveSearchBook(it) {
startActivity<BookInfoActivity> { startActivity(
putExtra("name", it.name) MainActivity.createBookInfoIntent(
putExtra("author", it.author) context = requireContext(),
putExtra("bookUrl", it.bookUrl) name = it.name,
} author = it.author,
bookUrl = it.bookUrl
)
)
dismiss() dismiss()
} }
} }
@@ -175,4 +177,4 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
} }
} }
@@ -47,6 +47,7 @@ data class BookCacheBookItem(
val author: String, val author: String,
val totalCount: Int, val totalCount: Int,
val cachedCount: Int, val cachedCount: Int,
val cachedFileCount: Int,
val waitingCount: Int, val waitingCount: Int,
val downloadingCount: Int, val downloadingCount: Int,
val errorCount: Int, val errorCount: Int,
@@ -188,9 +189,7 @@ class BookCacheManageViewModel(
sourceBooks sourceBooks
.filterNot { it.isLocal || it.isAudio } .filterNot { it.isLocal || it.isAudio }
.mapNotNull { book -> buildBookItem(book) } .mapNotNull { book -> buildBookItem(book) }
.filter { item -> .filter(::shouldShowItem)
item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
}
) )
val booksByUrl = items.associateBy { it.bookUrl } val booksByUrl = items.associateBy { it.bookUrl }
val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) { val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) {
@@ -265,9 +264,7 @@ class BookCacheManageViewModel(
val item = book val item = book
?.takeUnless { it.isLocal || it.isAudio } ?.takeUnless { it.isLocal || it.isAudio }
?.let { buildBookItem(it) } ?.let { buildBookItem(it) }
?.takeIf { ?.takeIf(::shouldShowItem)
it.cachedCount > 0 || it.isDownloading || it.errorCount > 0
}
val chapters = if (expanded && item != null) { val chapters = if (expanded && item != null) {
buildChapterItems(item.bookUrl) buildChapterItems(item.bookUrl)
} else { } else {
@@ -320,6 +317,7 @@ class BookCacheManageViewModel(
author = book.getRealAuthor(), author = book.getRealAuthor(),
totalCount = totalCount, totalCount = totalCount,
cachedCount = cachedCount, cachedCount = cachedCount,
cachedFileCount = cachedFileCount,
waitingCount = waitingCount, waitingCount = waitingCount,
downloadingCount = downloadingCount, downloadingCount = downloadingCount,
errorCount = errorIndices.size, errorCount = errorIndices.size,
@@ -327,6 +325,10 @@ class BookCacheManageViewModel(
) )
} }
private fun shouldShowItem(item: BookCacheBookItem): Boolean {
return item.cachedFileCount > 0 || item.isDownloading || item.errorCount > 0
}
private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> { private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> {
val book = bookDao.getBook(bookUrl) ?: return emptyList() val book = bookDao.getBook(bookUrl) ?: return emptyList()
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl) val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
@@ -472,7 +474,7 @@ class BookCacheManageViewModel(
} }
private fun deleteBookCache(bookUrl: String) { private fun deleteBookCache(bookUrl: String) {
CacheBook.remove(context, bookUrl) CacheBook.removeBook(bookUrl)
execute { execute {
clearBookCacheUseCase.execute(bookUrl) clearBookCacheUseCase.execute(bookUrl)
}.onSuccess { }.onSuccess {
@@ -5,7 +5,6 @@ import android.os.Bundle
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb import io.legado.app.data.appDb
@@ -33,7 +32,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.util.Collections import java.util.Collections
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) { class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = OtherConfig.threadCount private val threadCount = OtherConfig.threadCount
@@ -105,7 +103,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
private fun initSearchPool() { private fun initSearchPool() {
searchPool = Executors searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() .newFixedThreadPool(threadCount).asCoroutineDispatcher()
} }
private fun startSearch() { private fun startSearch() {
@@ -6,7 +6,6 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb import io.legado.app.data.appDb
@@ -56,7 +55,6 @@ import kotlinx.coroutines.withTimeout
import java.util.Collections import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) { open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
@@ -181,7 +179,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
private fun initSearchPool() { private fun initSearchPool() {
searchPool = Executors searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() .newFixedThreadPool(threadCount).asCoroutineDispatcher()
} }
fun refresh(): Boolean { fun refresh(): Boolean {
@@ -1,39 +0,0 @@
package io.legado.app.ui.book.explore
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.utils.startActivity
/**
* 发现列表
*/
class ExploreShowActivity : BaseComposeActivity() {
private lateinit var screenTitle: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
screenTitle = intent.getStringExtra("exploreName") ?: "探索"
}
@Composable
override fun Content() {
MaterialTheme {
ExploreShowScreen(
title = screenTitle,
intent = intent,
onBack = { finish() },
onBookClick = { book ->
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
)
}
}
}
@@ -1,7 +1,6 @@
package io.legado.app.ui.book.explore package io.legado.app.ui.book.explore
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -95,14 +94,15 @@ import org.koin.compose.koinInject
@Composable @Composable
fun ExploreShowScreen( fun ExploreShowScreen(
title: String, title: String,
intent: Intent, sourceUrl: String?,
exploreUrl: String?,
onBack: () -> Unit, onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit, onBookClick: (SearchBook) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel() viewModel: ExploreShowViewModel = koinViewModel()
) { ) {
LaunchedEffect(Unit) { LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
viewModel.initData(intent) viewModel.initData(sourceUrl, exploreUrl)
} }
val books by viewModel.uiBooks.collectAsState() val books by viewModel.uiBooks.collectAsState()
@@ -124,7 +124,6 @@ fun ExploreShowScreen(
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity val activity = context as? AppCompatActivity
val sourceUrl = remember(intent) { intent.getStringExtra("sourceUrl") }
val exploreKindUseCase: ExploreKindUiUseCase = koinInject() val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) { LaunchedEffect(sourceUrl) {
@@ -1,6 +1,5 @@
package io.legado.app.ui.book.explore package io.legado.app.ui.book.explore
import android.content.Intent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
@@ -124,10 +123,18 @@ class ExploreShowViewModel(
} }
} }
fun initData(intent: Intent) { fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
val incomingSourceUrl = intent.getStringExtra("sourceUrl") if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
return
}
sourceUrl = incomingSourceUrl sourceUrl = incomingSourceUrl
exploreUrl = intent.getStringExtra("exploreUrl") exploreUrl = incomingExploreUrl
page = 1
bookSource = null
_rawBooks.value = emptyList()
_isEndStateFlow.value = false
_errorMsg.value = null
_selectedKindTitle.value = null
viewModelScope.launch { viewModelScope.launch {
if (bookSource == null && incomingSourceUrl != null) { if (bookSource == null && incomingSourceUrl != null) {
@@ -1,193 +1,37 @@
package io.legado.app.ui.book.info package io.legado.app.ui.book.info
import io.legado.app.ui.config.otherConfig.OtherConfig
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.base.BaseComposeActivity import io.legado.app.base.BaseComposeActivity
import io.legado.app.help.config.AppConfig import io.legado.app.ui.main.MainActivity
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal
import io.legado.app.model.SourceCallBack
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.dialog.VariableDialog import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.openFileUri
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
private val viewModel: BookInfoViewModel by viewModel() private val viewModel: BookInfoViewModel by viewModel()
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
viewModel.onTocResult(it)
}
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
OtherConfig.defaultBookTreeUri = treeUri.toString()
}
}
private val infoEditResult = registerForActivityResult(
StartActivityContract(BookInfoEditActivity::class.java)
) {
if (it.resultCode == RESULT_OK) {
viewModel.onInfoEdited()
}
}
private val editSourceResult = registerForActivityResult(
StartActivityContract(BookSourceEditActivity::class.java)
) {
if (it.resultCode != RESULT_CANCELED) {
viewModel.onSourceEdited()
}
}
private val readBookResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
viewModel.onReaderResult(it.resultCode)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.initData(intent)
} }
@Composable @Composable
override fun Content() { override fun Content() {
val state = viewModel.uiState.collectAsStateWithLifecycle().value BookInfoRouteScreen(
bookUrl = intent.getStringExtra("bookUrl").orEmpty(),
LaunchedEffect(Unit) { viewModel = viewModel,
viewModel.effects.collectLatest { effect ->
when (effect) {
is BookInfoEffect.Finish -> {
effect.resultCode?.let { setResult(it) }
if (effect.afterTransition) finishAfterTransition() else finish()
}
is BookInfoEffect.OpenBookInfoEdit -> {
infoEditResult.launch {
putExtra("bookUrl", effect.bookUrl)
}
}
is BookInfoEffect.OpenReader -> startReadActivity(
book = effect.book,
inBookshelf = effect.inBookshelf,
chapterChanged = effect.chapterChanged,
)
is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl)
is BookInfoEffect.OpenBookSourceEdit -> {
editSourceResult.launch {
putExtra("sourceUrl", effect.sourceUrl)
}
}
is BookInfoEffect.OpenSourceLogin -> {
startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", effect.sourceUrl)
}
}
BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
title = getString(R.string.select_book_folder)
}
is BookInfoEffect.OpenFile -> openFileUri(effect.uri, effect.mimeType)
is BookInfoEffect.RunSourceCallback -> runSourceCallback(effect)
is BookInfoEffect.ShowVariableDialog -> {
showDialogFragment(
VariableDialog(
effect.title,
effect.key,
effect.variable,
effect.comment,
)
)
}
}
}
}
BookInfoScreen(
state = state,
onIntent = viewModel::onIntent,
onBack = { finishAfterTransition() }, onBack = { finishAfterTransition() },
onFinish = { resultCode, afterTransition ->
resultCode?.let { setResult(it) }
if (afterTransition) finishAfterTransition() else finish()
},
onOpenSearch = { keyword ->
startActivity(MainActivity.createSearchIntent(this, key = keyword))
},
) )
} }
override fun setVariable(key: String, variable: String?) { override fun setVariable(key: String, variable: String?) {
viewModel.setVariable(key, variable) viewModel.setVariable(key, variable)
} }
private fun runSourceCallback(effect: BookInfoEffect.RunSourceCallback) {
SourceCallBack.callBackBtn(
this,
effect.event,
effect.source,
effect.book,
null,
) {
when (val action = effect.action) {
is BookInfoCallbackAction.Search -> {
startActivity<SearchActivity> {
putExtra("key", action.keyword)
}
}
is BookInfoCallbackAction.ShareText -> {
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, action.text)
type = "text/plain"
}
startActivity(Intent.createChooser(intent, action.chooserTitle))
}
is BookInfoCallbackAction.CopyText -> {
sendToClip(action.text)
}
BookInfoCallbackAction.ClearCache -> {
viewModel.clearCache()
}
}
}
}
private fun startReadActivity(
book: io.legado.app.data.entities.Book,
inBookshelf: Boolean,
chapterChanged: Boolean,
) {
val cls = when {
book.isAudio -> AudioPlayActivity::class.java
!book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
else -> ReadBookActivity::class.java
}
readBookResult.launch(
Intent(this, cls).apply {
putExtra("bookUrl", book.bookUrl)
putExtra("inBookshelf", inBookshelf)
putExtra("chapterChanged", chapterChanged)
}
)
}
} }
@@ -0,0 +1,197 @@
package io.legado.app.ui.book.info
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.model.SourceCallBack
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.openFileUri
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collectLatest
@Composable
fun BookInfoRouteScreen(
bookUrl: String,
viewModel: BookInfoViewModel,
onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
onOpenSearch: (String) -> Unit,
onRegisterVariableSetter: (((String, String?) -> Unit)?) -> Unit = {}
) {
val context = LocalContext.current
val activity = context as AppCompatActivity
val tocActivityResult = rememberLauncherForActivityResult(TocActivityResult()) {
viewModel.onTocResult(it)
}
val localBookTreeSelect = rememberLauncherForActivityResult(HandleFileContract()) {
it.uri?.let { treeUri ->
OtherConfig.defaultBookTreeUri = treeUri.toString()
}
}
val infoEditResult = rememberLauncherForActivityResult(
StartActivityContract(BookInfoEditActivity::class.java)
) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.onInfoEdited()
}
}
val editSourceResult = rememberLauncherForActivityResult(
StartActivityContract(BookSourceEditActivity::class.java)
) {
if (it.resultCode != Activity.RESULT_CANCELED) {
viewModel.onSourceEdited()
}
}
val readBookResult = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
viewModel.onReaderResult(it.resultCode)
}
LaunchedEffect(bookUrl, viewModel) {
viewModel.initData(bookUrl)
}
DisposableEffect(viewModel) {
onRegisterVariableSetter(viewModel::setVariable)
onDispose {
onRegisterVariableSetter(null)
}
}
LaunchedEffect(viewModel, activity) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is BookInfoEffect.Finish -> {
onFinish(effect.resultCode, effect.afterTransition)
}
is BookInfoEffect.OpenBookInfoEdit -> {
infoEditResult.launch {
putExtra("bookUrl", effect.bookUrl)
}
}
is BookInfoEffect.OpenReader -> {
val cls = when {
effect.book.isAudio -> AudioPlayActivity::class.java
!effect.book.isLocal && effect.book.isImage && AppConfig.showMangaUi -> {
ReadMangaActivity::class.java
}
else -> ReadBookActivity::class.java
}
readBookResult.launch(
Intent(activity, cls).apply {
putExtra("bookUrl", effect.book.bookUrl)
putExtra("inBookshelf", effect.inBookshelf)
putExtra("chapterChanged", effect.chapterChanged)
}
)
}
is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl)
is BookInfoEffect.OpenBookSourceEdit -> {
editSourceResult.launch {
putExtra("sourceUrl", effect.sourceUrl)
}
}
is BookInfoEffect.OpenSourceLogin -> {
activity.startActivity<SourceLoginActivity> {
putExtra("type", "bookSource")
putExtra("key", effect.sourceUrl)
}
}
BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
title = activity.getString(R.string.select_book_folder)
}
is BookInfoEffect.OpenFile -> activity.openFileUri(effect.uri, effect.mimeType)
is BookInfoEffect.RunSourceCallback -> {
runSourceCallback(activity, effect, viewModel, onOpenSearch)
}
is BookInfoEffect.ShowVariableDialog -> {
activity.showDialogFragment(
VariableDialog(
effect.title,
effect.key,
effect.variable,
effect.comment,
)
)
}
}
}
}
BookInfoScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
onBack = onBack,
)
}
private fun runSourceCallback(
activity: AppCompatActivity,
effect: BookInfoEffect.RunSourceCallback,
viewModel: BookInfoViewModel,
onOpenSearch: (String) -> Unit,
) {
SourceCallBack.callBackBtn(
activity,
effect.event,
effect.source,
effect.book,
null,
) {
when (val action = effect.action) {
is BookInfoCallbackAction.Search -> {
onOpenSearch(action.keyword)
}
is BookInfoCallbackAction.ShareText -> {
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, action.text)
type = "text/plain"
}
activity.startActivity(Intent.createChooser(intent, action.chooserTitle))
}
is BookInfoCallbackAction.CopyText -> {
activity.sendToClip(action.text)
}
BookInfoCallbackAction.ClearCache -> {
viewModel.clearCache()
}
}
}
}
@@ -163,6 +163,7 @@ private fun BookInfoScreenContent(
text = { Text(stringResource(R.string.reading)) }, text = { Text(stringResource(R.string.reading)) },
) )
}, },
alwaysDrawBehindBars = true,
) { paddingValues -> ) { paddingValues ->
val book = state.book val book = state.book
if (book == null) { if (book == null) {
@@ -826,7 +827,7 @@ private fun BookInfoSummary(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(LegadoTheme.colorScheme.surface) .background(LegadoTheme.colorScheme.surface)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AppText( AppText(
@@ -101,9 +101,23 @@ class BookInfoViewModel(
private var readRecordObserveJob: Job? = null private var readRecordObserveJob: Job? = null
fun initData(intent: Intent) { fun initData(intent: Intent) {
if (currentBook != null) return initData(intent.getStringExtra("bookUrl") ?: "")
}
fun initData(bookUrl: String) {
if (currentBook?.bookUrl == bookUrl) return
currentBook = null
currentChapterList = emptyList()
currentWebFiles = emptyList()
currentKindLabels = emptyList()
currentGroupNames = null
currentHasCustomGroup = false
inBookshelf = false
bookSource = null
chapterChanged = false
clearReadRecordObserve()
_uiState.value = BookInfoUiState()
execute { execute {
val bookUrl = intent.getStringExtra("bookUrl") ?: ""
appDb.bookDao.getBook(bookUrl)?.let { appDb.bookDao.getBook(bookUrl)?.let {
inBookshelf = !it.isNotShelf inBookshelf = !it.isNotShelf
return@execute it return@execute it
@@ -1,243 +0,0 @@
package io.legado.app.ui.book.manage
import android.annotation.SuppressLint
import android.content.Context
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ItemArrangeBookBinding
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
//import io.legado.app.lib.theme.backgroundColor
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
import io.legado.app.ui.widget.recycler.ItemTouchCallback
import java.util.*
class BookAdapter(context: Context, val callBack: CallBack) :
RecyclerAdapter<Book, ItemArrangeBookBinding>(context),
ItemTouchCallback.Callback {
val groupRequestCode = 12
private val selectedBooks: HashSet<Book> = hashSetOf()
var actionItem: Book? = null
val selection: List<Book>
get() {
return getItems().filter {
selectedBooks.contains(it)
}
}
override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding {
return ItemArrangeBookBinding.inflate(inflater, parent, false)
}
override fun onCurrentListChanged() {
callBack.upSelectCount()
}
override fun convert(
holder: ItemViewHolder,
binding: ItemArrangeBookBinding,
item: Book,
payloads: MutableList<Any>
) {
binding.apply {
//root.setBackgroundColor(context.backgroundColor)
tvName.text = item.name
tvAuthor.text = item.author
tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE
tvGroupS.text = getGroupName(item.group)
checkbox.isChecked = selectedBooks.contains(item)
if (item.isLocal) {
tvOrigin.setText(R.string.local_book)
} else {
tvOrigin.text = item.originName
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) {
binding.apply {
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
if (buttonView.isPressed) {
getItem(holder.layoutPosition)?.let {
if (buttonView.isPressed) {
if (isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
}
}
root.setOnClickListener {
getItem(holder.layoutPosition)?.let {
checkbox.isChecked = !checkbox.isChecked
if (checkbox.isChecked) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
callBack.upSelectCount()
}
}
if (AppConfig.openBookInfoByClickTitle) {
tvName.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openBook(it)
}
}
}
tvDelete.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.deleteBook(it)
}
}
tvGroup.setOnClickListener {
getItem(holder.layoutPosition)?.let {
actionItem = it
callBack.selectGroup(groupRequestCode, it.group)
}
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun selectAll(selectAll: Boolean) {
if (selectAll) {
getItems().forEach {
selectedBooks.add(it)
}
} else {
selectedBooks.clear()
}
notifyDataSetChanged()
callBack.upSelectCount()
}
@SuppressLint("NotifyDataSetChanged")
fun revertSelection() {
getItems().forEach {
if (selectedBooks.contains(it)) {
selectedBooks.remove(it)
} else {
selectedBooks.add(it)
}
}
notifyDataSetChanged()
callBack.upSelectCount()
}
fun checkSelectedInterval() {
val selectedPosition = linkedSetOf<Int>()
getItems().forEachIndexed { index, it ->
if (selectedBooks.contains(it)) {
selectedPosition.add(index)
}
}
val minPosition = Collections.min(selectedPosition)
val maxPosition = Collections.max(selectedPosition)
val itemCount = maxPosition - minPosition + 1
for (i in minPosition..maxPosition) {
getItem(i)?.let {
selectedBooks.add(it)
}
}
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
}
private fun getGroupList(groupId: Long): List<String> {
val groupNames = arrayListOf<String>()
callBack.groupList.forEach {
if (it.groupId > 0 && it.groupId and groupId > 0) {
groupNames.add(it.groupName)
}
}
return groupNames
}
private fun getGroupName(groupId: Long): String {
val groupNames = getGroupList(groupId)
if (groupNames.isEmpty()) {
return ""
}
return groupNames.joinToString(",")
}
private var isMoved = false
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
val srcItem = getItem(srcPosition)
val targetItem = getItem(targetPosition)
if (srcItem != null && targetItem != null) {
if (srcItem.order == targetItem.order) {
for ((index, item) in getItems().withIndex()) {
item.order = index + 1
}
} else {
val pos = srcItem.order
srcItem.order = targetItem.order
targetItem.order = pos
}
}
swapItem(srcPosition, targetPosition)
isMoved = true
return true
}
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
if (isMoved) {
callBack.updateBook(*getItems().toTypedArray())
}
isMoved = false
}
val dragSelectCallback: DragSelectTouchHelper.Callback =
object : DragSelectTouchHelper.AdvanceCallback<Book>(Mode.ToggleAndReverse) {
override fun currentSelectedId(): MutableSet<Book> {
return selectedBooks
}
override fun getItemId(position: Int): Book {
return getItem(position)!!
}
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
getItem(position)?.let {
if (isSelected) {
selectedBooks.add(it)
} else {
selectedBooks.remove(it)
}
notifyItemChanged(position, bundleOf(Pair("selected", null)))
callBack.upSelectCount()
return true
}
return false
}
}
interface CallBack {
val groupList: List<BookGroup>
fun upSelectCount()
fun updateBook(vararg book: Book)
fun deleteBook(book: Book)
fun selectGroup(requestCode: Int, groupId: Long)
fun openBook(book: Book)
}
}
@@ -1,421 +0,0 @@
package io.legado.app.ui.book.manage
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import io.legado.app.data.entities.BookSource
import io.legado.app.databinding.ActivityArrangeBookBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.book.contains
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.book.group.GroupManageDialog
import io.legado.app.ui.book.group.GroupSelectDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.widget.SelectActionBar
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
import io.legado.app.ui.widget.recycler.ItemTouchCallback
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.cnCompare
import io.legado.app.utils.dpToPx
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.sendToClip
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
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 kotlin.math.max
/**
* 书架管理
*/
class BookshelfManageActivity :
VMBaseActivity<ActivityArrangeBookBinding, BookshelfManageViewModel>(),
PopupMenu.OnMenuItemClickListener,
SelectActionBar.CallBack,
BookAdapter.CallBack,
SourcePickerDialog.Callback,
GroupSelectDialog.CallBack {
override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
override val viewModel by viewModels<BookshelfManageViewModel>()
override val groupList: ArrayList<BookGroup> = arrayListOf()
private val groupRequestCode = 22
private val addToGroupRequestCode = 34
private val adapter by lazy { BookAdapter(this, this) }
private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var books: List<Book>? = null
private val waitDialog by lazy { WaitDialog(this) }
private val exportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
sendToClip(uri.toString())
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
viewModel.groupName = withContext(IO) {
appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName
?: getString(R.string.no_group)
}
upTitle()
}
initSearchView()
initRecyclerView()
initOtherView()
initGroupData()
upBookDataByGroupId()
}
override fun observeLiveBus() {
viewModel.batchChangeSourceState.observe(this) {
if (it) {
waitDialog.setText(R.string.change_source_batch)
waitDialog.show()
} else {
waitDialog.dismiss()
}
}
viewModel.batchChangeSourceProcessLiveData.observe(this) {
waitDialog.setText(it)
}
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.bookshelf_manage, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked =
AppConfig.openBookInfoByClickTitle
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun selectAll(selectAll: Boolean) {
adapter.selectAll(selectAll)
}
override fun revertSelection() {
adapter.revertSelection()
}
override fun onClickSelectBarMainAction() {
selectGroup(groupRequestCode, 0)
}
private fun upTitle() {
searchView.queryHint = getString(R.string.screen) + "" + viewModel.groupName
}
private fun initSearchView() {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
upBookData()
return false
}
})
}
private fun initRecyclerView() {
//binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.addItemDecoration(VerticalDivider(this))
binding.recyclerView.adapter = adapter
itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3
val dragSelectTouchHelper: DragSelectTouchHelper =
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
// When this page is opened, it is in selection mode
dragSelectTouchHelper.activeSlideSelect()
// Note: need judge selection first, so add ItemTouchHelper after it.
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
}
private fun initOtherView() {
binding.selectActionBar.setMainActionText(R.string.move_to_group)
binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel)
binding.selectActionBar.setOnMenuItemClickListener(this)
binding.selectActionBar.setCallBack(this)
waitDialog.setOnCancelListener {
viewModel.batchChangeSourceCoroutine?.cancel()
}
}
@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 upBookDataByGroupId() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId)
appDb.bookDao.flowByGroup(viewModel.groupId).map { list ->
when (bookSort) {
1 -> list.sortedByDescending {
it.latestChapterTime
}
2 -> list.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> list.sortedBy {
it.order
}
4 -> list.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> list.sortedByDescending {
it.durChapterTime
}
}
}.catch {
AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO)
.conflate().collect {
books = it
upBookData()
itemTouchCallback.isCanDrag = bookSort == 3
}
}
}
private fun upBookData() {
books?.let { books ->
val searchKey = searchView.query
if (searchKey.isNullOrEmpty()) {
adapter.setItems(books)
} else {
books.filter {
it.contains(searchKey.toString())
}.let {
adapter.setItems(it)
}
}
}
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
R.id.menu_open_book_info_by_click_title -> {
AppConfig.openBookInfoByClickTitle = !item.isChecked
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file ->
exportDir.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"bookSource.json",
file,
"application/json"
)
}
}
else -> if (item.groupId == R.id.menu_group) {
viewModel.groupName = item.title.toString()
upTitle()
viewModel.groupId =
appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
upBookDataByGroupId()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_del_selection -> alertDelSelection()
R.id.menu_update_enable ->
viewModel.upCanUpdate(adapter.selection, true)
R.id.menu_update_disable ->
viewModel.upCanUpdate(adapter.selection, false)
R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection)
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
}
return false
}
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)
}
}
}
private fun alertDelSelection() {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
val checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
okButton {
LocalConfig.deleteBookOriginal = checkBox.isChecked
viewModel.deleteBook(adapter.selection, checkBox.isChecked)
}
noButton()
}
}
override fun selectGroup(requestCode: Int, groupId: Long) {
showDialogFragment(
GroupSelectDialog(groupId, requestCode)
)
}
override fun upGroup(requestCode: Int, groupId: Long) {
when (requestCode) {
groupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) {
books[it].copy(group = groupId)
}
viewModel.updateBook(*array)
}
adapter.groupRequestCode -> {
adapter.actionItem?.let {
viewModel.updateBook(it.copy(group = groupId))
}
}
addToGroupRequestCode -> adapter.selection.let { books ->
val array = Array(books.size) { index ->
val book = books[index]
book.copy(group = book.group or groupId)
}
viewModel.updateBook(*array)
}
}
}
override fun upSelectCount() {
binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size)
}
override fun updateBook(vararg book: Book) {
viewModel.updateBook(*book)
}
override fun deleteBook(book: Book) {
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
var checkBox: CheckBox? = null
if (book.isLocal) {
checkBox = CheckBox(this@BookshelfManageActivity).apply {
setText(R.string.delete_book_file)
isChecked = LocalConfig.deleteBookOriginal
}
val view = LinearLayout(this@BookshelfManageActivity).apply {
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
addView(checkBox)
}
customView { view }
}
okButton {
if (checkBox != null) {
LocalConfig.deleteBookOriginal = checkBox.isChecked
}
viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal)
}
}
}
override fun openBook(book: Book) {
startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}
override fun sourceOnClick(source: BookSource) {
viewModel.changeSource(adapter.selection, source)
viewModel.batchChangeSourceState.value = true
}
}
@@ -82,7 +82,6 @@ import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.service.ExportBookService import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.info.ChangeSourceSheet import io.legado.app.ui.book.info.ChangeSourceSheet
import io.legado.app.ui.book.info.GroupSelectSheet import io.legado.app.ui.book.info.GroupSelectSheet
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
@@ -141,19 +140,25 @@ private data class BookshelfManageListState(
fun BookshelfManageRouteScreen( fun BookshelfManageRouteScreen(
groupId: Long, groupId: Long,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
viewModel: BookshelfManageScreenViewModel = koinViewModel() viewModel: BookshelfManageScreenViewModel = koinViewModel()
) { ) {
LaunchedEffect(groupId) { LaunchedEffect(groupId) {
viewModel.dispatch(BookshelfManageScreenIntent.Initialize(groupId)) viewModel.dispatch(BookshelfManageScreenIntent.Initialize(groupId))
} }
BookshelfManageScreen(viewModel = viewModel, onBackClick = onBackClick) BookshelfManageScreen(
viewModel = viewModel,
onBackClick = onBackClick,
onOpenBookInfo = onOpenBookInfo
)
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun BookshelfManageScreen( private fun BookshelfManageScreen(
viewModel: BookshelfManageScreenViewModel, viewModel: BookshelfManageScreenViewModel,
onBackClick: () -> Unit onBackClick: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@@ -205,18 +210,8 @@ private fun BookshelfManageScreen(
val resultAnalyzedText = stringResource(R.string.result_analyzed) val resultAnalyzedText = stringResource(R.string.result_analyzed)
val errorScopeInputText = stringResource(R.string.error_scope_input) val errorScopeInputText = stringResource(R.string.error_scope_input)
val noGroupText = stringResource(R.string.no_group) val noGroupText = stringResource(R.string.no_group)
val exportFileNameHintText = "书名:《{name}》 作者:{author}" val exportFileNameHintText = stringResource(R.string.export_file_name_template_hint)
val exportFileNameHelpText = """ val exportFileNameHelpText = stringResource(R.string.export_file_name_template_help)
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } } val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } } val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
@@ -331,10 +326,8 @@ private fun BookshelfManageScreen(
when (effect) { when (effect) {
is BookshelfManageScreenEffect.ShowMessage -> context.toastOnUi(effect.message) is BookshelfManageScreenEffect.ShowMessage -> context.toastOnUi(effect.message)
is BookshelfManageScreenEffect.NotifyBookChanged -> Unit is BookshelfManageScreenEffect.NotifyBookChanged -> Unit
is BookshelfManageScreenEffect.OpenBookInfo -> context.startActivity<BookInfoActivity> { is BookshelfManageScreenEffect.OpenBookInfo -> {
putExtra("name", effect.name) onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
putExtra("author", effect.author)
putExtra("bookUrl", effect.bookUrl)
} }
} }
} }
@@ -686,8 +679,17 @@ private fun BookshelfManageScreen(
val cacheCount = remember(renderVersion, book.bookUrl) { val cacheCount = remember(renderVersion, book.bookUrl) {
viewModel.getCacheCount(book.bookUrl) ?: 0 viewModel.getCacheCount(book.bookUrl) ?: 0
} }
val isDownloading = remember(renderVersion, book.bookUrl) { val downloadState = state.downloadStates[book.bookUrl]
viewModel.isBookDownloading(book.bookUrl) val isPreparingDownload = state.pendingDownloadBookUrls.contains(book.bookUrl)
val waitingDownloadCount = downloadState?.waitingCount ?: 0
val runningDownloadCount = downloadState?.runningIndices?.size ?: 0
val isDownloadingInCacheModel = viewModel.isBookDownloading(book.bookUrl)
val isDownloading = isPreparingDownload ||
waitingDownloadCount > 0 ||
runningDownloadCount > 0 ||
isDownloadingInCacheModel
val downloadFailureText = state.downloadFailureMessages[book.bookUrl]?.let {
stringResource(R.string.cache_download_failed, it)
} }
val isSelected = selectedBookUrls.contains(book.bookUrl) val isSelected = selectedBookUrls.contains(book.bookUrl)
val exportMsg = remember(renderVersion, book.bookUrl) { val exportMsg = remember(renderVersion, book.bookUrl) {
@@ -761,8 +763,20 @@ private fun BookshelfManageScreen(
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
if (downloadFailureText != null) {
AppText(
text = downloadFailureText,
modifier = Modifier.weight(1f),
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.error,
maxLines = 1
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SmallTonalIconButton( SmallTonalIconButton(
onClick = { onClick = {
@@ -912,7 +926,7 @@ private fun BookshelfManageScreen(
oldBookUrl = book.bookUrl, oldBookUrl = book.bookUrl,
source = source, source = source,
book = newBook, book = newBook,
chapters = toc, chapterCount = toc.size,
) )
) )
manualSearchPreviewBook = null manualSearchPreviewBook = null
@@ -27,6 +27,7 @@ import io.legado.app.help.book.isAudio
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.removeType import io.legado.app.help.book.removeType
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheBookDownloadState
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.service.ExportBookService import io.legado.app.service.ExportBookService
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
@@ -72,6 +73,9 @@ data class BookshelfManageScreenUiState(
val bookSort: Int = BookshelfConfig.bookshelfSort, val bookSort: Int = BookshelfConfig.bookshelfSort,
val bookSortOrder: Int = BookshelfConfig.bookshelfSortOrder, val bookSortOrder: Int = BookshelfConfig.bookshelfSortOrder,
val isDownloadRunning: Boolean = false, val isDownloadRunning: Boolean = false,
val pendingDownloadBookUrls: Set<String> = emptySet(),
val downloadStates: Map<String, CacheBookDownloadState> = emptyMap(),
val downloadFailureMessages: Map<String, String> = emptyMap(),
val isChangingSource: Boolean = false, val isChangingSource: Boolean = false,
val changeSourceProgress: String? = null, val changeSourceProgress: String? = null,
val changeSourceMessage: String? = null, val changeSourceMessage: String? = null,
@@ -118,7 +122,7 @@ sealed interface BookshelfManageScreenIntent {
val oldBookUrl: String, val oldBookUrl: String,
val source: BookSource, val source: BookSource,
val book: Book, val book: Book,
val chapters: List<BookChapter>, val chapterCount: Int,
) : BookshelfManageScreenIntent ) : BookshelfManageScreenIntent
data class AddPreviewItemToShelf(val oldBookUrl: String) : BookshelfManageScreenIntent data class AddPreviewItemToShelf(val oldBookUrl: String) : BookshelfManageScreenIntent
data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : BookshelfManageScreenIntent data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : BookshelfManageScreenIntent
@@ -220,7 +224,7 @@ class BookshelfManageScreenViewModel(
intent.oldBookUrl, intent.oldBookUrl,
intent.source, intent.source,
intent.book, intent.book,
intent.chapters intent.chapterCount
) )
is BookshelfManageScreenIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl) is BookshelfManageScreenIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl)
@@ -380,8 +384,33 @@ class BookshelfManageScreenViewModel(
} }
} }
viewModelScope.launch { viewModelScope.launch {
CacheBook.downloadStateFlow.collect { state -> CacheBook.downloadStateFlow.collect { downloadState ->
state.books.keys.forEach { bookUrl -> _uiState.update { state ->
val successfulBookUrls = downloadState.books
.filterValues {
it.successCount > 0 &&
it.failedIndices.isEmpty() &&
it.failureMessage == null
}
.keys
val failureMessages = downloadState.books.mapNotNull { (bookUrl, bookState) ->
val message = bookState.failureMessage ?: if (bookState.failedIndices.isNotEmpty()) {
"${bookState.failedIndices.size}"
} else {
null
}
message?.let { bookUrl to it }
}.toMap()
state.copy(
isDownloadRunning = downloadState.isRunning,
pendingDownloadBookUrls = state.pendingDownloadBookUrls - downloadState.books.keys,
downloadStates = downloadState.books,
downloadFailureMessages = (
state.downloadFailureMessages - successfulBookUrls
) + failureMessages,
)
}
downloadState.books.keys.forEach { bookUrl ->
scheduleDownloadStatusRefresh(bookUrl) scheduleDownloadStatusRefresh(bookUrl)
} }
scheduleDownloadStatusRefresh() scheduleDownloadStatusRefresh()
@@ -509,9 +538,13 @@ class BookshelfManageScreenViewModel(
} }
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) { private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
_uiState.update {
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
}
execute { execute {
batchCacheDownloadUseCase.execute( batchCacheDownloadUseCase.execute(
bookUrls = books.map { it.bookUrl }.toSet(), bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters, downloadAllChapters = downloadAllChapters,
skipAudioBooks = true skipAudioBooks = true
) )
@@ -522,12 +555,41 @@ class BookshelfManageScreenViewModel(
private fun toggleBookDownload(book: Book) { private fun toggleBookDownload(book: Book) {
if (book.isLocal) return if (book.isLocal) return
if (isBookDownloading(book.bookUrl)) { if (isBookDownloading(book.bookUrl) || uiState.value.pendingDownloadBookUrls.contains(book.bookUrl)) {
CacheBook.remove(context, book.bookUrl) CacheBook.remove(context, book.bookUrl)
_uiState.update {
it.copy(pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl)
}
syncDownloadRunning() syncDownloadRunning()
} else { } else {
_uiState.update {
it.copy(
pendingDownloadBookUrls = it.pendingDownloadBookUrls + book.bookUrl,
downloadFailureMessages = it.downloadFailureMessages - book.bookUrl
)
}
execute { execute {
cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex) cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
}.onSuccess { count ->
if (count <= 0) {
_uiState.update {
it.copy(
pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
downloadFailureMessages = it.downloadFailureMessages +
(book.bookUrl to "没有可缓存的章节")
)
}
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("没有可缓存的章节"))
}
}.onError { error ->
_uiState.update {
it.copy(
pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
downloadFailureMessages = it.downloadFailureMessages +
(book.bookUrl to (error.localizedMessage ?: "未知错误"))
)
}
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存失败\n${error.localizedMessage}"))
}.onFinally { }.onFinally {
syncDownloadRunning() syncDownloadRunning()
} }
@@ -599,6 +661,9 @@ class BookshelfManageScreenViewModel(
private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) { private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
if (bookUrls.isEmpty()) return if (bookUrls.isEmpty()) return
_uiState.update {
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
}
execute { execute {
batchCacheDownloadUseCase.execute( batchCacheDownloadUseCase.execute(
bookUrls = bookUrls, bookUrls = bookUrls,
@@ -652,7 +717,7 @@ class BookshelfManageScreenViewModel(
return return
} }
execute { execute {
val concurrency = OtherConfig.threadCount.coerceIn(1, 4) val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
_uiState.update { _uiState.update {
it.copy( it.copy(
isChangingSource = true, isChangingSource = true,
@@ -775,19 +840,19 @@ class BookshelfManageScreenViewModel(
oldBookUrl: String, oldBookUrl: String,
source: BookSource, source: BookSource,
book: Book, book: Book,
chapters: List<BookChapter>, chapterCount: Int,
) { ) {
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
batchChangePreviewItems = state.batchChangePreviewItems.map { item -> batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
if (item.oldBook.bookUrl == oldBookUrl) { if (item.oldBook.bookUrl == oldBookUrl) {
book.totalChapterNum = chapters.size book.totalChapterNum = chapterCount
item.copy( item.copy(
candidates = listOf( candidates = listOf(
BatchChangeSourceCandidate( BatchChangeSourceCandidate(
source = source, source = source,
book = book, book = book,
chapterCount = chapters.size chapterCount = chapterCount
) )
) + ) +
item.candidates, item.candidates,
@@ -1,135 +0,0 @@
package io.legado.app.ui.book.manage
import android.app.Application
import androidx.lifecycle.MutableLiveData
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookSource
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.removeType
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.SourceCallBack
import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.stackTraceStr
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.writeToOutputStream
import kotlinx.coroutines.delay
import java.io.File
class BookshelfManageViewModel(application: Application) : BaseViewModel(application) {
var groupId: Long = -1L
var groupName: String? = null
val batchChangeSourceState = MutableLiveData<Boolean>()
val batchChangeSourceProcessLiveData = MutableLiveData<String>()
var batchChangeSourceCoroutine: Coroutine<Unit>? = null
fun upCanUpdate(books: List<Book>, canUpdate: Boolean) {
execute {
val array = Array(books.size) {
books[it].copy(canUpdate = canUpdate).apply {
if (!canUpdate) {
removeType(BookType.updateError)
}
}
}
appDb.bookDao.update(*array)
}
}
fun updateBook(vararg book: Book) {
execute {
appDb.bookDao.update(*book)
}
}
fun deleteBook(books: List<Book>, deleteOriginal: Boolean = false) {
execute {
books.forEach {
if (it.isLocal) {
LocalBook.deleteBook(it, deleteOriginal)
} else {
val source = appDb.bookSourceDao.getBookSource(it.origin)
SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, it)
}
appDb.bookChapterDao.delByBook(it.bookUrl)
}
appDb.bookDao.delete(*books.toTypedArray())
}
}
fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) {
execute {
val path = "${context.filesDir}/shareBookSource.json"
FileUtils.delete(path)
val file = FileUtils.createFileWithReplace(path)
val sources = appDb.bookDao.getAllUseBookSource()
file.outputStream().buffered().use {
GSON.writeToOutputStream(it, sources)
}
file
}.onSuccess {
success.invoke(it)
}.onError {
context.toastOnUi(it.stackTraceStr)
}
}
fun changeSource(books: List<Book>, source: BookSource) {
batchChangeSourceCoroutine?.cancel()
batchChangeSourceCoroutine = execute {
val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L
books.forEachIndexed { index, book ->
batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}")
if (book.isLocal) return@forEachIndexed
if (book.origin == source.bookSourceUrl) return@forEachIndexed
val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
.onFailure {
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
}.getOrNull() ?: return@forEachIndexed
kotlin.runCatching {
if (newBook.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, newBook)
}
}.onFailure {
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
return@forEachIndexed
}
WebBook.getChapterListAwait(source, newBook)
.onFailure {
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
}.getOrNull()?.let { toc ->
book.migrateTo(newBook, toc)
book.removeType(BookType.updateError)
appDb.bookDao.insert(newBook)
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
delay(changeSourceDelay)
}
}.onStart {
batchChangeSourceState.postValue(true)
}.onFinally {
batchChangeSourceState.postValue(false)
}
}
fun clearCache(books: List<Book>) {
execute {
books.forEach {
BookHelp.clearCache(it)
}
}.onSuccess {
context.toastOnUi(R.string.clear_cache_success)
}
}
}
@@ -1,160 +0,0 @@
package io.legado.app.ui.book.manage
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.databinding.DialogSourcePickerBinding
import io.legado.app.databinding.Item1lineTextBinding
import io.legado.app.help.config.AppConfig
//import io.legado.app.lib.theme.primaryColor
//import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.ui.widget.number.NumberPickerDialog
import io.legado.app.utils.dpToPx
import io.legado.app.utils.setLayout
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.flowOn
import kotlinx.coroutines.launch
import splitties.views.onClick
/**
* 书源选择
*/
class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogSourcePickerBinding::bind)
private val searchView: SearchView by lazy {
binding.toolBar.findViewById(R.id.search_view)
}
private val toolBar: Toolbar by lazy {
binding.toolBar.toolbar
}
private val adapter by lazy {
SourceAdapter(requireContext())
}
private var sourceFlowJob: Job? = null
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initData()
initMenu()
}
private fun initView() {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.toolBar.title = "选择书源"
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search_book_source)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
initData(newText)
return false
}
})
}
private fun initData(searchKey: String? = null) {
sourceFlowJob?.cancel()
sourceFlowJob = lifecycleScope.launch {
when {
searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
}.catch {
AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).collect {
adapter.setItems(it)
}
}
}
private fun initMenu() {
toolBar.setOnMenuItemClickListener(this)
toolBar.inflateMenu(R.menu.source_picker)
//toolBar.menu.applyTint(requireContext())
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_change_source_delay -> NumberPickerDialog(requireContext())
.setTitle(getString(R.string.change_source_delay))
.setMaxValue(9999)
.setMinValue(0)
.setValue(AppConfig.batchChangeSourceDelay)
.show {
AppConfig.batchChangeSourceDelay = it
}
}
return true
}
inner class SourceAdapter(context: Context) :
RecyclerAdapter<BookSourcePart, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false).apply {
root.setPadding(16.dpToPx())
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: BookSourcePart,
payloads: MutableList<Any>
) {
binding.textView.text = item.getDisPlayNameGroup()
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.onClick {
getItemByLayoutPosition(holder.layoutPosition)?.let {
it.getBookSource()?.let { source ->
callback?.sourceOnClick(source)
}
dismissAllowingStateLoss()
}
}
}
}
private val callback: Callback?
get() {
return (parentFragment as? Callback) ?: activity as? Callback
}
interface Callback {
fun sourceOnClick(source: BookSource)
}
}
@@ -5,8 +5,8 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -41,11 +41,14 @@ class SearchActivity : BaseComposeActivity() {
viewModel = viewModel, viewModel = viewModel,
onBack = { finish() }, onBack = { finish() },
onOpenBookInfo = { name, author, bookUrl -> onOpenBookInfo = { name, author, bookUrl ->
startActivity<BookInfoActivity> { startActivity(
putExtra("name", name) MainActivity.createBookInfoIntent(
putExtra("author", author) context = this,
putExtra("bookUrl", bookUrl) name = name,
} author = author,
bookUrl = bookUrl
)
)
}, },
onOpenSourceManage = { onOpenSourceManage = {
startActivity<BookSourceActivity>() startActivity<BookSourceActivity>()
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.model.CacheBook
import io.legado.app.service.WebService import io.legado.app.service.WebService
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.theme.adaptiveContentPadding
@@ -366,6 +367,7 @@ fun OtherConfigScreen(
SliderSettingItem( SliderSettingItem(
title = stringResource(R.string.threads_num_title), title = stringResource(R.string.threads_num_title),
description = stringResource(R.string.threads_num_summary),
value = OtherConfig.threadCount.toFloat(), value = OtherConfig.threadCount.toFloat(),
defaultValue = 8f, defaultValue = 8f,
valueRange = 1f..256f, valueRange = 1f..256f,
@@ -375,10 +377,15 @@ fun OtherConfigScreen(
SliderSettingItem( SliderSettingItem(
title = stringResource(R.string.cache_book_threads_num_title), title = stringResource(R.string.cache_book_threads_num_title),
description = stringResource(R.string.cache_book_threads_num_summary), description = stringResource(R.string.cache_book_threads_num_summary),
value = OtherConfig.cacheBookThreadCount.toFloat(), value = OtherConfig.cacheBookThreadCount
defaultValue = 16f, .coerceIn(1, CacheBook.maxDownloadConcurrency)
valueRange = 1f..256f, .toFloat(),
onValueChange = { OtherConfig.cacheBookThreadCount = it.toInt() } defaultValue = CacheBook.maxDownloadConcurrency.toFloat(),
valueRange = 1f..CacheBook.maxDownloadConcurrency.toFloat(),
onValueChange = {
OtherConfig.cacheBookThreadCount =
it.toInt().coerceIn(1, CacheBook.maxDownloadConcurrency)
}
) )
SwitchSettingItem( SwitchSettingItem(
@@ -41,7 +41,9 @@ import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.CrashLogsDialog
import io.legado.app.ui.about.UpdateDialog import io.legado.app.ui.about.UpdateDialog
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.explore.ExploreShowScreen
import io.legado.app.ui.book.info.BookInfoRouteScreen
import io.legado.app.ui.book.info.BookInfoViewModel
import io.legado.app.ui.book.import.local.ImportBookScreen import io.legado.app.ui.book.import.local.ImportBookScreen
import io.legado.app.ui.book.import.remote.RemoteBookScreen import io.legado.app.ui.book.import.remote.RemoteBookScreen
import io.legado.app.ui.book.search.SearchIntent import io.legado.app.ui.book.search.SearchIntent
@@ -64,6 +66,7 @@ import io.legado.app.ui.rss.read.MainRouteRssRead
import io.legado.app.ui.rss.read.RssReadRouteScreen import io.legado.app.ui.rss.read.RssReadRouteScreen
import io.legado.app.ui.welcome.WelcomeActivity import io.legado.app.ui.welcome.WelcomeActivity
import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
@@ -80,7 +83,7 @@ import kotlin.coroutines.suspendCoroutine
/** /**
* 主界面 * 主界面
*/ */
open class MainActivity : BaseComposeActivity() { open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
companion object { companion object {
const val EXTRA_START_ROUTE = "startRoute" const val EXTRA_START_ROUTE = "startRoute"
@@ -96,11 +99,19 @@ open class MainActivity : BaseComposeActivity() {
private const val ROUTE_CACHE = "cache" private const val ROUTE_CACHE = "cache"
private const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage" private const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage"
private const val ROUTE_SEARCH = "search" private const val ROUTE_SEARCH = "search"
private const val ROUTE_BOOK_INFO = "book/info"
private const val ROUTE_EXPLORE_SHOW = "explore/show"
private const val ROUTE_RSS_SORT = "rss/sort" private const val ROUTE_RSS_SORT = "rss/sort"
private const val ROUTE_RSS_READ = "rss/read" private const val ROUTE_RSS_READ = "rss/read"
private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id" 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_KEY = "extra_search_key"
private const val EXTRA_SEARCH_SCOPE = "extra_search_scope" private const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
private const val EXTRA_BOOK_NAME = "name"
private const val EXTRA_BOOK_AUTHOR = "author"
private const val EXTRA_BOOK_URL = "bookUrl"
private const val EXTRA_EXPLORE_NAME = "exploreName"
private const val EXTRA_SOURCE_URL = "sourceUrl"
private const val EXTRA_EXPLORE_URL = "exploreUrl"
private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url" private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url" private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
@@ -196,6 +207,34 @@ open class MainActivity : BaseComposeActivity() {
} }
} }
fun createBookInfoIntent(
context: Context,
name: String? = null,
author: String? = null,
bookUrl: String
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_BOOK_INFO)
putExtra(EXTRA_BOOK_NAME, name)
putExtra(EXTRA_BOOK_AUTHOR, author)
putExtra(EXTRA_BOOK_URL, bookUrl)
}
}
fun createExploreShowIntent(
context: Context,
exploreName: String? = null,
sourceUrl: String,
exploreUrl: String? = null
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_EXPLORE_SHOW)
putExtra(EXTRA_EXPLORE_NAME, exploreName)
putExtra(EXTRA_SOURCE_URL, sourceUrl)
putExtra(EXTRA_EXPLORE_URL, exploreUrl)
}
}
private fun routeForConfigTag(configTag: String?): String { private fun routeForConfigTag(configTag: String?): String {
return when (configTag) { return when (configTag) {
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
@@ -210,6 +249,7 @@ open class MainActivity : BaseComposeActivity() {
private val viewModel by viewModel<MainViewModel>() private val viewModel by viewModel<MainViewModel>()
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1) private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
@Serializable @Serializable
private sealed interface MainRoute : NavKey private sealed interface MainRoute : NavKey
@@ -253,6 +293,20 @@ open class MainActivity : BaseComposeActivity() {
val scopeRaw: String? = null val scopeRaw: String? = null
) : MainRoute ) : MainRoute
@Serializable
private data class MainRouteBookInfo(
val name: String?,
val author: String?,
val bookUrl: String,
) : MainRoute
@Serializable
private data class MainRouteExploreShow(
val title: String?,
val sourceUrl: String,
val exploreUrl: String?,
) : MainRoute
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -414,6 +468,26 @@ open class MainActivity : BaseComposeActivity() {
onNavigateToBookCacheManage = { onNavigateToBookCacheManage = {
navigateToRoute(backStack, MainRouteBookCacheManage) navigateToRoute(backStack, MainRouteBookCacheManage)
}, },
onNavigateToBookInfo = { name, author, bookUrl ->
navigateToRoute(
backStack,
MainRouteBookInfo(
name = name,
author = author,
bookUrl = bookUrl
)
)
},
onNavigateToExploreShow = { title, sourceUrl, exploreUrl ->
navigateToRoute(
backStack,
MainRouteExploreShow(
title = title,
sourceUrl = sourceUrl,
exploreUrl = exploreUrl
)
)
},
onNavigateToRssSort = { sourceUrl, sortUrl, key -> onNavigateToRssSort = { sourceUrl, sortUrl, key ->
navigateToRoute( navigateToRoute(
backStack, backStack,
@@ -484,7 +558,17 @@ open class MainActivity : BaseComposeActivity() {
entry<MainRouteCache> { route -> entry<MainRouteCache> { route ->
BookshelfManageRouteScreen( BookshelfManageRouteScreen(
groupId = route.groupId, groupId = route.groupId,
onBackClick = { navigateBack(backStack) } onBackClick = { navigateBack(backStack) },
onOpenBookInfo = { name, author, bookUrl ->
navigateToRoute(
backStack,
MainRouteBookInfo(
name = name,
author = author,
bookUrl = bookUrl
)
)
}
) )
} }
@@ -533,11 +617,14 @@ open class MainActivity : BaseComposeActivity() {
viewModel = searchViewModel, viewModel = searchViewModel,
onBack = { navigateBack(backStack) }, onBack = { navigateBack(backStack) },
onOpenBookInfo = { name, author, bookUrl -> onOpenBookInfo = { name, author, bookUrl ->
this@MainActivity.startActivity<BookInfoActivity> { navigateToRoute(
putExtra("name", name) backStack,
putExtra("author", author) MainRouteBookInfo(
putExtra("bookUrl", bookUrl) name = name,
} author = author,
bookUrl = bookUrl
)
)
}, },
onOpenSourceManage = { onOpenSourceManage = {
this@MainActivity.startActivity<BookSourceActivity>() this@MainActivity.startActivity<BookSourceActivity>()
@@ -573,6 +660,41 @@ open class MainActivity : BaseComposeActivity() {
onBackClick = { navigateBack(backStack) } onBackClick = { navigateBack(backStack) }
) )
} }
entry<MainRouteBookInfo> { route ->
val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
BookInfoRouteScreen(
bookUrl = route.bookUrl,
viewModel = bookInfoViewModel,
onBack = { navigateBack(backStack) },
onFinish = { _, _ -> navigateBack(backStack) },
onOpenSearch = { keyword ->
navigateToRoute(backStack, MainRouteSearch(key = keyword))
},
onRegisterVariableSetter = { setter ->
bookInfoVariableSetter = setter
}
)
}
entry<MainRouteExploreShow> { route ->
ExploreShowScreen(
title = route.title ?: "探索",
sourceUrl = route.sourceUrl,
exploreUrl = route.exploreUrl,
onBack = { navigateBack(backStack) },
onBookClick = { book ->
navigateToRoute(
backStack,
MainRouteBookInfo(
name = book.name,
author = book.author,
bookUrl = book.bookUrl
)
)
}
)
}
} }
) )
} }
@@ -631,6 +753,30 @@ open class MainActivity : BaseComposeActivity() {
} }
} }
is MainRouteBookInfo -> {
if (
currentRoute == MainRouteHome ||
currentRoute is MainRouteSearch ||
currentRoute is MainRouteExploreShow
) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
is MainRouteExploreShow -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
is MainRouteRssSort -> { is MainRouteRssSort -> {
if (currentRoute == MainRouteHome) { if (currentRoute == MainRouteHome) {
backStack.add(route) backStack.add(route)
@@ -827,10 +973,32 @@ open class MainActivity : BaseComposeActivity() {
key = intent?.getStringExtra(EXTRA_SEARCH_KEY), key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE) scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE)
) )
ROUTE_BOOK_INFO -> intent?.getStringExtra(EXTRA_BOOK_URL)
?.takeIf { it.isNotBlank() }
?.let { bookUrl ->
MainRouteBookInfo(
name = intent.getStringExtra(EXTRA_BOOK_NAME),
author = intent.getStringExtra(EXTRA_BOOK_AUTHOR),
bookUrl = bookUrl
)
} ?: MainRouteHome
ROUTE_EXPLORE_SHOW -> intent?.getStringExtra(EXTRA_SOURCE_URL)
?.takeIf { it.isNotBlank() }
?.let { sourceUrl ->
MainRouteExploreShow(
title = intent.getStringExtra(EXTRA_EXPLORE_NAME),
sourceUrl = sourceUrl,
exploreUrl = intent.getStringExtra(EXTRA_EXPLORE_URL)
)
} ?: MainRouteHome
else -> MainRouteHome else -> MainRouteHome
} }
} }
override fun setVariable(key: String, variable: String?) {
bookInfoVariableSetter?.invoke(key, variable)
}
} }
class LauncherW : MainActivity() class LauncherW : MainActivity()
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import io.legado.app.R import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -76,7 +75,6 @@ import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu 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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.startActivity
import io.legado.app.utils.startActivityForBook import io.legado.app.utils.startActivityForBook
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -95,6 +93,8 @@ fun MainScreen(
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit, onNavigateToCache: (Long) -> Unit,
onNavigateToBookCacheManage: () -> Unit, onNavigateToBookCacheManage: () -> Unit,
onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) { ) {
@@ -374,11 +374,7 @@ fun MainScreen(
context.startActivityForBook(book) context.startActivityForBook(book)
}, },
onBookLongClick = { book -> onBookLongClick = { book ->
context.startActivity<BookInfoActivity> { onNavigateToBookInfo(book.name, book.author, book.bookUrl)
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
}, },
onNavigateToSearch = { query -> onNavigateToSearch(query) }, onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport, onNavigateToRemoteImport = onNavigateToRemoteImport,
@@ -386,7 +382,9 @@ fun MainScreen(
onNavigateToCache = onNavigateToCache onNavigateToCache = onNavigateToCache
) )
MainDestination.Explore -> ExploreScreen() MainDestination.Explore -> ExploreScreen(
onOpenExploreShow = onNavigateToExploreShow
)
MainDestination.Rss -> RssScreen( MainDestination.Rss -> RssScreen(
onOpenSort = { sourceUrl, sortUrl, key -> onOpenSort = { sourceUrl, sortUrl, key ->
onNavigateToRssSort(sourceUrl, sortUrl, key) onNavigateToRssSort(sourceUrl, sortUrl, key)
@@ -107,7 +107,7 @@ class BookshelfViewModel(
// 更新相关 // 更新相关
private var threadCount = AppConfig.threadCount private var threadCount = AppConfig.threadCount
private var poolSize = min(threadCount, AppConst.MAX_THREAD) private var poolSize = threadCount
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher() private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
private val waitUpTocBooks = LinkedList<String>() private val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>() private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
@@ -495,7 +495,7 @@ class BookshelfViewModel(
private fun upPool() { private fun upPool() {
threadCount = AppConfig.threadCount threadCount = AppConfig.threadCount
val newPoolSize = min(threadCount, AppConst.MAX_THREAD) val newPoolSize = threadCount
if (poolSize == newPoolSize) return if (poolSize == newPoolSize) return
poolSize = newPoolSize poolSize = newPoolSize
upTocPool.close() upTocPool.close()
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity
@@ -84,7 +83,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ExploreScreen( fun ExploreScreen(
viewModel: ExploreViewModel = koinViewModel() viewModel: ExploreViewModel = koinViewModel(),
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity val activity = context as? AppCompatActivity
@@ -238,11 +238,7 @@ fun ExploreScreen(
sourceUrl = item.bookSourceUrl, sourceUrl = item.bookSourceUrl,
activity = activity, activity = activity,
onOpenUrl = { url -> onOpenUrl = { url ->
context.startActivity<ExploreShowActivity> { onOpenExploreShow(kind.title, item.bookSourceUrl, url)
putExtra("exploreName", kind.title)
putExtra("sourceUrl", item.bookSourceUrl)
putExtra("exploreUrl", url)
}
}, },
onRefreshKinds = { viewModel.refreshExploreKinds(item) }, onRefreshKinds = { viewModel.refreshExploreKinds(item) },
modifier = Modifier.weight(span.toFloat()), modifier = Modifier.weight(span.toFloat()),
@@ -10,7 +10,6 @@ import io.legado.app.data.entities.RssReadRecord
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.help.JsExtensions import io.legado.app.help.JsExtensions
import io.legado.app.ui.association.AddToBookshelfDialog import io.legado.app.ui.association.AddToBookshelfDialog
import io.legado.app.ui.book.explore.ExploreShowActivity
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.main.MainActivity import io.legado.app.ui.main.MainActivity
@@ -184,11 +183,14 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
} ?: (source as? BookSource) ?: return@launch } ?: (source as? BookSource) ?: return@launch
val sourceUrl = toSource.bookSourceUrl val sourceUrl = toSource.bookSourceUrl
withContext(Main) { withContext(Main) {
activity.startActivity<ExploreShowActivity> { activity.startActivity(
putExtra("exploreName", title) MainActivity.createExploreShowIntent(
putExtra("sourceUrl", sourceUrl) context = activity,
putExtra("exploreUrl", url) exploreName = title,
} sourceUrl = sourceUrl,
exploreUrl = url
)
)
} }
} }
} }
@@ -38,13 +38,15 @@ fun AppScaffold(
floatingActionButtonPosition: FabPosition = FabPosition.End, floatingActionButtonPosition: FabPosition = FabPosition.End,
contentColor: Color = contentColorFor(MiuixTheme.colorScheme.surface), contentColor: Color = contentColorFor(MiuixTheme.colorScheme.surface),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
alwaysDrawBehindBars: Boolean = false,
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasImageBg = ThemeConfig.hasImageBg(isDark) val hasImageBg = ThemeConfig.hasImageBg(isDark)
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val composeEngine = LegadoTheme.composeEngine val composeEngine = LegadoTheme.composeEngine
val contentDrawsBehindBars = ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur val contentDrawsBehindBars =
alwaysDrawBehindBars || ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur
val containerColor = if (hasImageBg) { val containerColor = if (hasImageBg) {
Color.Transparent Color.Transparent
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<io.legado.app.ui.widget.TitleBar
android:id="@+id/title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentInsetStartWithNavigation="0dp"
app:contentLayout="@layout/view_search"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/bookshelf_management" />
<io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scrollbars="none"
app:layout_constraintBottom_toTopOf="@id/select_action_bar"
app:layout_constraintTop_toBottomOf="@id/title_bar" />
<io.legado.app.ui.widget.SelectActionBar
android:id="@+id/select_action_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<io.legado.app.ui.widget.TitleBar
android:id="@+id/tool_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarStyle"
app:attachToActivity="false"
app:contentLayout="@layout/view_search"
app:fitStatusBar="false"
tools:ignore="RtlHardcoded,RtlSymmetry" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none"
tools:ignore="SpeakableTextPresentCheck" />
<com.google.android.material.loadingindicator.LoadingIndicator
android:id="@+id/rotate_loading"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:layout_margin="6dp"
android:visibility="gone"
app:loading_width="2dp" />
<TextView
android:id="@+id/tv_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:textColor="@color/secondaryText"
android:visibility="gone" />
</FrameLayout>
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="12dp"
android:paddingRight="12dp"
app:flexWrap="wrap"
app:justifyContent="space_between">
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_footer_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:visibility="gone"
tools:ignore="RtlHardcoded" />
<Space
android:layout_width="0dp"
android:layout_height="0dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/cancel"
android:visibility="gone"
tools:ignore="RtlHardcoded" />
<io.legado.app.ui.widget.text.AccentTextView
android:id="@+id/tv_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp"
android:text="@string/ok"
android:visibility="gone"
tools:ignore="RtlHardcoded" />
</LinearLayout>
</com.google.android.flexbox.FlexboxLayout>
</LinearLayout>
@@ -1,94 +0,0 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="4dp"
android:paddingEnd="16dp"
android:paddingTop="2dp"
android:paddingBottom="8dp">
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingBottom="2dp"
android:singleLine="true"
android:text="@string/book_name"
style="@style/ChapterNameText"
app:layout_constraintStart_toEndOf="@id/checkbox"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/checkbox"
app:layout_constraintBottom_toBottomOf="@id/checkbox"/>
<TextView
android:id="@+id/tv_author"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:singleLine="true"
android:text="@string/author"
android:textSize="12sp"
tools:ignore="RtlHardcoded"
app:layout_constraintStart_toStartOf="@id/tv_name"
app:layout_constraintEnd_toStartOf="@id/tv_group"
app:layout_constraintTop_toBottomOf="@id/tv_name" />
<TextView
android:id="@+id/tv_origin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp"
tools:text="@string/book_source"
app:layout_constraintStart_toStartOf="@id/tv_name"
app:layout_constraintTop_toBottomOf="@id/tv_author" />
<TextView
android:id="@+id/tv_group_s"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/ungroup"
android:layout_marginStart="8dp"
android:singleLine="true"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@id/tv_origin"
app:layout_constraintEnd_toStartOf="@id/tv_group"
app:layout_constraintBaseline_toBaselineOf="@id/tv_origin" />
<com.google.android.material.button.MaterialButton
android:id="@+id/tv_group"
android:layout_width="wrap_content"
android:layout_height="40dp"
style="@style/Widget.Material3Expressive.Button.TonalButton"
android:textSize="12sp"
android:layout_marginEnd="4dp"
android:minWidth="0dp"
android:padding="0dp"
android:text="@string/group"
app:layout_constraintTop_toTopOf="@id/tv_group_s"
app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
app:layout_constraintEnd_toStartOf="@id/tv_delete" />
<com.google.android.material.button.MaterialButton
android:id="@+id/tv_delete"
android:layout_width="40dp"
android:layout_height="40dp"
style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
app:icon="@drawable/ic_clear_all"
app:iconSize="20dp"
app:iconGravity="textStart"
app:layout_constraintTop_toTopOf="@id/tv_group_s"
app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AlwaysShowAction">
<item
android:id="@+id/menu_book_group"
android:icon="@drawable/ic_groups"
android:title="@string/group"
app:showAsAction="always">
<menu>
<item
android:id="@+id/menu_group_manage"
android:title="@string/group_manage" />
</menu>
</item>
<item
android:id="@+id/menu_export_all_use_book_source"
android:title="@string/export_all_use_book_source" />
<item
android:id="@+id/menu_open_book_info_by_click_title"
android:checkable="true"
android:title="@string/open_book_info_by_click_title" />
</menu>
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_del_selection"
android:title="@string/delete" />
<item
android:id="@+id/menu_update_enable"
android:title="@string/allow_update" />
<item
android:id="@+id/menu_update_disable"
android:title="@string/disable_update" />
<item
android:id="@+id/menu_add_to_group"
android:title="@string/add_to_group" />
<item
android:id="@+id/menu_change_source"
android:title="@string/change_source_batch" />
<item
android:id="@+id/menu_clear_cache"
android:title="@string/clear_cache" />
<item
android:id="@+id/menu_check_selected_interval"
android:title="@string/check_selected_interval" />
</menu>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_change_source_delay"
android:title="@string/change_source_delay" />
</menu>
+7 -3
View File
@@ -243,10 +243,12 @@
<string name="download_cancel">取消下载</string> <string name="download_cancel">取消下载</string>
<string name="no_download">暂无任务</string> <string name="no_download">暂无任务</string>
<string name="download_count">%1$d/%2$d</string> <string name="download_count">%1$d/%2$d</string>
<string name="cache_download_failed">缓存失败:%1$s</string>
<string name="import_select_book">导入选择书籍</string> <string name="import_select_book">导入选择书籍</string>
<string name="threads_num_title">更新和搜索线程</string> <string name="threads_num_title">更新和搜索线程</string>
<string name="cache_book_threads_num_title">书籍缓存下载线程数</string> <string name="threads_num_summary">控制更新和搜索线程的并发数量。数值越高越不稳定</string>
<string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量</string> <string name="cache_book_threads_num_title">书籍缓存下载线程</string>
<string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量。数值越高越不稳定</string>
<string name="change_icon">切换图标</string> <string name="change_icon">切换图标</string>
<string name="remove_from_bookshelf">已在书架</string> <string name="remove_from_bookshelf">已在书架</string>
<string name="already_in_bookshelf">已在书架</string> <string name="already_in_bookshelf">已在书架</string>
@@ -910,6 +912,8 @@
<string name="style">样式</string> <string name="style">样式</string>
<string name="group_style">分组样式</string> <string name="group_style">分组样式</string>
<string name="export_file_name">导出文件名</string> <string name="export_file_name">导出文件名</string>
<string name="export_file_name_template_hint">{name} - {author}</string>
<string name="export_file_name_template_help">可用变量:{name} 书名、{author} 作者、{group} 分组、{source} 书源、{remark} 备注。\n\n示例:{name} - {author}\n输出:三体 - 刘慈欣\n\n变量前后可以添加固定文字。</string>
<string name="reset">重置</string> <string name="reset">重置</string>
<string name="null_url">URL 为空</string> <string name="null_url">URL 为空</string>
<string name="dict">字典</string> <string name="dict">字典</string>
+5 -1
View File
@@ -246,10 +246,12 @@
<string name="download_cancel">Download cancel</string> <string name="download_cancel">Download cancel</string>
<string name="no_download">No download</string> <string name="no_download">No download</string>
<string name="download_count">Downloaded %1$d/%2$d</string> <string name="download_count">Downloaded %1$d/%2$d</string>
<string name="cache_download_failed">Cache failed: %1$s</string>
<string name="import_select_book">Import selected book(s)</string> <string name="import_select_book">Import selected book(s)</string>
<string name="threads_num_title">Number of Concurrent Tasks</string> <string name="threads_num_title">Number of Concurrent Tasks</string>
<string name="threads_num_summary">Controls concurrent download tasks. Higher values increase OOM risk</string>
<string name="cache_book_threads_num_title">Cache Download Threads</string> <string name="cache_book_threads_num_title">Cache Download Threads</string>
<string name="cache_book_threads_num_summary">Controls concurrent chapter downloads for offline book cache</string> <string name="cache_book_threads_num_summary">Controls concurrent chapter downloads for offline book cache. Higher values increase OOM risk</string>
<string name="change_icon">Change Icon</string> <string name="change_icon">Change Icon</string>
<string name="remove_from_bookshelf">In library</string> <string name="remove_from_bookshelf">In library</string>
<string name="already_in_bookshelf">Already in Bookshelf</string> <string name="already_in_bookshelf">Already in Bookshelf</string>
@@ -919,6 +921,8 @@
<string name="style">Style</string> <string name="style">Style</string>
<string name="group_style">Group style</string> <string name="group_style">Group style</string>
<string name="export_file_name">Export file name</string> <string name="export_file_name">Export file name</string>
<string name="export_file_name_template_hint">{name} - {author}</string>
<string name="export_file_name_template_help">Available variables: {name} title, {author} author, {group} group, {source} source, {remark} remark.\n\nExample: {name} - {author}\nResult: The Three-Body Problem - Liu Cixin\n\nYou can add fixed text before or after variables.</string>
<string name="reset">Reset</string> <string name="reset">Reset</string>
<string name="null_url">Null URL</string> <string name="null_url">Null URL</string>
<string name="dict">dict</string> <string name="dict">dict</string>
@@ -2,6 +2,7 @@ package io.legado.app.model.cache
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -36,17 +37,6 @@ class CacheDownloadStateStoreTest {
assertEquals(1, store.state.totalSuccess) assertEquals(1, store.state.totalSuccess)
} }
@Test
fun duplicateSuccessDoesNotInflateCount() {
val store = CacheDownloadStateStore()
store.markSuccess("a", 1)
store.markSuccess("a", 1)
assertEquals(1, store.state.books.getValue("a").successCount)
assertEquals(1, store.state.totalSuccess)
}
@Test @Test
fun removeBookRecalculatesRunningState() { fun removeBookRecalculatesRunningState() {
val store = CacheDownloadStateStore() val store = CacheDownloadStateStore()
@@ -58,4 +48,19 @@ class CacheDownloadStateStoreTest {
assertEquals(0, store.state.totalWaiting) assertEquals(0, store.state.totalWaiting)
assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books) assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
} }
@Test
fun bookFailureIsVisibleUntilQueueStartsAgain() {
val store = CacheDownloadStateStore()
store.markBookFailed("a", "source unavailable")
assertEquals("source unavailable", store.state.books.getValue("a").failureMessage)
assertEquals(1, store.state.totalFailure)
store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
assertNull(store.state.books.getValue("a").failureMessage)
assertEquals(0, store.state.totalFailure)
}
} }