[优化] 一些代码优化

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:windowSoftInputMode="adjustResize"
android:launchMode="singleTop" />
<!-- 书籍管理 -->
<activity
android:name=".ui.book.manage.BookshelfManageActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 书源调试 -->
<activity
android:name=".ui.book.source.debug.BookSourceDebugActivity"
@@ -263,11 +258,6 @@
android:name=".ui.book.searchContent.SearchContentActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 发现界面 -->
<activity
android:name=".ui.book.explore.ExploreShowActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- 订阅源调试 -->
<activity
android:name=".ui.rss.source.debug.RssSourceDebugActivity"
@@ -32,7 +32,6 @@ data class ChangeSourceMigrationOptions(
data class ChangeBookSourceResult(
val oldBookUrl: String,
val book: Book,
val chapters: List<BookChapter>,
)
data class BatchChangeBookSourceResult(
@@ -102,7 +101,7 @@ class ChangeBookSourceUseCase(
bookChapterDao.insert(*chapters.toTypedArray())
ReadBook.onChapterListUpdated(newBook)
}
return ChangeBookSourceResult(oldBookUrl, newBook, chapters)
return ChangeBookSourceResult(oldBookUrl, newBook)
}
suspend fun batchChangeTo(
@@ -1,6 +1,5 @@
package io.legado.app.domain.usecase
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
@@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.coroutineContext
import kotlin.math.min
data class BookSearchRequest(
val keyword: String,
@@ -83,7 +81,7 @@ class SearchBooksUseCase(
}
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 processedSources = 0
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx
@@ -48,6 +49,7 @@ import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
import java.util.zip.ZipFile
@@ -62,6 +64,8 @@ object BookHelp {
private const val cacheImageFolderName = "images"
private const val cacheEpubFolderName = "epub"
private val downloadImages = ConcurrentHashMap<String, Mutex>()
private val imageDownloadSlots = Semaphore(2)
private val imageDecodeSlots = Semaphore(1)
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
@@ -299,9 +303,18 @@ object BookHelp {
if (isImageExist(book, src)) {
return
}
imageDownloadSlots.acquire()
try {
val analyzeUrl = AnalyzeUrl(
src, source = bookSource, coroutineContext = currentCoroutineContext()
)
if (ImageUtils.skipDecode(bookSource, isCover = false)) {
analyzeUrl.getInputStreamAwait().use {
writeImage(book, src, it)
}
} else {
imageDecodeSlots.acquire()
try {
val bytes = analyzeUrl.getByteArrayAwait()
//某些图片被加密,需要进一步解密
runScriptWithContext {
@@ -317,6 +330,13 @@ object BookHelp {
}
writeImage(book, src, it)
}
} finally {
imageDecodeSlots.release()
}
}
} finally {
imageDownloadSlots.release()
}
} catch (e: Exception) {
currentCoroutineContext().ensureActive()
val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
@@ -341,6 +361,35 @@ object BookHelp {
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
fun isImageExist(book: Book, src: String): Boolean {
return getImage(book, src).exists()
@@ -519,6 +568,16 @@ object BookHelp {
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.withLock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.CoroutineContext
object CacheBook {
const val maxDownloadConcurrency = 8
private data class QueueStats(
val waitingCount: Int,
val downloadingCount: Int
)
private data class ChapterKey(
val bookUrl: String,
val index: Int,
)
private class CacheBookCoordinator {
val taskMap = ConcurrentHashMap<String, CacheBookModel>()
private val processMutex = Mutex()
@@ -80,7 +88,7 @@ object CacheBook {
}
}.onStart {
updateSummary()
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)) {
coroutineScope {
it.download(this, context)
}
@@ -92,6 +100,8 @@ object CacheBook {
private val coordinator = CacheBookCoordinator()
private val stateStore = CacheDownloadStateStore()
private val pendingRequests = ConcurrentHashMap<Long, CacheDownloadRequest>()
private val pendingRequestId = AtomicLong(0)
val downloadStateFlow = stateStore.stateFlow
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
@@ -113,9 +123,9 @@ object CacheBook {
@Volatile
private var lastQueueStats = QueueStats(0, 0)
val successDownloadSet = ConcurrentHashMap.newKeySet<String>()
val errorDownloadMap = ConcurrentHashMap<String, Int>()
private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>()
@Volatile
private var successDownloadCount = 0
private val errorRetryMap = ConcurrentHashMap<ChapterKey, Int>()
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = coordinator.taskMap
@@ -124,6 +134,12 @@ object CacheBook {
return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
}
fun markBookFailed(bookUrl: String, message: String) {
stateStore.markBookFailed(bookUrl, message)
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
}
private fun collectQueueStats(): QueueStats {
var waiting = 0
var downloading = 0
@@ -139,7 +155,7 @@ object CacheBook {
val stats = collectQueueStats()
lastQueueStats = stats
_downloadSummaryFlow.value =
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${stateStore.state.totalFailure}|成功:$successDownloadCount"
}
@Synchronized
@@ -204,8 +220,11 @@ object CacheBook {
}
is ChapterSelection.Single -> Unit
}
val requestId = pendingRequestId.incrementAndGet()
pendingRequests[requestId] = request
context.startService<CacheBookService> {
action = IntentAction.start
putExtra("requestId", requestId)
putExtra("bookUrl", request.bookUrl)
putExtra("source", request.source.name)
when (val selection = request.selection) {
@@ -213,9 +232,7 @@ object CacheBook {
putExtra("start", selection.start)
putExtra("end", selection.end)
}
is ChapterSelection.Indices -> {
putIntegerArrayListExtra("indices", ArrayList(selection.values))
}
is ChapterSelection.Indices -> Unit
is ChapterSelection.Single -> {
putExtra("start", 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) {
context.startService<CacheBookService> {
action = IntentAction.remove
@@ -232,12 +253,12 @@ object CacheBook {
}
fun removeBook(bookUrl: String): Boolean {
val model = cacheBookMap.remove(bookUrl) ?: return false
model.stop()
val model = cacheBookMap.remove(bookUrl)
model?.stop()
stateStore.removeBook(bookUrl)
updateSummary()
_queueChangedFlow.tryEmit(bookUrl)
return true
return model != null
}
fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean {
@@ -255,9 +276,9 @@ object CacheBook {
fun close() {
cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear()
successDownloadSet.clear()
errorDownloadMap.clear()
errorIndexMap.clear()
successDownloadCount = 0
errorRetryMap.clear()
pendingRequests.clear()
stateStore.clear()
updateSummary()
}
@@ -273,16 +294,16 @@ object CacheBook {
val totalCount: Int
get() {
val stats = collectQueueStats()
return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
return stats.waitingCount + stats.downloadingCount + successDownloadCount + stateStore.state.totalFailure
}
val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size
get() = successDownloadCount + stateStore.state.totalFailure
val downloadSummary: String
get() {
val stats = collectQueueStats()
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${errorDownloadMap.size} | 成功:${successDownloadSet.size}"
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${stateStore.state.totalFailure} | 成功:$successDownloadCount"
}
val isRun: Boolean
@@ -351,9 +372,6 @@ object CacheBook {
@Synchronized
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
@Synchronized
fun waitingIndices(): Set<Int> = queue.waitingIndices()
@Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@@ -438,9 +456,9 @@ object CacheBook {
private fun onSuccess(chapter: BookChapter) {
onDownloadSet.remove(chapter.index)
chapterTasks.remove(chapter.index)
successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr())
errorIndexMap[book.bookUrl]?.remove(chapter.index)
val chapterKey = ChapterKey(book.bookUrl, chapter.index)
successDownloadCount++
errorRetryMap.remove(chapterKey)
stateStore.markSuccess(book.bookUrl, chapter.index)
notifyDownloadSetChanged()
notifyErrorChanged()
@@ -451,9 +469,7 @@ object CacheBook {
private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true
if (error !is ConcurrentException) {
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index)
errorRetryMap.merge(ChapterKey(book.bookUrl, chapter.index), 1) { old, inc -> old + inc }
stateStore.markFailed(book.bookUrl, chapter.index)
}
onDownloadSet.remove(chapter.index)
@@ -462,7 +478,7 @@ object CacheBook {
@Synchronized
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) {
queue.enqueue(ChapterSelection.Single(chapter.index))
} else {
@@ -41,6 +41,6 @@ data class CacheBookDownloadState(
val waitingCount: Int = 0,
val runningIndices: Set<Int> = emptySet(),
val failedIndices: Set<Int> = emptySet(),
val successIndices: Set<Int> = emptySet(),
val successCount: Int = 0,
val failureMessage: String? = null,
)
@@ -2,6 +2,91 @@ package io.legado.app.model.cache
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(
val start: Int,
val end: Int,
@@ -9,13 +94,13 @@ class CacheDownloadQueue {
) {
fun contains(index: Int): Boolean = index in next..end
fun remainingCount(
emittedIndices: Set<Int>,
removedIndices: Set<Int>,
emittedIndices: IntRangeSet,
removedIndices: IntRangeSet,
): Int {
if (next > end) return 0
val rawCount = end - next + 1
val emittedCount = emittedIndices.count { it in next..end }
val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
val emittedCount = emittedIndices.countInRange(next, end)
val removedCount = removedIndices.countInRange(next, end, excluding = emittedIndices)
val excludedCount = emittedCount + removedCount
return rawCount - excludedCount
}
@@ -23,8 +108,8 @@ class CacheDownloadQueue {
private val ranges = ArrayDeque<RangeCursor>()
private val indices = linkedSetOf<Int>()
private val emittedIndices = hashSetOf<Int>()
private val removedIndices = hashSetOf<Int>()
private val emittedIndices = IntRangeSet()
private val removedIndices = IntRangeSet()
fun enqueue(request: CacheDownloadRequest) {
enqueue(request.selection)
@@ -42,7 +127,7 @@ class CacheDownloadQueue {
while (indices.isNotEmpty()) {
val index = indices.first()
indices.remove(index)
if (index in runningIndices || index in removedIndices) continue
if (index in runningIndices || removedIndices.contains(index)) continue
emittedIndices.add(index)
return CacheDownloadCandidate(bookUrl, index)
}
@@ -51,10 +136,15 @@ class CacheDownloadQueue {
val cursor = ranges.first()
while (cursor.next <= cursor.end) {
val index = cursor.next++
if (index in removedIndices || index in runningIndices) continue
if (emittedIndices.add(index)) {
return CacheDownloadCandidate(bookUrl, index)
if (
removedIndices.contains(index) ||
emittedIndices.contains(index) ||
index in runningIndices
) {
continue
}
emittedIndices.add(index)
return CacheDownloadCandidate(bookUrl, index)
}
ranges.removeFirst()
}
@@ -79,30 +169,22 @@ class CacheDownloadQueue {
}
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) }
return indexCount + rangeCount
}
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) }
}
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) {
if (end < start) return
removedIndices.removeAll { it in start..end }
emittedIndices.removeRange(start, end)
removedIndices.removeRange(start, end)
ranges.add(RangeCursor(start, end))
}
@@ -21,18 +21,22 @@ class CacheDownloadStateStore {
current.copy(
waitingCount = waitingCount,
runningIndices = runningIndices,
failureMessage = if (waitingCount > 0 || runningIndices.isNotEmpty()) {
null
} else {
current.failureMessage
},
)
}
}
fun markSuccess(bookUrl: String, chapterIndex: Int) {
updateBook(bookUrl) { current ->
val successIndices = current.successIndices + chapterIndex
current.copy(
runningIndices = current.runningIndices - chapterIndex,
failedIndices = current.failedIndices - chapterIndex,
successIndices = successIndices,
successCount = successIndices.size,
successCount = current.successCount + 1,
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) {
updateBook(bookUrl) { current ->
current.copy(failedIndices = current.failedIndices - chapterIndex)
@@ -79,7 +93,8 @@ class CacheDownloadStateStore {
private fun CacheDownloadState.recalculate(): CacheDownloadState {
val totalWaiting = books.values.sumOf { it.waitingCount }
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 }
return copy(
isRunning = totalWaiting > 0 || totalRunning > 0,
@@ -35,6 +35,8 @@ import kotlin.coroutines.coroutineContext
*/
object BookContent {
private const val maxNextPageConcurrency = 4
@Throws(Exception::class)
suspend fun analyzeContent(
bookSource: BookSource,
@@ -50,14 +52,24 @@ object BookContent {
appCtx.getString(R.string.error_get_web_content, baseUrl)
)
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
if (!needSave) {
Debug.log(bookSource.bookSourceUrl, body, state = 40)
}
val mNextChapterUrl = if (nextChapterUrl.isNullOrEmpty()) {
appDb.bookChapterDao.getChapter(book.bookUrl, bookChapter.index + 1)?.url
?: appDb.bookChapterDao.getChapter(book.bookUrl, 0)?.url
} else {
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 contentRule = bookSource.getContentRule()
val analyzeRule = AnalyzeRule(book, bookSource)
@@ -70,7 +82,7 @@ object BookContent {
var contentData = analyzeContent(
book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl
)
contentList.add(contentData.first)
appendContent(contentData.first)
if (contentData.second.size == 1) {
var nextUrl = contentData.second[0]
while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
@@ -95,8 +107,8 @@ object BookContent {
)
nextUrl =
if (contentData.second.isNotEmpty()) contentData.second[0] else ""
contentList.add(contentData.first)
Debug.log(bookSource.bookSourceUrl, "${contentList.size}页完成")
appendContent(contentData.first)
Debug.log(bookSource.bookSourceUrl, "${pageCount}页完成")
}
}
Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}")
@@ -106,7 +118,7 @@ object BookContent {
for (urlStr in contentData.second) {
emit(urlStr)
}
}.mapAsync(OtherConfig.threadCount) { urlStr ->
}.mapAsync(OtherConfig.threadCount.coerceIn(1, maxNextPageConcurrency)) { urlStr ->
val analyzeUrl = AnalyzeUrl(
mUrl = urlStr,
source = bookSource,
@@ -122,10 +134,10 @@ object BookContent {
).first
}.collect {
coroutineContext.ensureActive()
contentList.add(it)
appendContent(it)
}
}
var contentStr = contentList.joinToString("\n")
var contentStr = contentBuilder.toString()
val titleRule = contentRule.title //先正文再章节名称
if (!titleRule.isNullOrBlank()) {
var title = analyzeRule.runCatching {
@@ -160,7 +172,11 @@ object BookContent {
Debug.log(bookSource.bookSourceUrl, "┌获取章节名称")
Debug.log(bookSource.bookSourceUrl, "${bookChapter.title}")
Debug.log(bookSource.bookSourceUrl, "┌获取正文内容")
if (needSave) {
Debug.log(bookSource.bookSourceUrl, "└正文长度:${contentStr.length}")
} else {
Debug.log(bookSource.bookSourceUrl, "\n$contentStr")
}
if (!bookChapter.isVolume && contentStr.isBlank()) {
throw ContentEmptyException("内容为空")
}
@@ -42,7 +42,7 @@ class CacheBookService : BaseService() {
private set
}
private val threadCount = OtherConfig.cacheBookThreadCount
private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency)
private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null
@@ -84,11 +84,16 @@ class CacheBookService : BaseService() {
intent?.action?.let { action ->
when (action) {
IntentAction.start -> {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
val indices = intent.getIntegerArrayListExtra("indices")
if (indices != null && indices.isNotEmpty()) {
addDownloadData(bookUrl, indices)
val requestId = intent.getLongExtra("requestId", -1L)
val request = if (requestId >= 0) {
CacheBook.takePendingRequest(requestId)
} else {
null
}
if (request != null) {
addDownloadRequest(request)
} else {
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
addDownloadData(
bookUrl,
intent.getIntExtra("start", 0),
@@ -113,19 +118,6 @@ class CacheBookService : BaseService() {
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) {
bookUrl ?: return
if (end < start) return
@@ -140,7 +132,10 @@ class CacheBookService : BaseService() {
private fun addDownloadRequest(request: CacheDownloadRequest) {
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 chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
@@ -154,6 +149,10 @@ class CacheBookService : BaseService() {
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure {
removeDownload(request.bookUrl)
CacheBook.markBookFailed(
request.bookUrl,
getString(R.string.error_get_book_info)
)
AppLog.put(
"$name》目录为空且加载详情页失败\n${it.localizedMessage}",
it,
@@ -169,6 +168,10 @@ class CacheBookService : BaseService() {
book.update()
}
removeDownload(request.bookUrl)
CacheBook.markBookFailed(
request.bookUrl,
getString(R.string.error_get_chapter_list)
)
AppLog.put(
"$name》目录为空且加载目录失败\n${it.localizedMessage}",
it,
@@ -45,7 +45,6 @@ import splitties.init.appCtx
import splitties.systemservices.notificationManager
import java.util.concurrent.Executors
import kotlin.coroutines.coroutineContext
import kotlin.math.min
/**
* 校验书源
@@ -53,7 +52,7 @@ import kotlin.math.min
class CheckSourceService : BaseService() {
private var threadCount = OtherConfig.threadCount
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 checkJob: Job? = null
private var originSize = 0
@@ -20,12 +20,11 @@ import io.legado.app.databinding.DialogAddToBookshelfBinding
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.analyzeRule.AnalyzeUrl
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.NetworkUtils
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.setLayout
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -82,11 +81,14 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
}
viewModel.load(bookUrl) {
viewModel.saveSearchBook(it) {
startActivity<BookInfoActivity> {
putExtra("name", it.name)
putExtra("author", it.author)
putExtra("bookUrl", it.bookUrl)
}
startActivity(
MainActivity.createBookInfoIntent(
context = requireContext(),
name = it.name,
author = it.author,
bookUrl = it.bookUrl
)
)
dismiss()
}
}
@@ -47,6 +47,7 @@ data class BookCacheBookItem(
val author: String,
val totalCount: Int,
val cachedCount: Int,
val cachedFileCount: Int,
val waitingCount: Int,
val downloadingCount: Int,
val errorCount: Int,
@@ -188,9 +189,7 @@ class BookCacheManageViewModel(
sourceBooks
.filterNot { it.isLocal || it.isAudio }
.mapNotNull { book -> buildBookItem(book) }
.filter { item ->
item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
}
.filter(::shouldShowItem)
)
val booksByUrl = items.associateBy { it.bookUrl }
val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) {
@@ -265,9 +264,7 @@ class BookCacheManageViewModel(
val item = book
?.takeUnless { it.isLocal || it.isAudio }
?.let { buildBookItem(it) }
?.takeIf {
it.cachedCount > 0 || it.isDownloading || it.errorCount > 0
}
?.takeIf(::shouldShowItem)
val chapters = if (expanded && item != null) {
buildChapterItems(item.bookUrl)
} else {
@@ -320,6 +317,7 @@ class BookCacheManageViewModel(
author = book.getRealAuthor(),
totalCount = totalCount,
cachedCount = cachedCount,
cachedFileCount = cachedFileCount,
waitingCount = waitingCount,
downloadingCount = downloadingCount,
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> {
val book = bookDao.getBook(bookUrl) ?: return emptyList()
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
@@ -472,7 +474,7 @@ class BookCacheManageViewModel(
}
private fun deleteBookCache(bookUrl: String) {
CacheBook.remove(context, bookUrl)
CacheBook.removeBook(bookUrl)
execute {
clearBookCacheUseCase.execute(bookUrl)
}.onSuccess {
@@ -5,7 +5,6 @@ import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
@@ -33,7 +32,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = OtherConfig.threadCount
@@ -105,7 +103,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
.newFixedThreadPool(threadCount).asCoroutineDispatcher()
}
private fun startSearch() {
@@ -6,7 +6,6 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
@@ -56,7 +55,6 @@ import kotlinx.coroutines.withTimeout
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
@@ -181,7 +179,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
private fun initSearchPool() {
searchPool = Executors
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
.newFixedThreadPool(threadCount).asCoroutineDispatcher()
}
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
import android.annotation.SuppressLint
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@@ -95,14 +94,15 @@ import org.koin.compose.koinInject
@Composable
fun ExploreShowScreen(
title: String,
intent: Intent,
sourceUrl: String?,
exploreUrl: String?,
onBack: () -> Unit,
onBookClick: (SearchBook) -> Unit,
viewModel: ExploreShowViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
viewModel.initData(intent)
LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
viewModel.initData(sourceUrl, exploreUrl)
}
val books by viewModel.uiBooks.collectAsState()
@@ -124,7 +124,6 @@ fun ExploreShowScreen(
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val context = LocalContext.current
val activity = context as? AppCompatActivity
val sourceUrl = remember(intent) { intent.getStringExtra("sourceUrl") }
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
LaunchedEffect(sourceUrl) {
@@ -1,6 +1,5 @@
package io.legado.app.ui.book.explore
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.BookSource
@@ -124,10 +123,18 @@ class ExploreShowViewModel(
}
}
fun initData(intent: Intent) {
val incomingSourceUrl = intent.getStringExtra("sourceUrl")
fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
return
}
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 {
if (bookSource == null && incomingSourceUrl != null) {
@@ -1,193 +1,37 @@
package io.legado.app.ui.book.info
import io.legado.app.ui.config.otherConfig.OtherConfig
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
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.help.config.AppConfig
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.main.MainActivity
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
class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
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?) {
super.onCreate(savedInstanceState)
viewModel.initData(intent)
}
@Composable
override fun Content() {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
LaunchedEffect(Unit) {
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,
BookInfoRouteScreen(
bookUrl = intent.getStringExtra("bookUrl").orEmpty(),
viewModel = viewModel,
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?) {
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)) },
)
},
alwaysDrawBehindBars = true,
) { paddingValues ->
val book = state.book
if (book == null) {
@@ -826,7 +827,7 @@ private fun BookInfoSummary(
modifier = Modifier
.fillMaxWidth()
.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)
) {
AppText(
@@ -101,9 +101,23 @@ class BookInfoViewModel(
private var readRecordObserveJob: Job? = null
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 {
val bookUrl = intent.getStringExtra("bookUrl") ?: ""
appDb.bookDao.getBook(bookUrl)?.let {
inBookshelf = !it.isNotShelf
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.ui.about.AppLogSheet
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.GroupSelectSheet
import io.legado.app.ui.theme.LegadoTheme
@@ -141,19 +140,25 @@ private data class BookshelfManageListState(
fun BookshelfManageRouteScreen(
groupId: Long,
onBackClick: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
viewModel: BookshelfManageScreenViewModel = koinViewModel()
) {
LaunchedEffect(groupId) {
viewModel.dispatch(BookshelfManageScreenIntent.Initialize(groupId))
}
BookshelfManageScreen(viewModel = viewModel, onBackClick = onBackClick)
BookshelfManageScreen(
viewModel = viewModel,
onBackClick = onBackClick,
onOpenBookInfo = onOpenBookInfo
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BookshelfManageScreen(
viewModel: BookshelfManageScreenViewModel,
onBackClick: () -> Unit
onBackClick: () -> Unit,
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -205,18 +210,8 @@ private fun BookshelfManageScreen(
val resultAnalyzedText = stringResource(R.string.result_analyzed)
val errorScopeInputText = stringResource(R.string.error_scope_input)
val noGroupText = stringResource(R.string.no_group)
val exportFileNameHintText = "书名:《{name}》 作者:{author}"
val exportFileNameHelpText = """
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
val exportFileNameHintText = stringResource(R.string.export_file_name_template_hint)
val exportFileNameHelpText = stringResource(R.string.export_file_name_template_help)
val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
@@ -331,10 +326,8 @@ private fun BookshelfManageScreen(
when (effect) {
is BookshelfManageScreenEffect.ShowMessage -> context.toastOnUi(effect.message)
is BookshelfManageScreenEffect.NotifyBookChanged -> Unit
is BookshelfManageScreenEffect.OpenBookInfo -> context.startActivity<BookInfoActivity> {
putExtra("name", effect.name)
putExtra("author", effect.author)
putExtra("bookUrl", effect.bookUrl)
is BookshelfManageScreenEffect.OpenBookInfo -> {
onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
}
}
}
@@ -686,8 +679,17 @@ private fun BookshelfManageScreen(
val cacheCount = remember(renderVersion, book.bookUrl) {
viewModel.getCacheCount(book.bookUrl) ?: 0
}
val isDownloading = remember(renderVersion, book.bookUrl) {
viewModel.isBookDownloading(book.bookUrl)
val downloadState = state.downloadStates[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 exportMsg = remember(renderVersion, book.bookUrl) {
@@ -761,8 +763,20 @@ private fun BookshelfManageScreen(
}
Row(
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)) {
SmallTonalIconButton(
onClick = {
@@ -912,7 +926,7 @@ private fun BookshelfManageScreen(
oldBookUrl = book.bookUrl,
source = source,
book = newBook,
chapters = toc,
chapterCount = toc.size,
)
)
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.removeType
import io.legado.app.model.CacheBook
import io.legado.app.model.cache.CacheBookDownloadState
import io.legado.app.help.config.LocalConfig
import io.legado.app.service.ExportBookService
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
@@ -72,6 +73,9 @@ data class BookshelfManageScreenUiState(
val bookSort: Int = BookshelfConfig.bookshelfSort,
val bookSortOrder: Int = BookshelfConfig.bookshelfSortOrder,
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 changeSourceProgress: String? = null,
val changeSourceMessage: String? = null,
@@ -118,7 +122,7 @@ sealed interface BookshelfManageScreenIntent {
val oldBookUrl: String,
val source: BookSource,
val book: Book,
val chapters: List<BookChapter>,
val chapterCount: Int,
) : BookshelfManageScreenIntent
data class AddPreviewItemToShelf(val oldBookUrl: String) : BookshelfManageScreenIntent
data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : BookshelfManageScreenIntent
@@ -220,7 +224,7 @@ class BookshelfManageScreenViewModel(
intent.oldBookUrl,
intent.source,
intent.book,
intent.chapters
intent.chapterCount
)
is BookshelfManageScreenIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl)
@@ -380,8 +384,33 @@ class BookshelfManageScreenViewModel(
}
}
viewModelScope.launch {
CacheBook.downloadStateFlow.collect { state ->
state.books.keys.forEach { bookUrl ->
CacheBook.downloadStateFlow.collect { downloadState ->
_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()
@@ -509,9 +538,13 @@ class BookshelfManageScreenViewModel(
}
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
_uiState.update {
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
}
execute {
batchCacheDownloadUseCase.execute(
bookUrls = books.map { it.bookUrl }.toSet(),
bookUrls = bookUrls,
downloadAllChapters = downloadAllChapters,
skipAudioBooks = true
)
@@ -522,12 +555,41 @@ class BookshelfManageScreenViewModel(
private fun toggleBookDownload(book: Book) {
if (book.isLocal) return
if (isBookDownloading(book.bookUrl)) {
if (isBookDownloading(book.bookUrl) || uiState.value.pendingDownloadBookUrls.contains(book.bookUrl)) {
CacheBook.remove(context, book.bookUrl)
_uiState.update {
it.copy(pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl)
}
syncDownloadRunning()
} else {
_uiState.update {
it.copy(
pendingDownloadBookUrls = it.pendingDownloadBookUrls + book.bookUrl,
downloadFailureMessages = it.downloadFailureMessages - book.bookUrl
)
}
execute {
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 {
syncDownloadRunning()
}
@@ -599,6 +661,9 @@ class BookshelfManageScreenViewModel(
private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
if (bookUrls.isEmpty()) return
_uiState.update {
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
}
execute {
batchCacheDownloadUseCase.execute(
bookUrls = bookUrls,
@@ -652,7 +717,7 @@ class BookshelfManageScreenViewModel(
return
}
execute {
val concurrency = OtherConfig.threadCount.coerceIn(1, 4)
val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
_uiState.update {
it.copy(
isChangingSource = true,
@@ -775,19 +840,19 @@ class BookshelfManageScreenViewModel(
oldBookUrl: String,
source: BookSource,
book: Book,
chapters: List<BookChapter>,
chapterCount: Int,
) {
_uiState.update { state ->
state.copy(
batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
if (item.oldBook.bookUrl == oldBookUrl) {
book.totalChapterNum = chapters.size
book.totalChapterNum = chapterCount
item.copy(
candidates = listOf(
BatchChangeSourceCandidate(
source = source,
book = book,
chapterCount = chapters.size
chapterCount = chapterCount
)
) +
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 androidx.compose.runtime.Composable
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.main.MainActivity
import io.legado.app.utils.startActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -41,11 +41,14 @@ class SearchActivity : BaseComposeActivity() {
viewModel = viewModel,
onBack = { finish() },
onOpenBookInfo = { name, author, bookUrl ->
startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
startActivity(
MainActivity.createBookInfoIntent(
context = this,
name = name,
author = author,
bookUrl = bookUrl
)
)
},
onOpenSourceManage = {
startActivity<BookSourceActivity>()
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.model.CacheBook
import io.legado.app.service.WebService
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
@@ -366,6 +367,7 @@ fun OtherConfigScreen(
SliderSettingItem(
title = stringResource(R.string.threads_num_title),
description = stringResource(R.string.threads_num_summary),
value = OtherConfig.threadCount.toFloat(),
defaultValue = 8f,
valueRange = 1f..256f,
@@ -375,10 +377,15 @@ fun OtherConfigScreen(
SliderSettingItem(
title = stringResource(R.string.cache_book_threads_num_title),
description = stringResource(R.string.cache_book_threads_num_summary),
value = OtherConfig.cacheBookThreadCount.toFloat(),
defaultValue = 16f,
valueRange = 1f..256f,
onValueChange = { OtherConfig.cacheBookThreadCount = it.toInt() }
value = OtherConfig.cacheBookThreadCount
.coerceIn(1, CacheBook.maxDownloadConcurrency)
.toFloat(),
defaultValue = CacheBook.maxDownloadConcurrency.toFloat(),
valueRange = 1f..CacheBook.maxDownloadConcurrency.toFloat(),
onValueChange = {
OtherConfig.cacheBookThreadCount =
it.toInt().coerceIn(1, CacheBook.maxDownloadConcurrency)
}
)
SwitchSettingItem(
@@ -41,7 +41,9 @@ import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog
import io.legado.app.ui.about.UpdateDialog
import io.legado.app.ui.book.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.remote.RemoteBookScreen
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.welcome.WelcomeActivity
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.showDialogFragment
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 {
const val EXTRA_START_ROUTE = "startRoute"
@@ -96,11 +99,19 @@ open class MainActivity : BaseComposeActivity() {
private const val ROUTE_CACHE = "cache"
private const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage"
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_READ = "rss/read"
private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id"
private const val EXTRA_SEARCH_KEY = "extra_search_key"
private const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
private const val EXTRA_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_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 {
return when (configTag) {
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
@@ -210,6 +249,7 @@ open class MainActivity : BaseComposeActivity() {
private val viewModel by viewModel<MainViewModel>()
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
@Serializable
private sealed interface MainRoute : NavKey
@@ -253,6 +293,20 @@ open class MainActivity : BaseComposeActivity() {
val scopeRaw: String? = null
) : 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?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -414,6 +468,26 @@ open class MainActivity : BaseComposeActivity() {
onNavigateToBookCacheManage = {
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 ->
navigateToRoute(
backStack,
@@ -484,7 +558,17 @@ open class MainActivity : BaseComposeActivity() {
entry<MainRouteCache> { route ->
BookshelfManageRouteScreen(
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,
onBack = { navigateBack(backStack) },
onOpenBookInfo = { name, author, bookUrl ->
this@MainActivity.startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
navigateToRoute(
backStack,
MainRouteBookInfo(
name = name,
author = author,
bookUrl = bookUrl
)
)
},
onOpenSourceManage = {
this@MainActivity.startActivity<BookSourceActivity>()
@@ -573,6 +660,41 @@ open class MainActivity : BaseComposeActivity() {
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 -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
@@ -827,10 +973,32 @@ open class MainActivity : BaseComposeActivity() {
key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
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
}
}
override fun setVariable(key: String, variable: String?) {
bookInfoVariableSetter?.invoke(key, variable)
}
}
class LauncherW : MainActivity()
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen
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.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.startActivity
import io.legado.app.utils.startActivityForBook
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -95,6 +93,8 @@ fun MainScreen(
onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> 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,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) {
@@ -374,11 +374,7 @@ fun MainScreen(
context.startActivityForBook(book)
},
onBookLongClick = { book ->
context.startActivity<BookInfoActivity> {
putExtra("name", book.name)
putExtra("author", book.author)
putExtra("bookUrl", book.bookUrl)
}
onNavigateToBookInfo(book.name, book.author, book.bookUrl)
},
onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport,
@@ -386,7 +382,9 @@ fun MainScreen(
onNavigateToCache = onNavigateToCache
)
MainDestination.Explore -> ExploreScreen()
MainDestination.Explore -> ExploreScreen(
onOpenExploreShow = onNavigateToExploreShow
)
MainDestination.Rss -> RssScreen(
onOpenSort = { sourceUrl, sortUrl, key ->
onNavigateToRssSort(sourceUrl, sortUrl, key)
@@ -107,7 +107,7 @@ class BookshelfViewModel(
// 更新相关
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 val waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
@@ -495,7 +495,7 @@ class BookshelfViewModel(
private fun upPool() {
threadCount = AppConfig.threadCount
val newPoolSize = min(threadCount, AppConst.MAX_THREAD)
val newPoolSize = threadCount
if (poolSize == newPoolSize) return
poolSize = newPoolSize
upTocPool.close()
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
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.SearchScope
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)
@Composable
fun ExploreScreen(
viewModel: ExploreViewModel = koinViewModel()
viewModel: ExploreViewModel = koinViewModel(),
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit
) {
val context = LocalContext.current
val activity = context as? AppCompatActivity
@@ -238,11 +238,7 @@ fun ExploreScreen(
sourceUrl = item.bookSourceUrl,
activity = activity,
onOpenUrl = { url ->
context.startActivity<ExploreShowActivity> {
putExtra("exploreName", kind.title)
putExtra("sourceUrl", item.bookSourceUrl)
putExtra("exploreUrl", url)
}
onOpenExploreShow(kind.title, item.bookSourceUrl, url)
},
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
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.help.JsExtensions
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.login.SourceLoginActivity
import io.legado.app.ui.main.MainActivity
@@ -184,11 +183,14 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
} ?: (source as? BookSource) ?: return@launch
val sourceUrl = toSource.bookSourceUrl
withContext(Main) {
activity.startActivity<ExploreShowActivity> {
putExtra("exploreName", title)
putExtra("sourceUrl", sourceUrl)
putExtra("exploreUrl", url)
}
activity.startActivity(
MainActivity.createExploreShowIntent(
context = activity,
exploreName = title,
sourceUrl = sourceUrl,
exploreUrl = url
)
)
}
}
}
@@ -38,13 +38,15 @@ fun AppScaffold(
floatingActionButtonPosition: FabPosition = FabPosition.End,
contentColor: Color = contentColorFor(MiuixTheme.colorScheme.surface),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
alwaysDrawBehindBars: Boolean = false,
content: @Composable (PaddingValues) -> Unit
) {
val isDark = isSystemInDarkTheme()
val hasImageBg = ThemeConfig.hasImageBg(isDark)
val hazeState = remember { HazeState() }
val composeEngine = LegadoTheme.composeEngine
val contentDrawsBehindBars = ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur
val contentDrawsBehindBars =
alwaysDrawBehindBars || ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur
val containerColor = if (hasImageBg) {
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="no_download">暂无任务</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="threads_num_title">更新和搜索线程</string>
<string name="cache_book_threads_num_title">书籍缓存下载线程数</string>
<string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量</string>
<string name="threads_num_title">更新和搜索线程</string>
<string name="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="remove_from_bookshelf">已在书架</string>
<string name="already_in_bookshelf">已在书架</string>
@@ -910,6 +912,8 @@
<string name="style">样式</string>
<string name="group_style">分组样式</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="null_url">URL 为空</string>
<string name="dict">字典</string>
+5 -1
View File
@@ -246,10 +246,12 @@
<string name="download_cancel">Download cancel</string>
<string name="no_download">No download</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="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_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="remove_from_bookshelf">In library</string>
<string name="already_in_bookshelf">Already in Bookshelf</string>
@@ -919,6 +921,8 @@
<string name="style">Style</string>
<string name="group_style">Group style</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="null_url">Null URL</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.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -36,17 +37,6 @@ class CacheDownloadStateStoreTest {
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
fun removeBookRecalculatesRunningState() {
val store = CacheDownloadStateStore()
@@ -58,4 +48,19 @@ class CacheDownloadStateStoreTest {
assertEquals(0, store.state.totalWaiting)
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)
}
}