diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c8f22fbaf --- /dev/null +++ b/CLAUDE.md @@ -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 { 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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50232b2be..97d469162 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -243,11 +243,6 @@ android:enableOnBackInvokedCallback="true" android:windowSoftInputMode="adjustResize" android:launchMode="singleTop" /> - - - - , ) 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( diff --git a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt index ddf2c0bd3..b90331383 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/help/book/BookHelp.kt b/app/src/main/java/io/legado/app/help/book/BookHelp.kt index bba633036..18562a43d 100644 --- a/app/src/main/java/io/legado/app/help/book/BookHelp.kt +++ b/app/src/main/java/io/legado/app/help/book/BookHelp.kt @@ -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() + private val imageDownloadSlots = Semaphore(2) + private val imageDecodeSlots = Semaphore(1) val cachePath = FileUtils.getPath(downloadDir, cacheFolderName) @@ -299,23 +303,39 @@ object BookHelp { if (isImageExist(book, src)) { return } - val analyzeUrl = AnalyzeUrl( - src, source = bookSource, coroutineContext = currentCoroutineContext() - ) - val bytes = analyzeUrl.getByteArrayAwait() - //某些图片被加密,需要进一步解密 - runScriptWithContext { - ImageUtils.decode( - src, bytes, isCover = false, bookSource, book + imageDownloadSlots.acquire() + try { + val analyzeUrl = AnalyzeUrl( + src, source = bookSource, coroutineContext = currentCoroutineContext() ) - }?.let { - if (!checkImage(it)) { - // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据 - // 所以无论如何都要将数据写入到文件里 - // throw NoStackTraceException("数据异常") - AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常") + if (ImageUtils.skipDecode(bookSource, isCover = false)) { + analyzeUrl.getInputStreamAwait().use { + writeImage(book, src, it) + } + } else { + imageDecodeSlots.acquire() + try { + val bytes = analyzeUrl.getByteArrayAwait() + //某些图片被加密,需要进一步解密 + runScriptWithContext { + ImageUtils.decode( + src, bytes, isCover = false, bookSource, book + ) + }?.let { + if (!checkImage(it)) { + // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据 + // 所以无论如何都要将数据写入到文件里 + // throw NoStackTraceException("数据异常") + AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常") + } + writeImage(book, src, it) + } + } finally { + imageDecodeSlots.release() + } } - writeImage(book, src, it) + } finally { + imageDownloadSlots.release() } } catch (e: Exception) { currentCoroutineContext().ensureActive() @@ -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 + } + /** * 读取章节内容 */ diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 38501f264..391d3cf17 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -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() 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() + private val pendingRequestId = AtomicLong(0) val downloadStateFlow = stateStore.stateFlow private val _cacheSuccessFlow = MutableSharedFlow(extraBufferCapacity = 64) @@ -113,9 +123,9 @@ object CacheBook { @Volatile private var lastQueueStats = QueueStats(0, 0) - val successDownloadSet = ConcurrentHashMap.newKeySet() - val errorDownloadMap = ConcurrentHashMap() - private val errorIndexMap = ConcurrentHashMap>() + @Volatile + private var successDownloadCount = 0 + private val errorRetryMap = ConcurrentHashMap() val cacheBookMap: ConcurrentHashMap 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 { 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 { 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 = queue.waitingIndices() - @Synchronized fun downloadingIndices(): Set = 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 { diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt index 77754e55c..f7485738c 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt @@ -41,6 +41,6 @@ data class CacheBookDownloadState( val waitingCount: Int = 0, val runningIndices: Set = emptySet(), val failedIndices: Set = emptySet(), - val successIndices: Set = emptySet(), val successCount: Int = 0, + val failureMessage: String? = null, ) diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt index b1779bd27..773919bdd 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt @@ -2,6 +2,91 @@ package io.legado.app.model.cache class CacheDownloadQueue { + private class IntRangeSet { + private val ranges = mutableListOf() + + 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, - removedIndices: Set, + 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() private val indices = linkedSetOf() - private val emittedIndices = hashSetOf() - private val removedIndices = hashSetOf() + 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 { - 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)) } diff --git a/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt b/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt index 7ebdb150b..dabd34e97 100644 --- a/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt +++ b/app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt index 68cd2ecfc..15a60ffba 100644 --- a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt +++ b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt @@ -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}") - Debug.log(bookSource.bookSourceUrl, body, state = 40) + 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() + 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, "┌获取正文内容") - Debug.log(bookSource.bookSourceUrl, "└\n$contentStr") + if (needSave) { + Debug.log(bookSource.bookSourceUrl, "└正文长度:${contentStr.length}") + } else { + Debug.log(bookSource.bookSourceUrl, "└\n$contentStr") + } if (!bookChapter.isVolume && contentStr.isBlank()) { throw ContentEmptyException("内容为空") } diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt index 06fcb772b..916c50ba1 100644 --- a/app/src/main/java/io/legado/app/service/CacheBookService.kt +++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt @@ -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) { - 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, diff --git a/app/src/main/java/io/legado/app/service/CheckSourceService.kt b/app/src/main/java/io/legado/app/service/CheckSourceService.kt index c6bea9e67..2a4a19111 100644 --- a/app/src/main/java/io/legado/app/service/CheckSourceService.kt +++ b/app/src/main/java/io/legado/app/service/CheckSourceService.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt b/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt index 771f0c5ff..5496dcd38 100644 --- a/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt +++ b/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt @@ -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 { - 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() } } @@ -175,4 +177,4 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt index ebcc7a698..de6462960 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt @@ -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 { 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 { diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt index 74075ed78..bcf24eca0 100644 --- a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt @@ -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() { diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt index c101be7be..20b766dc3 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt @@ -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 { diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt deleted file mode 100644 index ba1e4b581..000000000 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt +++ /dev/null @@ -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 { - putExtra("name", book.name) - putExtra("author", book.author) - putExtra("bookUrl", book.bookUrl) - } - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index c93eb2e89..cda365770 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -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) { diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index 3b099f6d7..4fbcf9438 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -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) { diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt index cb0810e6f..7ec901235 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -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 { - 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 { - 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) - } - ) - } } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt new file mode 100644 index 000000000..bf232cc01 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt @@ -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 { + 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() + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index fce52fc32..e8a7ded31 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -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( diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 030e7d236..ae7904928 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt deleted file mode 100644 index 2017ddcce..000000000 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt +++ /dev/null @@ -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(context), - - ItemTouchCallback.Callback { - val groupRequestCode = 12 - private val selectedBooks: HashSet = hashSetOf() - var actionItem: Book? = null - - val selection: List - 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 - ) { - 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() - 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 { - val groupNames = arrayListOf() - 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(Mode.ToggleAndReverse) { - override fun currentSelectedId(): MutableSet { - 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 - - fun upSelectCount() - - fun updateBook(vararg book: Book) - - fun deleteBook(book: Book) - - fun selectGroup(requestCode: Int, groupId: Long) - - fun openBook(book: Book) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt deleted file mode 100644 index eae91e470..000000000 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt +++ /dev/null @@ -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(), - PopupMenu.OnMenuItemClickListener, - SelectActionBar.CallBack, - BookAdapter.CallBack, - SourcePickerDialog.Callback, - GroupSelectDialog.CallBack { - - override val binding by viewBinding(ActivityArrangeBookBinding::inflate) - override val viewModel by viewModels() - override val groupList: ArrayList = 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? = 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() - 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() - 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 { - 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 - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt index cf2f92c94..558c18025 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt @@ -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 { - 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 diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt index 3eb379758..e66881bc9 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt @@ -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 = emptySet(), + val downloadStates: Map = emptyMap(), + val downloadFailureMessages: Map = 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, + 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, 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, 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, + 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, diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt deleted file mode 100644 index 9a0ad6c63..000000000 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt +++ /dev/null @@ -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() - val batchChangeSourceProcessLiveData = MutableLiveData() - var batchChangeSourceCoroutine: Coroutine? = null - - fun upCanUpdate(books: List, 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, 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, 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) { - execute { - books.forEach { - BookHelp.clearCache(it) - } - }.onSuccess { - context.toastOnUi(R.string.clear_cache_success) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt b/app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt deleted file mode 100644 index 16ccff708..000000000 --- a/app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt +++ /dev/null @@ -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(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 - ) { - 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) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index 28d096d07..461a78df4 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -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 { - putExtra("name", name) - putExtra("author", author) - putExtra("bookUrl", bookUrl) - } + startActivity( + MainActivity.createBookInfoIntent( + context = this, + name = name, + author = author, + bookUrl = bookUrl + ) + ) }, onOpenSourceManage = { startActivity() diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt index 8aab199d9..81a6ea858 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt @@ -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( diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index caace31cb..19f2303ea 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -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() private val routeEvents = MutableSharedFlow(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 { 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 { - putExtra("name", name) - putExtra("author", author) - putExtra("bookUrl", bookUrl) - } + navigateToRoute( + backStack, + MainRouteBookInfo( + name = name, + author = author, + bookUrl = bookUrl + ) + ) }, onOpenSourceManage = { this@MainActivity.startActivity() @@ -573,6 +660,41 @@ open class MainActivity : BaseComposeActivity() { onBackClick = { navigateBack(backStack) } ) } + + entry { route -> + val bookInfoViewModel = koinViewModel() + BookInfoRouteScreen( + bookUrl = route.bookUrl, + viewModel = bookInfoViewModel, + onBack = { navigateBack(backStack) }, + onFinish = { _, _ -> navigateBack(backStack) }, + onOpenSearch = { keyword -> + navigateToRoute(backStack, MainRouteSearch(key = keyword)) + }, + onRegisterVariableSetter = { setter -> + bookInfoVariableSetter = setter + } + ) + } + + entry { 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() diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 95d08b1d0..3b8b27b00 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -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 { - 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) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index 236435142..5734d545a 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -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() private val onUpTocBooks = ConcurrentHashMap.newKeySet() @@ -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() diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index b637bbd68..291b0252b 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -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 { - 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()), diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt index 180dad729..c16ef87f9 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt +++ b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt @@ -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 { - putExtra("exploreName", title) - putExtra("sourceUrl", sourceUrl) - putExtra("exploreUrl", url) - } + activity.startActivity( + MainActivity.createExploreShowIntent( + context = activity, + exploreName = title, + sourceUrl = sourceUrl, + exploreUrl = url + ) + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt index 6f05876ee..f77d0d20c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt @@ -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 diff --git a/app/src/main/res/layout/activity_arrange_book.xml b/app/src/main/res/layout/activity_arrange_book.xml deleted file mode 100644 index 178ff06fe..000000000 --- a/app/src/main/res/layout/activity_arrange_book.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_source_picker.xml b/app/src/main/res/layout/dialog_source_picker.xml deleted file mode 100644 index 7d79f6dc0..000000000 --- a/app/src/main/res/layout/dialog_source_picker.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_arrange_book.xml b/app/src/main/res/layout/item_arrange_book.xml deleted file mode 100644 index dec4a0b75..000000000 --- a/app/src/main/res/layout/item_arrange_book.xml +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/bookshelf_manage.xml b/app/src/main/res/menu/bookshelf_manage.xml deleted file mode 100644 index c359f2231..000000000 --- a/app/src/main/res/menu/bookshelf_manage.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/bookshelf_menage_sel.xml b/app/src/main/res/menu/bookshelf_menage_sel.xml deleted file mode 100644 index 290f38fd9..000000000 --- a/app/src/main/res/menu/bookshelf_menage_sel.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/source_picker.xml b/app/src/main/res/menu/source_picker.xml deleted file mode 100644 index b34e11e76..000000000 --- a/app/src/main/res/menu/source_picker.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 64dae3c67..fe10a4ab5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -243,10 +243,12 @@ 取消下载 暂无任务 %1$d/%2$d + 缓存失败:%1$s 导入选择书籍 - 更新和搜索线程数 - 书籍缓存下载线程数 - 控制离线缓存章节下载的并发数量 + 更新和搜索线程 + 控制更新和搜索线程的并发数量。数值越高越不稳定 + 书籍缓存下载线程 + 控制离线缓存章节下载的并发数量。数值越高越不稳定 切换图标 已在书架 已在书架 @@ -910,6 +912,8 @@ 样式 分组样式 导出文件名 + {name} - {author} + 可用变量:{name} 书名、{author} 作者、{group} 分组、{source} 书源、{remark} 备注。\n\n示例:{name} - {author}\n输出:三体 - 刘慈欣\n\n变量前后可以添加固定文字。 重置 URL 为空 字典 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7bfba451..4651f6df6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,10 +246,12 @@ Download cancel No download Downloaded %1$d/%2$d + Cache failed: %1$s Import selected book(s) Number of Concurrent Tasks + Controls concurrent download tasks. Higher values increase OOM risk Cache Download Threads - Controls concurrent chapter downloads for offline book cache + Controls concurrent chapter downloads for offline book cache. Higher values increase OOM risk Change Icon In library Already in Bookshelf @@ -919,6 +921,8 @@ Style Group style Export file name + {name} - {author} + 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. Reset Null URL dict diff --git a/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt b/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt index 6779812df..b4aca83ce 100644 --- a/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt +++ b/app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt @@ -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(), 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) + } }