Sfoglia il codice sorgente

[优化] 一些代码优化

HapeLee 6 mesi fa
parent
commit
0afed18353
46 ha cambiato i file con 1040 aggiunte e 1698 eliminazioni
  1. 128 0
      CLAUDE.md
  2. 0 10
      app/src/main/AndroidManifest.xml
  3. 1 2
      app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt
  4. 1 3
      app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt
  5. 74 15
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  6. 43 27
      app/src/main/java/io/legado/app/model/CacheBook.kt
  7. 1 1
      app/src/main/java/io/legado/app/model/cache/CacheDownloadModels.kt
  8. 106 24
      app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt
  9. 19 4
      app/src/main/java/io/legado/app/model/cache/CacheDownloadStateStore.kt
  10. 25 9
      app/src/main/java/io/legado/app/model/webBook/BookContent.kt
  11. 22 19
      app/src/main/java/io/legado/app/service/CacheBookService.kt
  12. 1 2
      app/src/main/java/io/legado/app/service/CheckSourceService.kt
  13. 10 8
      app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt
  14. 9 7
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  15. 1 3
      app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
  16. 1 3
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
  17. 0 39
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt
  18. 4 5
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  19. 11 4
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  20. 11 167
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  21. 197 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  22. 2 1
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  23. 16 2
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  24. 0 243
      app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt
  25. 0 421
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt
  26. 37 23
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  27. 75 10
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  28. 0 135
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt
  29. 0 160
      app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt
  30. 9 6
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  31. 11 4
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt
  32. 176 8
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  33. 6 8
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  34. 2 2
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  35. 3 7
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  36. 8 6
      app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt
  37. 3 1
      app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
  38. 0 31
      app/src/main/res/layout/activity_arrange_book.xml
  39. 0 98
      app/src/main/res/layout/dialog_source_picker.xml
  40. 0 94
      app/src/main/res/layout/item_arrange_book.xml
  41. 0 32
      app/src/main/res/menu/bookshelf_manage.xml
  42. 0 32
      app/src/main/res/menu/bookshelf_menage_sel.xml
  43. 0 8
      app/src/main/res/menu/source_picker.xml
  44. 7 3
      app/src/main/res/values-zh-rCN/strings.xml
  45. 5 1
      app/src/main/res/values/strings.xml
  46. 15 10
      app/src/test/java/io/legado/app/model/cache/CacheDownloadStateStoreTest.kt

+ 128 - 0
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<LocalBookGateway> { LocalBookRepository(get()) }`), not through `singleOf`.
+
+## Navigation
+
+Uses **Jetpack Navigation 3** (`androidx.navigation3`) with type-safe `@Serializable` sealed interfaces for route keys:
+
+```kotlin
+@Serializable
+private sealed interface MainRoute : NavKey
+@Serializable
+private data object MainRouteHome : MainRoute
+@Serializable
+private data class MainRouteCache(val groupId: Long) : MainRoute
+```
+
+`MainActivity` holds a single `NavDisplay` with `entryProvider { ... }` defining all composable entries. `Launcher0` through `LauncherW` extend `MainActivity` to provide multiple launcher icon alias entries. Separate activities handle the reader (`ReadBookActivity` — still View-based), book info, source management, replace rules, file manager, QR scanner, etc.
+
+## Theme System
+
+A multi-engine theming system in `ui/theme/`:
+
+1. **Material 3 Expressive** (default): Uses `MaterialExpressiveTheme` with `MotionScheme.expressive()`
+2. **Miuix** (alternative): Uses `top.yukonga.miuix.kmp` theming engine
+
+14 theme modes (`AppThemeMode` enum) — Dynamic (Monet), 12 named presets, Custom (MaterialKolor seed-color generation), Transparent. `CustomColorScheme` wraps `com.materialkolor` with configurable `PaletteStyle` (TonalSpot, Neutral, Vibrant, Expressive, Rainbow, etc.) and `ColorSpec` (2021 vs 2025).
+
+Legacy View-based theme still exists in `lib/theme/` (used by non-migrated screens like `ReadBookActivity`).
+
+## Hybrid Compose + View
+
+The app is mid-migration from Views to Compose. View-based screens (reader, book info, source management) coexist with Compose screens (main tabs, settings, search, RSS, cache management). XML layouts, `viewBinding`, and traditional Activities are still heavily used. The `viewBinding` build feature is enabled but Compose screens are the target.
+
+## Rhino JavaScript Engine
+
+Book sources, RSS sources, and HTTP TTS use JavaScript rules. `initRhino()` in `App.kt` registers `NativeBaseSource` wrappers for `BookSource`, `RssSource`, `HttpTTS` (writable JS objects) and `ReadOnlyJavaObject` wrappers for rule entities. Rule parsing logic lives in `help/source/` and `model/analyzeRule/`.
+
+## Important Constraints
+
+- **Do not update jsoup** beyond 1.16.2 — a breaking change in newer versions (see [jsoup#2017](https://github.com/jhy/jsoup/pull/2017)) affects `AnalyzeByJSoup.kt` and the JsoupXpath library
+- **Do not update hutool** beyond 5.8.22 — pinned in `libs.versions.toml:42`
+- Package name discrepancy: code namespace is `io.legado.app` but `applicationId` is `io.legato.kazusa`
+- Min SDK 26, target SDK 37, compile SDK 37
+- Release builds enable R8 minification + resource shrinking; `noR8` variant disables both for crash debugging
+- APK is split by ABI (`armeabi-v7a`, `arm64-v8a`, plus universal)
+- Firebase Analytics and Performance are included; `google-services` plugin applied
+
+## Web Frontend
+
+Located in `modules/web/` — a Vue 3 + TypeScript + Vite project for remote bookshelf and source editing. Must connect to the app's built-in HTTP server (started via `WebService` in the main activity settings). Commands:
+
+```bash
+cd modules/web
+pnpm install
+pnpm dev       # dev server
+pnpm build     # production build
+```
+
+Set `VITE_API` in `.env.development` to the app's web service IP.

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

@@ -243,11 +243,6 @@
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:launchMode="singleTop" />
-        <!-- 书籍管理 -->
-        <activity
-            android:name=".ui.book.manage.BookshelfManageActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:launchMode="singleTop" />
         <!-- 书源调试 -->
         <activity
             android:name=".ui.book.source.debug.BookSourceDebugActivity"
@@ -263,11 +258,6 @@
             android:name=".ui.book.searchContent.SearchContentActivity"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
-        <!-- 发现界面 -->
-        <activity
-            android:name=".ui.book.explore.ExploreShowActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:launchMode="singleTop" />
         <!-- 订阅源调试 -->
         <activity
             android:name=".ui.rss.source.debug.RssSourceDebugActivity"

+ 1 - 2
app/src/main/java/io/legado/app/domain/usecase/ChangeBookSourceUseCase.kt

@@ -32,7 +32,6 @@ data class ChangeSourceMigrationOptions(
 data class ChangeBookSourceResult(
     val oldBookUrl: String,
     val book: Book,
-    val chapters: List<BookChapter>,
 )
 
 data class BatchChangeBookSourceResult(
@@ -102,7 +101,7 @@ class ChangeBookSourceUseCase(
             bookChapterDao.insert(*chapters.toTypedArray())
             ReadBook.onChapterListUpdated(newBook)
         }
-        return ChangeBookSourceResult(oldBookUrl, newBook, chapters)
+        return ChangeBookSourceResult(oldBookUrl, newBook)
     }
 
     suspend fun batchChangeTo(

+ 1 - 3
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
 

+ 74 - 15
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<String, Mutex>()
+    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
+    }
+
     /**
      * 读取章节内容
      */

+ 43 - 27
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<String, CacheBookModel>()
         private val processMutex = Mutex()
@@ -80,7 +88,7 @@ object CacheBook {
                 }
             }.onStart {
                 updateSummary()
-            }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
+            }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)) {
                 coroutineScope {
                     it.download(this, context)
                 }
@@ -92,6 +100,8 @@ object CacheBook {
 
     private val coordinator = CacheBookCoordinator()
     private val stateStore = CacheDownloadStateStore()
+    private val pendingRequests = ConcurrentHashMap<Long, CacheDownloadRequest>()
+    private val pendingRequestId = AtomicLong(0)
     val downloadStateFlow = stateStore.stateFlow
 
     private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
@@ -113,9 +123,9 @@ object CacheBook {
     @Volatile
     private var lastQueueStats = QueueStats(0, 0)
 
-    val successDownloadSet = ConcurrentHashMap.newKeySet<String>()
-    val errorDownloadMap = ConcurrentHashMap<String, Int>()
-    private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>()
+    @Volatile
+    private var successDownloadCount = 0
+    private val errorRetryMap = ConcurrentHashMap<ChapterKey, Int>()
 
     val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
         get() = coordinator.taskMap
@@ -124,6 +134,12 @@ object CacheBook {
         return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
     }
 
+    fun markBookFailed(bookUrl: String, message: String) {
+        stateStore.markBookFailed(bookUrl, message)
+        updateSummary()
+        _queueChangedFlow.tryEmit(bookUrl)
+    }
+
     private fun collectQueueStats(): QueueStats {
         var waiting = 0
         var downloading = 0
@@ -139,7 +155,7 @@ object CacheBook {
         val stats = collectQueueStats()
         lastQueueStats = stats
         _downloadSummaryFlow.value =
-            "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
+            "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${stateStore.state.totalFailure}|成功:$successDownloadCount"
     }
 
     @Synchronized
@@ -204,8 +220,11 @@ object CacheBook {
             }
             is ChapterSelection.Single -> Unit
         }
+        val requestId = pendingRequestId.incrementAndGet()
+        pendingRequests[requestId] = request
         context.startService<CacheBookService> {
             action = IntentAction.start
+            putExtra("requestId", requestId)
             putExtra("bookUrl", request.bookUrl)
             putExtra("source", request.source.name)
             when (val selection = request.selection) {
@@ -213,9 +232,7 @@ object CacheBook {
                     putExtra("start", selection.start)
                     putExtra("end", selection.end)
                 }
-                is ChapterSelection.Indices -> {
-                    putIntegerArrayListExtra("indices", ArrayList(selection.values))
-                }
+                is ChapterSelection.Indices -> Unit
                 is ChapterSelection.Single -> {
                     putExtra("start", selection.index)
                     putExtra("end", selection.index)
@@ -224,6 +241,10 @@ object CacheBook {
         }
     }
 
+    fun takePendingRequest(requestId: Long): CacheDownloadRequest? {
+        return pendingRequests.remove(requestId)
+    }
+
     fun remove(context: Context, bookUrl: String) {
         context.startService<CacheBookService> {
             action = IntentAction.remove
@@ -232,12 +253,12 @@ object CacheBook {
     }
 
     fun removeBook(bookUrl: String): Boolean {
-        val model = cacheBookMap.remove(bookUrl) ?: return false
-        model.stop()
+        val model = cacheBookMap.remove(bookUrl)
+        model?.stop()
         stateStore.removeBook(bookUrl)
         updateSummary()
         _queueChangedFlow.tryEmit(bookUrl)
-        return true
+        return model != null
     }
 
     fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean {
@@ -255,9 +276,9 @@ object CacheBook {
     fun close() {
         cacheBookMap.forEach { (_, model) -> model.stop() }
         cacheBookMap.clear()
-        successDownloadSet.clear()
-        errorDownloadMap.clear()
-        errorIndexMap.clear()
+        successDownloadCount = 0
+        errorRetryMap.clear()
+        pendingRequests.clear()
         stateStore.clear()
         updateSummary()
     }
@@ -273,16 +294,16 @@ object CacheBook {
     val totalCount: Int
         get() {
             val stats = collectQueueStats()
-            return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
+            return stats.waitingCount + stats.downloadingCount + successDownloadCount + stateStore.state.totalFailure
         }
 
     val completedCount: Int
-        get() = successDownloadSet.size + errorDownloadMap.size
+        get() = successDownloadCount + stateStore.state.totalFailure
 
     val downloadSummary: String
         get() {
             val stats = collectQueueStats()
-            return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${errorDownloadMap.size} | 成功:${successDownloadSet.size}"
+            return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${stateStore.state.totalFailure} | 成功:$successDownloadCount"
         }
 
     val isRun: Boolean
@@ -351,9 +372,6 @@ object CacheBook {
         @Synchronized
         fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
 
-        @Synchronized
-        fun waitingIndices(): Set<Int> = queue.waitingIndices()
-
         @Synchronized
         fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
 
@@ -438,9 +456,9 @@ object CacheBook {
         private fun onSuccess(chapter: BookChapter) {
             onDownloadSet.remove(chapter.index)
             chapterTasks.remove(chapter.index)
-            successDownloadSet.add(chapter.primaryStr())
-            errorDownloadMap.remove(chapter.primaryStr())
-            errorIndexMap[book.bookUrl]?.remove(chapter.index)
+            val chapterKey = ChapterKey(book.bookUrl, chapter.index)
+            successDownloadCount++
+            errorRetryMap.remove(chapterKey)
             stateStore.markSuccess(book.bookUrl, chapter.index)
             notifyDownloadSetChanged()
             notifyErrorChanged()
@@ -451,9 +469,7 @@ object CacheBook {
         private fun onPreError(chapter: BookChapter, error: Throwable) {
             waitingRetry = true
             if (error !is ConcurrentException) {
-                errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
-                errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
-                    .add(chapter.index)
+                errorRetryMap.merge(ChapterKey(book.bookUrl, chapter.index), 1) { old, inc -> old + inc }
                 stateStore.markFailed(book.bookUrl, chapter.index)
             }
             onDownloadSet.remove(chapter.index)
@@ -462,7 +478,7 @@ object CacheBook {
 
         @Synchronized
         private fun onPostError(chapter: BookChapter, error: Throwable) {
-            val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
+            val retryCount = errorRetryMap[ChapterKey(book.bookUrl, chapter.index)] ?: 0
             if (retryCount < 3 && !isStopped) {
                 queue.enqueue(ChapterSelection.Single(chapter.index))
             } else {

+ 1 - 1
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<Int> = emptySet(),
     val failedIndices: Set<Int> = emptySet(),
-    val successIndices: Set<Int> = emptySet(),
     val successCount: Int = 0,
+    val failureMessage: String? = null,
 )

+ 106 - 24
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<IntRange>()
+
+        fun contains(value: Int): Boolean {
+            return ranges.any { value in it }
+        }
+
+        fun add(value: Int) {
+            addRange(value, value)
+        }
+
+        fun addRange(start: Int, end: Int) {
+            if (end < start) return
+            var newStart = start
+            var newEnd = end
+            var index = 0
+            while (index < ranges.size) {
+                val range = ranges[index]
+                if (newEnd + 1 < range.first) break
+                if (newStart > range.last + 1) {
+                    index++
+                    continue
+                }
+                newStart = minOf(newStart, range.first)
+                newEnd = maxOf(newEnd, range.last)
+                ranges.removeAt(index)
+            }
+            ranges.add(index, newStart..newEnd)
+        }
+
+        fun remove(value: Int) {
+            val index = ranges.indexOfFirst { value in it }
+            if (index < 0) return
+            val range = ranges.removeAt(index)
+            if (range.first < value) {
+                ranges.add(index, range.first until value)
+            }
+            if (value < range.last) {
+                ranges.add(index + if (range.first < value) 1 else 0, value + 1..range.last)
+            }
+        }
+
+        fun removeRange(start: Int, end: Int) {
+            if (end < start) return
+            var index = 0
+            while (index < ranges.size) {
+                val range = ranges[index]
+                if (range.last < start) {
+                    index++
+                    continue
+                }
+                if (range.first > end) break
+                ranges.removeAt(index)
+                if (range.first < start) {
+                    ranges.add(index, range.first until start)
+                    index++
+                }
+                if (end < range.last) {
+                    ranges.add(index, end + 1..range.last)
+                    break
+                }
+            }
+        }
+
+        fun clear() {
+            ranges.clear()
+        }
+
+        fun countInRange(start: Int, end: Int, excluding: IntRangeSet? = null): Int {
+            if (end < start) return 0
+            var count = 0
+            ranges.forEach { range ->
+                val overlapStart = maxOf(start, range.first)
+                val overlapEnd = minOf(end, range.last)
+                if (overlapEnd >= overlapStart) {
+                    count += overlapEnd - overlapStart + 1
+                    if (excluding != null) {
+                        count -= excluding.countInRange(overlapStart, overlapEnd)
+                    }
+                }
+            }
+            return count
+        }
+    }
+
     private data class RangeCursor(
         val start: Int,
         val end: Int,
@@ -9,13 +94,13 @@ class CacheDownloadQueue {
     ) {
         fun contains(index: Int): Boolean = index in next..end
         fun remainingCount(
-            emittedIndices: Set<Int>,
-            removedIndices: Set<Int>,
+            emittedIndices: IntRangeSet,
+            removedIndices: IntRangeSet,
         ): Int {
             if (next > end) return 0
             val rawCount = end - next + 1
-            val emittedCount = emittedIndices.count { it in next..end }
-            val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
+            val emittedCount = emittedIndices.countInRange(next, end)
+            val removedCount = removedIndices.countInRange(next, end, excluding = emittedIndices)
             val excludedCount = emittedCount + removedCount
             return rawCount - excludedCount
         }
@@ -23,8 +108,8 @@ class CacheDownloadQueue {
 
     private val ranges = ArrayDeque<RangeCursor>()
     private val indices = linkedSetOf<Int>()
-    private val emittedIndices = hashSetOf<Int>()
-    private val removedIndices = hashSetOf<Int>()
+    private val emittedIndices = IntRangeSet()
+    private val removedIndices = IntRangeSet()
 
     fun enqueue(request: CacheDownloadRequest) {
         enqueue(request.selection)
@@ -42,7 +127,7 @@ class CacheDownloadQueue {
         while (indices.isNotEmpty()) {
             val index = indices.first()
             indices.remove(index)
-            if (index in runningIndices || index in removedIndices) continue
+            if (index in runningIndices || removedIndices.contains(index)) continue
             emittedIndices.add(index)
             return CacheDownloadCandidate(bookUrl, index)
         }
@@ -51,10 +136,15 @@ class CacheDownloadQueue {
             val cursor = ranges.first()
             while (cursor.next <= cursor.end) {
                 val index = cursor.next++
-                if (index in removedIndices || index in runningIndices) continue
-                if (emittedIndices.add(index)) {
-                    return CacheDownloadCandidate(bookUrl, index)
+                if (
+                    removedIndices.contains(index) ||
+                    emittedIndices.contains(index) ||
+                    index in runningIndices
+                ) {
+                    continue
                 }
+                emittedIndices.add(index)
+                return CacheDownloadCandidate(bookUrl, index)
             }
             ranges.removeFirst()
         }
@@ -79,30 +169,22 @@ class CacheDownloadQueue {
     }
 
     fun waitingCount(): Int {
-        val indexCount = indices.count { it !in emittedIndices && it !in removedIndices }
+        val indexCount = indices.count {
+            !emittedIndices.contains(it) && !removedIndices.contains(it)
+        }
         val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
         return indexCount + rangeCount
     }
 
     fun isWaiting(index: Int): Boolean {
-        if (index in emittedIndices || index in removedIndices) return false
+        if (emittedIndices.contains(index) || removedIndices.contains(index)) return false
         return indices.contains(index) || ranges.any { it.contains(index) }
     }
 
-    fun waitingIndices(): Set<Int> {
-        return buildSet {
-            indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
-            ranges.forEach { cursor ->
-                for (index in cursor.next..cursor.end) {
-                    if (index !in emittedIndices && index !in removedIndices) add(index)
-                }
-            }
-        }
-    }
-
     private fun addRange(start: Int, end: Int) {
         if (end < start) return
-        removedIndices.removeAll { it in start..end }
+        emittedIndices.removeRange(start, end)
+        removedIndices.removeRange(start, end)
         ranges.add(RangeCursor(start, end))
     }
 

+ 19 - 4
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,

+ 25 - 9
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<String>()
+        var pageCount = 0
+        val contentBuilder = StringBuilder()
+        fun appendContent(content: String) {
+            if (pageCount > 0) {
+                contentBuilder.append('\n')
+            }
+            contentBuilder.append(content)
+            pageCount++
+        }
         val nextUrlList = arrayListOf(redirectUrl)
         val contentRule = bookSource.getContentRule()
         val analyzeRule = AnalyzeRule(book, bookSource)
@@ -70,7 +82,7 @@ object BookContent {
         var contentData = analyzeContent(
             book, baseUrl, redirectUrl, body, contentRule, bookChapter, bookSource, mNextChapterUrl
         )
-        contentList.add(contentData.first)
+        appendContent(contentData.first)
         if (contentData.second.size == 1) {
             var nextUrl = contentData.second[0]
             while (nextUrl.isNotEmpty() && !nextUrlList.contains(nextUrl)) {
@@ -95,8 +107,8 @@ object BookContent {
                     )
                     nextUrl =
                         if (contentData.second.isNotEmpty()) contentData.second[0] else ""
-                    contentList.add(contentData.first)
-                    Debug.log(bookSource.bookSourceUrl, "第${contentList.size}页完成")
+                    appendContent(contentData.first)
+                    Debug.log(bookSource.bookSourceUrl, "第${pageCount}页完成")
                 }
             }
             Debug.log(bookSource.bookSourceUrl, "◇本章总页数:${nextUrlList.size}")
@@ -106,7 +118,7 @@ object BookContent {
                 for (urlStr in contentData.second) {
                     emit(urlStr)
                 }
-            }.mapAsync(OtherConfig.threadCount) { urlStr ->
+            }.mapAsync(OtherConfig.threadCount.coerceIn(1, maxNextPageConcurrency)) { urlStr ->
                 val analyzeUrl = AnalyzeUrl(
                     mUrl = urlStr,
                     source = bookSource,
@@ -122,10 +134,10 @@ object BookContent {
                 ).first
             }.collect {
                 coroutineContext.ensureActive()
-                contentList.add(it)
+                appendContent(it)
             }
         }
-        var contentStr = contentList.joinToString("\n")
+        var contentStr = contentBuilder.toString()
         val titleRule = contentRule.title //先正文再章节名称
         if (!titleRule.isNullOrBlank()) {
             var title = analyzeRule.runCatching {
@@ -160,7 +172,11 @@ object BookContent {
         Debug.log(bookSource.bookSourceUrl, "┌获取章节名称")
         Debug.log(bookSource.bookSourceUrl, "└${bookChapter.title}")
         Debug.log(bookSource.bookSourceUrl, "┌获取正文内容")
-        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("内容为空")
         }

+ 22 - 19
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<Int>) {
-        bookUrl ?: return
-        val values = indices.toSet()
-        if (values.isEmpty()) return
-        addDownloadRequest(
-            CacheDownloadRequest(
-                bookUrl = bookUrl,
-                selection = ChapterSelection.Indices(values),
-                source = CacheDownloadSource.Manual,
-            )
-        )
-    }
-
     private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
         bookUrl ?: return
         if (end < start) return
@@ -140,7 +132,10 @@ class CacheBookService : BaseService() {
 
     private fun addDownloadRequest(request: CacheDownloadRequest) {
         execute {
-            val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
+            val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: run {
+                CacheBook.markBookFailed(request.bookUrl, getString(R.string.error_no_source))
+                return@execute
+            }
 
             val book = cacheBook.book
             val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
@@ -154,6 +149,10 @@ class CacheBookService : BaseService() {
                             WebBook.getBookInfoAwait(cacheBook.bookSource, book)
                         }.onFailure {
                             removeDownload(request.bookUrl)
+                            CacheBook.markBookFailed(
+                                request.bookUrl,
+                                getString(R.string.error_get_book_info)
+                            )
                             AppLog.put(
                                 "《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
                                 it,
@@ -169,6 +168,10 @@ class CacheBookService : BaseService() {
                             book.update()
                         }
                         removeDownload(request.bookUrl)
+                        CacheBook.markBookFailed(
+                            request.bookUrl,
+                            getString(R.string.error_get_chapter_list)
+                        )
                         AppLog.put(
                             "《$name》目录为空且加载目录失败\n${it.localizedMessage}",
                             it,

+ 1 - 2
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

+ 10 - 8
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<BookInfoActivity> {
-                    putExtra("name", it.name)
-                    putExtra("author", it.author)
-                    putExtra("bookUrl", it.bookUrl)
-                }
+                startActivity(
+                    MainActivity.createBookInfoIntent(
+                        context = requireContext(),
+                        name = it.name,
+                        author = it.author,
+                        bookUrl = it.bookUrl
+                    )
+                )
                 dismiss()
             }
         }
@@ -175,4 +177,4 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
 
     }
 
-}
+}

+ 9 - 7
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<BookCacheChapterItem> {
         val book = bookDao.getBook(bookUrl) ?: return emptyList()
         val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
@@ -472,7 +474,7 @@ class BookCacheManageViewModel(
     }
 
     private fun deleteBookCache(bookUrl: String) {
-        CacheBook.remove(context, bookUrl)
+        CacheBook.removeBook(bookUrl)
         execute {
             clearBookCacheUseCase.execute(bookUrl)
         }.onSuccess {

+ 1 - 3
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() {

+ 1 - 3
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 {

+ 0 - 39
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowActivity.kt

@@ -1,39 +0,0 @@
-package io.legado.app.ui.book.explore
-
-import android.os.Bundle
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import io.legado.app.base.BaseComposeActivity
-import io.legado.app.ui.book.info.BookInfoActivity
-import io.legado.app.utils.startActivity
-
-/**
- * 发现列表
- */
-class ExploreShowActivity : BaseComposeActivity() {
-
-    private lateinit var screenTitle: String
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        screenTitle = intent.getStringExtra("exploreName") ?: "探索"
-    }
-
-    @Composable
-    override fun Content() {
-        MaterialTheme {
-            ExploreShowScreen(
-                title = screenTitle,
-                intent = intent,
-                onBack = { finish() },
-                onBookClick = { book ->
-                    startActivity<BookInfoActivity> {
-                        putExtra("name", book.name)
-                        putExtra("author", book.author)
-                        putExtra("bookUrl", book.bookUrl)
-                    }
-                }
-            )
-        }
-    }
-}

+ 4 - 5
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) {

+ 11 - 4
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) {

+ 11 - 167
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<SourceLoginActivity> {
-                            putExtra("type", "bookSource")
-                            putExtra("key", effect.sourceUrl)
-                        }
-                    }
-
-                    BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
-                        title = getString(R.string.select_book_folder)
-                    }
-
-                    is BookInfoEffect.OpenFile -> openFileUri(effect.uri, effect.mimeType)
-                    is BookInfoEffect.RunSourceCallback -> runSourceCallback(effect)
-                    is BookInfoEffect.ShowVariableDialog -> {
-                        showDialogFragment(
-                            VariableDialog(
-                                effect.title,
-                                effect.key,
-                                effect.variable,
-                                effect.comment,
-                            )
-                        )
-                    }
-                }
-            }
-        }
-
-        BookInfoScreen(
-            state = state,
-            onIntent = viewModel::onIntent,
+        BookInfoRouteScreen(
+            bookUrl = intent.getStringExtra("bookUrl").orEmpty(),
+            viewModel = viewModel,
             onBack = { finishAfterTransition() },
+            onFinish = { resultCode, afterTransition ->
+                resultCode?.let { setResult(it) }
+                if (afterTransition) finishAfterTransition() else finish()
+            },
+            onOpenSearch = { keyword ->
+                startActivity(MainActivity.createSearchIntent(this, key = keyword))
+            },
         )
     }
 
     override fun setVariable(key: String, variable: String?) {
         viewModel.setVariable(key, variable)
     }
-
-    private fun runSourceCallback(effect: BookInfoEffect.RunSourceCallback) {
-        SourceCallBack.callBackBtn(
-            this,
-            effect.event,
-            effect.source,
-            effect.book,
-            null,
-        ) {
-            when (val action = effect.action) {
-                is BookInfoCallbackAction.Search -> {
-                    startActivity<SearchActivity> {
-                        putExtra("key", action.keyword)
-                    }
-                }
-
-                is BookInfoCallbackAction.ShareText -> {
-                    val intent = Intent(Intent.ACTION_SEND).apply {
-                        putExtra(Intent.EXTRA_TEXT, action.text)
-                        type = "text/plain"
-                    }
-                    startActivity(Intent.createChooser(intent, action.chooserTitle))
-                }
-
-                is BookInfoCallbackAction.CopyText -> {
-                    sendToClip(action.text)
-                }
-
-                BookInfoCallbackAction.ClearCache -> {
-                    viewModel.clearCache()
-                }
-            }
-        }
-    }
-
-    private fun startReadActivity(
-        book: io.legado.app.data.entities.Book,
-        inBookshelf: Boolean,
-        chapterChanged: Boolean,
-    ) {
-        val cls = when {
-            book.isAudio -> AudioPlayActivity::class.java
-            !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
-            else -> ReadBookActivity::class.java
-        }
-        readBookResult.launch(
-            Intent(this, cls).apply {
-                putExtra("bookUrl", book.bookUrl)
-                putExtra("inBookshelf", inBookshelf)
-                putExtra("chapterChanged", chapterChanged)
-            }
-        )
-    }
 }

+ 197 - 0
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<SourceLoginActivity> {
+                        putExtra("type", "bookSource")
+                        putExtra("key", effect.sourceUrl)
+                    }
+                }
+
+                BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
+                    title = activity.getString(R.string.select_book_folder)
+                }
+
+                is BookInfoEffect.OpenFile -> activity.openFileUri(effect.uri, effect.mimeType)
+                is BookInfoEffect.RunSourceCallback -> {
+                    runSourceCallback(activity, effect, viewModel, onOpenSearch)
+                }
+
+                is BookInfoEffect.ShowVariableDialog -> {
+                    activity.showDialogFragment(
+                        VariableDialog(
+                            effect.title,
+                            effect.key,
+                            effect.variable,
+                            effect.comment,
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    BookInfoScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        onIntent = viewModel::onIntent,
+        onBack = onBack,
+    )
+}
+
+private fun runSourceCallback(
+    activity: AppCompatActivity,
+    effect: BookInfoEffect.RunSourceCallback,
+    viewModel: BookInfoViewModel,
+    onOpenSearch: (String) -> Unit,
+) {
+    SourceCallBack.callBackBtn(
+        activity,
+        effect.event,
+        effect.source,
+        effect.book,
+        null,
+    ) {
+        when (val action = effect.action) {
+            is BookInfoCallbackAction.Search -> {
+                onOpenSearch(action.keyword)
+            }
+
+            is BookInfoCallbackAction.ShareText -> {
+                val intent = Intent(Intent.ACTION_SEND).apply {
+                    putExtra(Intent.EXTRA_TEXT, action.text)
+                    type = "text/plain"
+                }
+                activity.startActivity(Intent.createChooser(intent, action.chooserTitle))
+            }
+
+            is BookInfoCallbackAction.CopyText -> {
+                activity.sendToClip(action.text)
+            }
+
+            BookInfoCallbackAction.ClearCache -> {
+                viewModel.clearCache()
+            }
+        }
+    }
+}

+ 2 - 1
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(

+ 16 - 2
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

+ 0 - 243
app/src/main/java/io/legado/app/ui/book/manage/BookAdapter.kt

@@ -1,243 +0,0 @@
-package io.legado.app.ui.book.manage
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.os.bundleOf
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookGroup
-import io.legado.app.databinding.ItemArrangeBookBinding
-import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
-//import io.legado.app.lib.theme.backgroundColor
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import java.util.*
-
-class BookAdapter(context: Context, val callBack: CallBack) :
-    RecyclerAdapter<Book, ItemArrangeBookBinding>(context),
-
-    ItemTouchCallback.Callback {
-    val groupRequestCode = 12
-    private val selectedBooks: HashSet<Book> = hashSetOf()
-    var actionItem: Book? = null
-
-    val selection: List<Book>
-        get() {
-            return getItems().filter {
-                selectedBooks.contains(it)
-            }
-        }
-
-    override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding {
-        return ItemArrangeBookBinding.inflate(inflater, parent, false)
-    }
-
-    override fun onCurrentListChanged() {
-        callBack.upSelectCount()
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemArrangeBookBinding,
-        item: Book,
-        payloads: MutableList<Any>
-    ) {
-        binding.apply {
-            //root.setBackgroundColor(context.backgroundColor)
-            tvName.text = item.name
-            tvAuthor.text = item.author
-            tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE
-            tvGroupS.text = getGroupName(item.group)
-            checkbox.isChecked = selectedBooks.contains(item)
-            if (item.isLocal) {
-                tvOrigin.setText(R.string.local_book)
-            } else {
-                tvOrigin.text = item.originName
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) {
-        binding.apply {
-            checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
-                if (buttonView.isPressed) {
-                    getItem(holder.layoutPosition)?.let {
-                        if (buttonView.isPressed) {
-                            if (isChecked) {
-                                selectedBooks.add(it)
-                            } else {
-                                selectedBooks.remove(it)
-                            }
-                            callBack.upSelectCount()
-                        }
-                    }
-                }
-            }
-            root.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    checkbox.isChecked = !checkbox.isChecked
-                    if (checkbox.isChecked) {
-                        selectedBooks.add(it)
-                    } else {
-                        selectedBooks.remove(it)
-                    }
-                    callBack.upSelectCount()
-                }
-            }
-            if (AppConfig.openBookInfoByClickTitle) {
-                tvName.setOnClickListener {
-                    getItem(holder.layoutPosition)?.let {
-                        callBack.openBook(it)
-                    }
-                }
-            }
-            tvDelete.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    callBack.deleteBook(it)
-                }
-            }
-            tvGroup.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    actionItem = it
-                    callBack.selectGroup(groupRequestCode, it.group)
-                }
-            }
-        }
-    }
-
-    @SuppressLint("NotifyDataSetChanged")
-    fun selectAll(selectAll: Boolean) {
-        if (selectAll) {
-            getItems().forEach {
-                selectedBooks.add(it)
-            }
-        } else {
-            selectedBooks.clear()
-        }
-        notifyDataSetChanged()
-        callBack.upSelectCount()
-    }
-
-    @SuppressLint("NotifyDataSetChanged")
-    fun revertSelection() {
-        getItems().forEach {
-            if (selectedBooks.contains(it)) {
-                selectedBooks.remove(it)
-            } else {
-                selectedBooks.add(it)
-            }
-        }
-        notifyDataSetChanged()
-        callBack.upSelectCount()
-    }
-
-    fun checkSelectedInterval() {
-        val selectedPosition = linkedSetOf<Int>()
-        getItems().forEachIndexed { index, it ->
-            if (selectedBooks.contains(it)) {
-                selectedPosition.add(index)
-            }
-        }
-        val minPosition = Collections.min(selectedPosition)
-        val maxPosition = Collections.max(selectedPosition)
-        val itemCount = maxPosition - minPosition + 1
-        for (i in minPosition..maxPosition) {
-            getItem(i)?.let {
-                selectedBooks.add(it)
-            }
-        }
-        notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upSelectCount()
-    }
-
-    private fun getGroupList(groupId: Long): List<String> {
-        val groupNames = arrayListOf<String>()
-        callBack.groupList.forEach {
-            if (it.groupId > 0 && it.groupId and groupId > 0) {
-                groupNames.add(it.groupName)
-            }
-        }
-        return groupNames
-    }
-
-    private fun getGroupName(groupId: Long): String {
-        val groupNames = getGroupList(groupId)
-        if (groupNames.isEmpty()) {
-            return ""
-        }
-        return groupNames.joinToString(",")
-    }
-
-    private var isMoved = false
-
-    override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
-        val srcItem = getItem(srcPosition)
-        val targetItem = getItem(targetPosition)
-        if (srcItem != null && targetItem != null) {
-            if (srcItem.order == targetItem.order) {
-                for ((index, item) in getItems().withIndex()) {
-                    item.order = index + 1
-                }
-            } else {
-                val pos = srcItem.order
-                srcItem.order = targetItem.order
-                targetItem.order = pos
-            }
-        }
-        swapItem(srcPosition, targetPosition)
-        isMoved = true
-        return true
-    }
-
-    override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
-        if (isMoved) {
-            callBack.updateBook(*getItems().toTypedArray())
-        }
-        isMoved = false
-    }
-
-    val dragSelectCallback: DragSelectTouchHelper.Callback =
-        object : DragSelectTouchHelper.AdvanceCallback<Book>(Mode.ToggleAndReverse) {
-            override fun currentSelectedId(): MutableSet<Book> {
-                return selectedBooks
-            }
-
-            override fun getItemId(position: Int): Book {
-                return getItem(position)!!
-            }
-
-            override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
-                getItem(position)?.let {
-                    if (isSelected) {
-                        selectedBooks.add(it)
-                    } else {
-                        selectedBooks.remove(it)
-                    }
-                    notifyItemChanged(position, bundleOf(Pair("selected", null)))
-                    callBack.upSelectCount()
-                    return true
-                }
-                return false
-            }
-        }
-
-    interface CallBack {
-        val groupList: List<BookGroup>
-
-        fun upSelectCount()
-
-        fun updateBook(vararg book: Book)
-
-        fun deleteBook(book: Book)
-
-        fun selectGroup(requestCode: Int, groupId: Long)
-
-        fun openBook(book: Book)
-    }
-}

+ 0 - 421
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageActivity.kt

@@ -1,421 +0,0 @@
-package io.legado.app.ui.book.manage
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MotionEvent
-import android.widget.CheckBox
-import android.widget.LinearLayout
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookGroup
-import io.legado.app.data.entities.BookSource
-import io.legado.app.databinding.ActivityArrangeBookBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.help.book.contains
-import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.config.LocalConfig
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.primaryColor
-//import io.legado.app.lib.theme.primaryTextColor
-import io.legado.app.ui.book.group.GroupManageDialog
-import io.legado.app.ui.book.group.GroupSelectDialog
-import io.legado.app.ui.book.info.BookInfoActivity
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.widget.SelectActionBar
-import io.legado.app.ui.widget.dialog.WaitDialog
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.cnCompare
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.hideSoftInput
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.shouldHideSoftInput
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import kotlin.math.max
-
-/**
- * 书架管理
- */
-class BookshelfManageActivity :
-    VMBaseActivity<ActivityArrangeBookBinding, BookshelfManageViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    SelectActionBar.CallBack,
-    BookAdapter.CallBack,
-    SourcePickerDialog.Callback,
-    GroupSelectDialog.CallBack {
-
-    override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
-    override val viewModel by viewModels<BookshelfManageViewModel>()
-    override val groupList: ArrayList<BookGroup> = arrayListOf()
-    private val groupRequestCode = 22
-    private val addToGroupRequestCode = 34
-    private val adapter by lazy { BookAdapter(this, this) }
-    private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
-    private var booksFlowJob: Job? = null
-    private var menu: Menu? = null
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private var books: List<Book>? = null
-    private val waitDialog by lazy { WaitDialog(this) }
-    private val exportDir = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        viewModel.groupId = intent.getLongExtra("groupId", -1)
-        lifecycleScope.launch {
-            viewModel.groupName = withContext(IO) {
-                appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName
-                    ?: getString(R.string.no_group)
-            }
-            upTitle()
-        }
-        initSearchView()
-        initRecyclerView()
-        initOtherView()
-        initGroupData()
-        upBookDataByGroupId()
-    }
-
-    override fun observeLiveBus() {
-        viewModel.batchChangeSourceState.observe(this) {
-            if (it) {
-                waitDialog.setText(R.string.change_source_batch)
-                waitDialog.show()
-            } else {
-                waitDialog.dismiss()
-            }
-        }
-        viewModel.batchChangeSourceProcessLiveData.observe(this) {
-            waitDialog.setText(it)
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.bookshelf_manage, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        this.menu = menu
-        menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked =
-            AppConfig.openBookInfoByClickTitle
-        upMenu()
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun selectAll(selectAll: Boolean) {
-        adapter.selectAll(selectAll)
-    }
-
-    override fun revertSelection() {
-        adapter.revertSelection()
-    }
-
-    override fun onClickSelectBarMainAction() {
-        selectGroup(groupRequestCode, 0)
-    }
-
-    private fun upTitle() {
-        searchView.queryHint = getString(R.string.screen) + " • " + viewModel.groupName
-    }
-
-    private fun initSearchView() {
-        //searchView.applyTint(primaryTextColor)
-        searchView.isSubmitButtonEnabled = true
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                return false
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                upBookData()
-                return false
-            }
-
-        })
-    }
-
-    private fun initRecyclerView() {
-        //binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.layoutManager = LinearLayoutManager(this)
-        binding.recyclerView.addItemDecoration(VerticalDivider(this))
-        binding.recyclerView.adapter = adapter
-        itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3
-        val dragSelectTouchHelper: DragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        // When this page is opened, it is in selection mode
-        dragSelectTouchHelper.activeSlideSelect()
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initOtherView() {
-        binding.selectActionBar.setMainActionText(R.string.move_to_group)
-        binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-        waitDialog.setOnCancelListener {
-            viewModel.batchChangeSourceCoroutine?.cancel()
-        }
-    }
-
-    @SuppressLint("NotifyDataSetChanged")
-    private fun initGroupData() {
-        lifecycleScope.launch {
-            appDb.bookGroupDao.flowAll().catch {
-                AppLog.put("书架管理界面获取分组数据失败\n${it.localizedMessage}", it)
-            }.flowOn(IO).conflate().collect {
-                groupList.clear()
-                groupList.addAll(it)
-                adapter.notifyDataSetChanged()
-                upMenu()
-            }
-        }
-    }
-
-    private fun upBookDataByGroupId() {
-        booksFlowJob?.cancel()
-        booksFlowJob = lifecycleScope.launch {
-            val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId)
-            appDb.bookDao.flowByGroup(viewModel.groupId).map { list ->
-                when (bookSort) {
-                    1 -> list.sortedByDescending {
-                        it.latestChapterTime
-                    }
-
-                    2 -> list.sortedWith { o1, o2 ->
-                        o1.name.cnCompare(o2.name)
-                    }
-
-                    3 -> list.sortedBy {
-                        it.order
-                    }
-
-                    4 -> list.sortedByDescending {
-                        max(it.latestChapterTime, it.durChapterTime)
-                    }
-
-                    else -> list.sortedByDescending {
-                        it.durChapterTime
-                    }
-                }
-            }.catch {
-                AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it)
-            }.flowOn(IO)
-                .conflate().collect {
-                    books = it
-                    upBookData()
-                    itemTouchCallback.isCanDrag = bookSort == 3
-                }
-        }
-    }
-
-    private fun upBookData() {
-        books?.let { books ->
-            val searchKey = searchView.query
-            if (searchKey.isNullOrEmpty()) {
-                adapter.setItems(books)
-            } else {
-                books.filter {
-                    it.contains(searchKey.toString())
-                }.let {
-                    adapter.setItems(it)
-                }
-            }
-        }
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
-            R.id.menu_open_book_info_by_click_title -> {
-                AppConfig.openBookInfoByClickTitle = !item.isChecked
-                adapter.notifyItemRangeChanged(0, adapter.itemCount)
-            }
-
-            R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file ->
-                exportDir.launch {
-                    mode = HandleFileContract.EXPORT
-                    fileData = HandleFileContract.FileData(
-                        "bookSource.json",
-                        file,
-                        "application/json"
-                    )
-                }
-            }
-
-            else -> if (item.groupId == R.id.menu_group) {
-                viewModel.groupName = item.title.toString()
-                upTitle()
-                viewModel.groupId =
-                    appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
-                upBookDataByGroupId()
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_del_selection -> alertDelSelection()
-            R.id.menu_update_enable ->
-                viewModel.upCanUpdate(adapter.selection, true)
-
-            R.id.menu_update_disable ->
-                viewModel.upCanUpdate(adapter.selection, false)
-
-            R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
-            R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
-            R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection)
-            R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
-        }
-        return false
-    }
-
-    private fun upMenu() {
-        menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
-            subMenu.removeGroup(R.id.menu_group)
-            groupList.forEach { bookGroup ->
-                subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
-            }
-        }
-    }
-
-    private fun alertDelSelection() {
-        alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
-            val checkBox = CheckBox(this@BookshelfManageActivity).apply {
-                setText(R.string.delete_book_file)
-                isChecked = LocalConfig.deleteBookOriginal
-            }
-            val view = LinearLayout(this@BookshelfManageActivity).apply {
-                setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
-                addView(checkBox)
-            }
-            customView { view }
-            okButton {
-                LocalConfig.deleteBookOriginal = checkBox.isChecked
-                viewModel.deleteBook(adapter.selection, checkBox.isChecked)
-            }
-            noButton()
-        }
-    }
-
-    override fun selectGroup(requestCode: Int, groupId: Long) {
-        showDialogFragment(
-            GroupSelectDialog(groupId, requestCode)
-        )
-    }
-
-    override fun upGroup(requestCode: Int, groupId: Long) {
-        when (requestCode) {
-            groupRequestCode -> adapter.selection.let { books ->
-                val array = Array(books.size) {
-                    books[it].copy(group = groupId)
-                }
-                viewModel.updateBook(*array)
-            }
-
-            adapter.groupRequestCode -> {
-                adapter.actionItem?.let {
-                    viewModel.updateBook(it.copy(group = groupId))
-                }
-            }
-
-            addToGroupRequestCode -> adapter.selection.let { books ->
-                val array = Array(books.size) { index ->
-                    val book = books[index]
-                    book.copy(group = book.group or groupId)
-                }
-                viewModel.updateBook(*array)
-            }
-        }
-    }
-
-    override fun upSelectCount() {
-        binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size)
-    }
-
-    override fun updateBook(vararg book: Book) {
-        viewModel.updateBook(*book)
-    }
-
-    override fun deleteBook(book: Book) {
-        alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
-            var checkBox: CheckBox? = null
-            if (book.isLocal) {
-                checkBox = CheckBox(this@BookshelfManageActivity).apply {
-                    setText(R.string.delete_book_file)
-                    isChecked = LocalConfig.deleteBookOriginal
-                }
-                val view = LinearLayout(this@BookshelfManageActivity).apply {
-                    setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
-                    addView(checkBox)
-                }
-                customView { view }
-            }
-            okButton {
-                if (checkBox != null) {
-                    LocalConfig.deleteBookOriginal = checkBox.isChecked
-                }
-                viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal)
-            }
-        }
-    }
-
-    override fun openBook(book: Book) {
-        startActivity<BookInfoActivity> {
-            putExtra("name", book.name)
-            putExtra("author", book.author)
-            putExtra("bookUrl", book.bookUrl)
-        }
-    }
-
-    override fun sourceOnClick(source: BookSource) {
-        viewModel.changeSource(adapter.selection, source)
-        viewModel.batchChangeSourceState.value = true
-    }
-
-}

+ 37 - 23
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<BookInfoActivity> {
-                    putExtra("name", effect.name)
-                    putExtra("author", effect.author)
-                    putExtra("bookUrl", effect.bookUrl)
+                is BookshelfManageScreenEffect.OpenBookInfo -> {
+                    onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
                 }
             }
         }
@@ -686,8 +679,17 @@ private fun BookshelfManageScreen(
                 val cacheCount = remember(renderVersion, book.bookUrl) {
                     viewModel.getCacheCount(book.bookUrl) ?: 0
                 }
-                val isDownloading = remember(renderVersion, book.bookUrl) {
-                    viewModel.isBookDownloading(book.bookUrl)
+                val downloadState = state.downloadStates[book.bookUrl]
+                val isPreparingDownload = state.pendingDownloadBookUrls.contains(book.bookUrl)
+                val waitingDownloadCount = downloadState?.waitingCount ?: 0
+                val runningDownloadCount = downloadState?.runningIndices?.size ?: 0
+                val isDownloadingInCacheModel = viewModel.isBookDownloading(book.bookUrl)
+                val isDownloading = isPreparingDownload ||
+                        waitingDownloadCount > 0 ||
+                        runningDownloadCount > 0 ||
+                        isDownloadingInCacheModel
+                val downloadFailureText = state.downloadFailureMessages[book.bookUrl]?.let {
+                    stringResource(R.string.cache_download_failed, it)
                 }
                 val isSelected = selectedBookUrls.contains(book.bookUrl)
                 val exportMsg = remember(renderVersion, book.bookUrl) {
@@ -761,8 +763,20 @@ private fun BookshelfManageScreen(
                             }
                             Row(
                                 modifier = Modifier.fillMaxWidth(),
-                                horizontalArrangement = Arrangement.End
+                                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                                verticalAlignment = Alignment.CenterVertically
                             ) {
+                                if (downloadFailureText != null) {
+                                    AppText(
+                                        text = downloadFailureText,
+                                        modifier = Modifier.weight(1f),
+                                        style = LegadoTheme.typography.labelSmall,
+                                        color = LegadoTheme.colorScheme.error,
+                                        maxLines = 1
+                                    )
+                                } else {
+                                    Spacer(modifier = Modifier.weight(1f))
+                                }
                                 Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                                     SmallTonalIconButton(
                                         onClick = {
@@ -912,7 +926,7 @@ private fun BookshelfManageScreen(
                         oldBookUrl = book.bookUrl,
                         source = source,
                         book = newBook,
-                        chapters = toc,
+                        chapterCount = toc.size,
                     )
                 )
                 manualSearchPreviewBook = null

+ 75 - 10
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<String> = emptySet(),
+    val downloadStates: Map<String, CacheBookDownloadState> = emptyMap(),
+    val downloadFailureMessages: Map<String, String> = emptyMap(),
     val isChangingSource: Boolean = false,
     val changeSourceProgress: String? = null,
     val changeSourceMessage: String? = null,
@@ -118,7 +122,7 @@ sealed interface BookshelfManageScreenIntent {
         val oldBookUrl: String,
         val source: BookSource,
         val book: Book,
-        val chapters: List<BookChapter>,
+        val chapterCount: Int,
     ) : BookshelfManageScreenIntent
     data class AddPreviewItemToShelf(val oldBookUrl: String) : BookshelfManageScreenIntent
     data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : BookshelfManageScreenIntent
@@ -220,7 +224,7 @@ class BookshelfManageScreenViewModel(
                 intent.oldBookUrl,
                 intent.source,
                 intent.book,
-                intent.chapters
+                intent.chapterCount
             )
 
             is BookshelfManageScreenIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl)
@@ -380,8 +384,33 @@ class BookshelfManageScreenViewModel(
             }
         }
         viewModelScope.launch {
-            CacheBook.downloadStateFlow.collect { state ->
-                state.books.keys.forEach { bookUrl ->
+            CacheBook.downloadStateFlow.collect { downloadState ->
+                _uiState.update { state ->
+                    val successfulBookUrls = downloadState.books
+                        .filterValues {
+                            it.successCount > 0 &&
+                                    it.failedIndices.isEmpty() &&
+                                    it.failureMessage == null
+                        }
+                        .keys
+                    val failureMessages = downloadState.books.mapNotNull { (bookUrl, bookState) ->
+                        val message = bookState.failureMessage ?: if (bookState.failedIndices.isNotEmpty()) {
+                            "${bookState.failedIndices.size} 章"
+                        } else {
+                            null
+                        }
+                        message?.let { bookUrl to it }
+                    }.toMap()
+                    state.copy(
+                        isDownloadRunning = downloadState.isRunning,
+                        pendingDownloadBookUrls = state.pendingDownloadBookUrls - downloadState.books.keys,
+                        downloadStates = downloadState.books,
+                        downloadFailureMessages = (
+                                state.downloadFailureMessages - successfulBookUrls
+                                ) + failureMessages,
+                    )
+                }
+                downloadState.books.keys.forEach { bookUrl ->
                     scheduleDownloadStatusRefresh(bookUrl)
                 }
                 scheduleDownloadStatusRefresh()
@@ -509,9 +538,13 @@ class BookshelfManageScreenViewModel(
     }
 
     private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
+        val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
+        _uiState.update {
+            it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
+        }
         execute {
             batchCacheDownloadUseCase.execute(
-                bookUrls = books.map { it.bookUrl }.toSet(),
+                bookUrls = bookUrls,
                 downloadAllChapters = downloadAllChapters,
                 skipAudioBooks = true
             )
@@ -522,12 +555,41 @@ class BookshelfManageScreenViewModel(
 
     private fun toggleBookDownload(book: Book) {
         if (book.isLocal) return
-        if (isBookDownloading(book.bookUrl)) {
+        if (isBookDownloading(book.bookUrl) || uiState.value.pendingDownloadBookUrls.contains(book.bookUrl)) {
             CacheBook.remove(context, book.bookUrl)
+            _uiState.update {
+                it.copy(pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl)
+            }
             syncDownloadRunning()
         } else {
+            _uiState.update {
+                it.copy(
+                    pendingDownloadBookUrls = it.pendingDownloadBookUrls + book.bookUrl,
+                    downloadFailureMessages = it.downloadFailureMessages - book.bookUrl
+                )
+            }
             execute {
                 cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
+            }.onSuccess { count ->
+                if (count <= 0) {
+                    _uiState.update {
+                        it.copy(
+                            pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
+                            downloadFailureMessages = it.downloadFailureMessages +
+                                    (book.bookUrl to "没有可缓存的章节")
+                        )
+                    }
+                    _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("没有可缓存的章节"))
+                }
+            }.onError { error ->
+                _uiState.update {
+                    it.copy(
+                        pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
+                        downloadFailureMessages = it.downloadFailureMessages +
+                                (book.bookUrl to (error.localizedMessage ?: "未知错误"))
+                    )
+                }
+                _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存失败\n${error.localizedMessage}"))
             }.onFinally {
                 syncDownloadRunning()
             }
@@ -599,6 +661,9 @@ class BookshelfManageScreenViewModel(
 
     private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
         if (bookUrls.isEmpty()) return
+        _uiState.update {
+            it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
+        }
         execute {
             batchCacheDownloadUseCase.execute(
                 bookUrls = bookUrls,
@@ -652,7 +717,7 @@ class BookshelfManageScreenViewModel(
             return
         }
         execute {
-            val concurrency = OtherConfig.threadCount.coerceIn(1, 4)
+            val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
             _uiState.update {
                 it.copy(
                     isChangingSource = true,
@@ -775,19 +840,19 @@ class BookshelfManageScreenViewModel(
         oldBookUrl: String,
         source: BookSource,
         book: Book,
-        chapters: List<BookChapter>,
+        chapterCount: Int,
     ) {
         _uiState.update { state ->
             state.copy(
                 batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
                     if (item.oldBook.bookUrl == oldBookUrl) {
-                        book.totalChapterNum = chapters.size
+                        book.totalChapterNum = chapterCount
                         item.copy(
                             candidates = listOf(
                                 BatchChangeSourceCandidate(
                                     source = source,
                                     book = book,
-                                    chapterCount = chapters.size
+                                    chapterCount = chapterCount
                                 )
                             ) +
                                     item.candidates,

+ 0 - 135
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageViewModel.kt

@@ -1,135 +0,0 @@
-package io.legado.app.ui.book.manage
-
-import android.app.Application
-import androidx.lifecycle.MutableLiveData
-import io.legado.app.R
-import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.BookType
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookSource
-import io.legado.app.help.book.BookHelp
-import io.legado.app.help.book.isLocal
-import io.legado.app.help.book.removeType
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.model.SourceCallBack
-import io.legado.app.model.localBook.LocalBook
-import io.legado.app.model.webBook.WebBook
-import io.legado.app.utils.FileUtils
-import io.legado.app.utils.GSON
-import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.writeToOutputStream
-import kotlinx.coroutines.delay
-import java.io.File
-
-
-class BookshelfManageViewModel(application: Application) : BaseViewModel(application) {
-    var groupId: Long = -1L
-    var groupName: String? = null
-    val batchChangeSourceState = MutableLiveData<Boolean>()
-    val batchChangeSourceProcessLiveData = MutableLiveData<String>()
-    var batchChangeSourceCoroutine: Coroutine<Unit>? = null
-
-    fun upCanUpdate(books: List<Book>, canUpdate: Boolean) {
-        execute {
-            val array = Array(books.size) {
-                books[it].copy(canUpdate = canUpdate).apply {
-                    if (!canUpdate) {
-                        removeType(BookType.updateError)
-                    }
-                }
-            }
-            appDb.bookDao.update(*array)
-        }
-    }
-
-    fun updateBook(vararg book: Book) {
-        execute {
-            appDb.bookDao.update(*book)
-        }
-    }
-
-    fun deleteBook(books: List<Book>, deleteOriginal: Boolean = false) {
-        execute {
-            books.forEach {
-                if (it.isLocal) {
-                    LocalBook.deleteBook(it, deleteOriginal)
-                } else {
-                    val source = appDb.bookSourceDao.getBookSource(it.origin)
-                    SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, it)
-                }
-                appDb.bookChapterDao.delByBook(it.bookUrl)
-            }
-            appDb.bookDao.delete(*books.toTypedArray())
-        }
-    }
-
-    fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) {
-        execute {
-            val path = "${context.filesDir}/shareBookSource.json"
-            FileUtils.delete(path)
-            val file = FileUtils.createFileWithReplace(path)
-            val sources = appDb.bookDao.getAllUseBookSource()
-            file.outputStream().buffered().use {
-                GSON.writeToOutputStream(it, sources)
-            }
-            file
-        }.onSuccess {
-            success.invoke(it)
-        }.onError {
-            context.toastOnUi(it.stackTraceStr)
-        }
-    }
-
-    fun changeSource(books: List<Book>, source: BookSource) {
-        batchChangeSourceCoroutine?.cancel()
-        batchChangeSourceCoroutine = execute {
-            val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L
-            books.forEachIndexed { index, book ->
-                batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}")
-                if (book.isLocal) return@forEachIndexed
-                if (book.origin == source.bookSourceUrl) return@forEachIndexed
-                val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
-                    .onFailure {
-                        AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
-                    }.getOrNull() ?: return@forEachIndexed
-                kotlin.runCatching {
-                    if (newBook.tocUrl.isEmpty()) {
-                        WebBook.getBookInfoAwait(source, newBook)
-                    }
-                }.onFailure {
-                    AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
-                    return@forEachIndexed
-                }
-                WebBook.getChapterListAwait(source, newBook)
-                    .onFailure {
-                        AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
-                    }.getOrNull()?.let { toc ->
-                        book.migrateTo(newBook, toc)
-                        book.removeType(BookType.updateError)
-                        appDb.bookDao.insert(newBook)
-                        appDb.bookChapterDao.insert(*toc.toTypedArray())
-                    }
-                delay(changeSourceDelay)
-            }
-        }.onStart {
-            batchChangeSourceState.postValue(true)
-        }.onFinally {
-            batchChangeSourceState.postValue(false)
-        }
-    }
-
-    fun clearCache(books: List<Book>) {
-        execute {
-            books.forEach {
-                BookHelp.clearCache(it)
-            }
-        }.onSuccess {
-            context.toastOnUi(R.string.clear_cache_success)
-        }
-    }
-
-}

+ 0 - 160
app/src/main/java/io/legado/app/ui/book/manage/SourcePickerDialog.kt

@@ -1,160 +0,0 @@
-package io.legado.app.ui.book.manage
-
-import android.content.Context
-import android.os.Bundle
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.appcompat.widget.SearchView
-import androidx.appcompat.widget.Toolbar
-import androidx.core.view.setPadding
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import io.legado.app.R
-import io.legado.app.base.BaseDialogFragment
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.BookSourcePart
-import io.legado.app.databinding.DialogSourcePickerBinding
-import io.legado.app.databinding.Item1lineTextBinding
-import io.legado.app.help.config.AppConfig
-//import io.legado.app.lib.theme.primaryColor
-//import io.legado.app.lib.theme.primaryTextColor
-import io.legado.app.ui.widget.number.NumberPickerDialog
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.setLayout
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
-import splitties.views.onClick
-
-/**
- * 书源选择
- */
-class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker),
-    Toolbar.OnMenuItemClickListener {
-
-    private val binding by viewBinding(DialogSourcePickerBinding::bind)
-    private val searchView: SearchView by lazy {
-        binding.toolBar.findViewById(R.id.search_view)
-    }
-    private val toolBar: Toolbar by lazy {
-        binding.toolBar.toolbar
-    }
-    private val adapter by lazy {
-        SourceAdapter(requireContext())
-    }
-    private var sourceFlowJob: Job? = null
-
-    override fun onStart() {
-        super.onStart()
-        setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        initView()
-        initData()
-        initMenu()
-    }
-
-    private fun initView() {
-        //binding.toolBar.setBackgroundColor(primaryColor)
-        binding.toolBar.title = "选择书源"
-        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
-        binding.recyclerView.adapter = adapter
-        //searchView.applyTint(primaryTextColor)
-        searchView.isSubmitButtonEnabled = true
-        searchView.queryHint = getString(R.string.search_book_source)
-        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-            override fun onQueryTextSubmit(query: String?): Boolean {
-                return false
-            }
-
-            override fun onQueryTextChange(newText: String?): Boolean {
-                initData(newText)
-                return false
-            }
-        })
-    }
-
-    private fun initData(searchKey: String? = null) {
-        sourceFlowJob?.cancel()
-        sourceFlowJob = lifecycleScope.launch {
-            when {
-                searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
-                else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
-            }.catch {
-                AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it)
-            }.flowOn(IO).collect {
-                adapter.setItems(it)
-            }
-        }
-    }
-
-    private fun initMenu() {
-        toolBar.setOnMenuItemClickListener(this)
-        toolBar.inflateMenu(R.menu.source_picker)
-        //toolBar.menu.applyTint(requireContext())
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_change_source_delay -> NumberPickerDialog(requireContext())
-                .setTitle(getString(R.string.change_source_delay))
-                .setMaxValue(9999)
-                .setMinValue(0)
-                .setValue(AppConfig.batchChangeSourceDelay)
-                .show {
-                    AppConfig.batchChangeSourceDelay = it
-                }
-        }
-        return true
-    }
-
-    inner class SourceAdapter(context: Context) :
-        RecyclerAdapter<BookSourcePart, Item1lineTextBinding>(context) {
-
-        override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
-            return Item1lineTextBinding.inflate(inflater, parent, false).apply {
-                root.setPadding(16.dpToPx())
-            }
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: Item1lineTextBinding,
-            item: BookSourcePart,
-            payloads: MutableList<Any>
-        ) {
-            binding.textView.text = item.getDisPlayNameGroup()
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
-            binding.root.onClick {
-                getItemByLayoutPosition(holder.layoutPosition)?.let {
-                    it.getBookSource()?.let { source ->
-                        callback?.sourceOnClick(source)
-                    }
-                    dismissAllowingStateLoss()
-                }
-            }
-        }
-
-    }
-
-    private val callback: Callback?
-        get() {
-            return (parentFragment as? Callback) ?: activity as? Callback
-        }
-
-    interface Callback {
-        fun sourceOnClick(source: BookSource)
-    }
-
-}

+ 9 - 6
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<BookInfoActivity> {
-                    putExtra("name", name)
-                    putExtra("author", author)
-                    putExtra("bookUrl", bookUrl)
-                }
+                startActivity(
+                    MainActivity.createBookInfoIntent(
+                        context = this,
+                        name = name,
+                        author = author,
+                        bookUrl = bookUrl
+                    )
+                )
             },
             onOpenSourceManage = {
                 startActivity<BookSourceActivity>()

+ 11 - 4
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(

+ 176 - 8
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<MainViewModel>()
     private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
+    private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
 
     @Serializable
     private sealed interface MainRoute : NavKey
@@ -253,6 +293,20 @@ open class MainActivity : BaseComposeActivity() {
         val scopeRaw: String? = null
     ) : MainRoute
 
+    @Serializable
+    private data class MainRouteBookInfo(
+        val name: String?,
+        val author: String?,
+        val bookUrl: String,
+    ) : MainRoute
+
+    @Serializable
+    private data class MainRouteExploreShow(
+        val title: String?,
+        val sourceUrl: String,
+        val exploreUrl: String?,
+    ) : MainRoute
+
     override fun onCreate(savedInstanceState: Bundle?) {
         installSplashScreen()
         super.onCreate(savedInstanceState)
@@ -414,6 +468,26 @@ open class MainActivity : BaseComposeActivity() {
                         onNavigateToBookCacheManage = {
                             navigateToRoute(backStack, MainRouteBookCacheManage)
                         },
+                        onNavigateToBookInfo = { name, author, bookUrl ->
+                            navigateToRoute(
+                                backStack,
+                                MainRouteBookInfo(
+                                    name = name,
+                                    author = author,
+                                    bookUrl = bookUrl
+                                )
+                            )
+                        },
+                        onNavigateToExploreShow = { title, sourceUrl, exploreUrl ->
+                            navigateToRoute(
+                                backStack,
+                                MainRouteExploreShow(
+                                    title = title,
+                                    sourceUrl = sourceUrl,
+                                    exploreUrl = exploreUrl
+                                )
+                            )
+                        },
                         onNavigateToRssSort = { sourceUrl, sortUrl, key ->
                             navigateToRoute(
                                 backStack,
@@ -484,7 +558,17 @@ open class MainActivity : BaseComposeActivity() {
                 entry<MainRouteCache> { route ->
                     BookshelfManageRouteScreen(
                         groupId = route.groupId,
-                        onBackClick = { navigateBack(backStack) }
+                        onBackClick = { navigateBack(backStack) },
+                        onOpenBookInfo = { name, author, bookUrl ->
+                            navigateToRoute(
+                                backStack,
+                                MainRouteBookInfo(
+                                    name = name,
+                                    author = author,
+                                    bookUrl = bookUrl
+                                )
+                            )
+                        }
                     )
                 }
 
@@ -533,11 +617,14 @@ open class MainActivity : BaseComposeActivity() {
                         viewModel = searchViewModel,
                         onBack = { navigateBack(backStack) },
                         onOpenBookInfo = { name, author, bookUrl ->
-                            this@MainActivity.startActivity<BookInfoActivity> {
-                                putExtra("name", name)
-                                putExtra("author", author)
-                                putExtra("bookUrl", bookUrl)
-                            }
+                            navigateToRoute(
+                                backStack,
+                                MainRouteBookInfo(
+                                    name = name,
+                                    author = author,
+                                    bookUrl = bookUrl
+                                )
+                            )
                         },
                         onOpenSourceManage = {
                             this@MainActivity.startActivity<BookSourceActivity>()
@@ -573,6 +660,41 @@ open class MainActivity : BaseComposeActivity() {
                         onBackClick = { navigateBack(backStack) }
                     )
                 }
+
+                entry<MainRouteBookInfo> { route ->
+                    val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
+                    BookInfoRouteScreen(
+                        bookUrl = route.bookUrl,
+                        viewModel = bookInfoViewModel,
+                        onBack = { navigateBack(backStack) },
+                        onFinish = { _, _ -> navigateBack(backStack) },
+                        onOpenSearch = { keyword ->
+                            navigateToRoute(backStack, MainRouteSearch(key = keyword))
+                        },
+                        onRegisterVariableSetter = { setter ->
+                            bookInfoVariableSetter = setter
+                        }
+                    )
+                }
+
+                entry<MainRouteExploreShow> { route ->
+                    ExploreShowScreen(
+                        title = route.title ?: "探索",
+                        sourceUrl = route.sourceUrl,
+                        exploreUrl = route.exploreUrl,
+                        onBack = { navigateBack(backStack) },
+                        onBookClick = { book ->
+                            navigateToRoute(
+                                backStack,
+                                MainRouteBookInfo(
+                                    name = book.name,
+                                    author = book.author,
+                                    bookUrl = book.bookUrl
+                                )
+                            )
+                        }
+                    )
+                }
             }
         )
     }
@@ -631,6 +753,30 @@ open class MainActivity : BaseComposeActivity() {
                 }
             }
 
+            is MainRouteBookInfo -> {
+                if (
+                    currentRoute == MainRouteHome ||
+                    currentRoute is MainRouteSearch ||
+                    currentRoute is MainRouteExploreShow
+                ) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
+
+            is MainRouteExploreShow -> {
+                if (currentRoute == MainRouteHome) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
+
             is MainRouteRssSort -> {
                 if (currentRoute == MainRouteHome) {
                     backStack.add(route)
@@ -827,10 +973,32 @@ open class MainActivity : BaseComposeActivity() {
                 key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
                 scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE)
             )
+            ROUTE_BOOK_INFO -> intent?.getStringExtra(EXTRA_BOOK_URL)
+                ?.takeIf { it.isNotBlank() }
+                ?.let { bookUrl ->
+                    MainRouteBookInfo(
+                        name = intent.getStringExtra(EXTRA_BOOK_NAME),
+                        author = intent.getStringExtra(EXTRA_BOOK_AUTHOR),
+                        bookUrl = bookUrl
+                    )
+                } ?: MainRouteHome
+            ROUTE_EXPLORE_SHOW -> intent?.getStringExtra(EXTRA_SOURCE_URL)
+                ?.takeIf { it.isNotBlank() }
+                ?.let { sourceUrl ->
+                    MainRouteExploreShow(
+                        title = intent.getStringExtra(EXTRA_EXPLORE_NAME),
+                        sourceUrl = sourceUrl,
+                        exploreUrl = intent.getStringExtra(EXTRA_EXPLORE_URL)
+                    )
+                } ?: MainRouteHome
             else -> MainRouteHome
         }
     }
 
+    override fun setVariable(key: String, variable: String?) {
+        bookInfoVariableSetter?.invoke(key, variable)
+    }
+
 }
 
 class LauncherW : MainActivity()

+ 6 - 8
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<BookInfoActivity> {
-                                    putExtra("name", book.name)
-                                    putExtra("author", book.author)
-                                    putExtra("bookUrl", book.bookUrl)
-                                }
+                                onNavigateToBookInfo(book.name, book.author, book.bookUrl)
                             },
                             onNavigateToSearch = { query -> onNavigateToSearch(query) },
                             onNavigateToRemoteImport = onNavigateToRemoteImport,
@@ -386,7 +382,9 @@ fun MainScreen(
                             onNavigateToCache = onNavigateToCache
                         )
 
-                        MainDestination.Explore -> ExploreScreen()
+                        MainDestination.Explore -> ExploreScreen(
+                            onOpenExploreShow = onNavigateToExploreShow
+                        )
                         MainDestination.Rss -> RssScreen(
                             onOpenSort = { sourceUrl, sortUrl, key ->
                                 onNavigateToRssSort(sourceUrl, sortUrl, key)

+ 2 - 2
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<String>()
     private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
@@ -495,7 +495,7 @@ class BookshelfViewModel(
 
     private fun upPool() {
         threadCount = AppConfig.threadCount
-        val newPoolSize = min(threadCount, AppConst.MAX_THREAD)
+        val newPoolSize = threadCount
         if (poolSize == newPoolSize) return
         poolSize = newPoolSize
         upTocPool.close()

+ 3 - 7
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<ExploreShowActivity> {
-                                                putExtra("exploreName", kind.title)
-                                                putExtra("sourceUrl", item.bookSourceUrl)
-                                                putExtra("exploreUrl", url)
-                                            }
+                                            onOpenExploreShow(kind.title, item.bookSourceUrl, url)
                                         },
                                         onRefreshKinds = { viewModel.refreshExploreKinds(item) },
                                         modifier = Modifier.weight(span.toFloat()),

+ 8 - 6
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<ExploreShowActivity> {
-                            putExtra("exploreName", title)
-                            putExtra("sourceUrl", sourceUrl)
-                            putExtra("exploreUrl", url)
-                        }
+                        activity.startActivity(
+                            MainActivity.createExploreShowIntent(
+                                context = activity,
+                                exploreName = title,
+                                sourceUrl = sourceUrl,
+                                exploreUrl = url
+                            )
+                        )
                     }
                 }
             }

+ 3 - 1
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

+ 0 - 31
app/src/main/res/layout/activity_arrange_book.xml

@@ -1,31 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:contentInsetStartWithNavigation="0dp"
-        app:contentLayout="@layout/view_search"
-        app:layout_constraintTop_toTopOf="parent"
-        app:title="@string/bookshelf_management" />
-
-    <io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
-        android:id="@+id/recycler_view"
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:scrollbars="none"
-        app:layout_constraintBottom_toTopOf="@id/select_action_bar"
-        app:layout_constraintTop_toBottomOf="@id/title_bar" />
-
-    <io.legado.app.ui.widget.SelectActionBar
-        android:id="@+id/select_action_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        app:layout_constraintBottom_toBottomOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 98
app/src/main/res/layout/dialog_source_picker.xml

@@ -1,98 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/tool_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:theme="?attr/actionBarStyle"
-        app:attachToActivity="false"
-        app:contentLayout="@layout/view_search"
-        app:fitStatusBar="false"
-
-        tools:ignore="RtlHardcoded,RtlSymmetry" />
-
-    <FrameLayout
-        android:layout_width="match_parent"
-        android:layout_height="0dp"
-        android:layout_weight="1">
-
-        <io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
-            android:id="@+id/recycler_view"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:scrollbars="none"
-            tools:ignore="SpeakableTextPresentCheck" />
-
-        <com.google.android.material.loadingindicator.LoadingIndicator
-            android:id="@+id/rotate_loading"
-            android:layout_width="36dp"
-            android:layout_height="36dp"
-            android:layout_gravity="center"
-            android:layout_margin="6dp"
-            android:visibility="gone"
-            app:loading_width="2dp" />
-
-        <TextView
-            android:id="@+id/tv_msg"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:padding="16dp"
-            android:textColor="@color/secondaryText"
-            android:visibility="gone" />
-
-    </FrameLayout>
-
-    <com.google.android.flexbox.FlexboxLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:paddingLeft="12dp"
-        android:paddingRight="12dp"
-        app:flexWrap="wrap"
-        app:justifyContent="space_between">
-
-        <io.legado.app.ui.widget.text.AccentTextView
-            android:id="@+id/tv_footer_left"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:padding="12dp"
-            android:visibility="gone"
-            tools:ignore="RtlHardcoded" />
-
-        <Space
-            android:layout_width="0dp"
-            android:layout_height="0dp" />
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal">
-
-            <io.legado.app.ui.widget.text.AccentTextView
-                android:id="@+id/tv_cancel"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:padding="12dp"
-                android:text="@string/cancel"
-                android:visibility="gone"
-                tools:ignore="RtlHardcoded" />
-
-            <io.legado.app.ui.widget.text.AccentTextView
-                android:id="@+id/tv_ok"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:padding="12dp"
-                android:text="@string/ok"
-                android:visibility="gone"
-                tools:ignore="RtlHardcoded" />
-
-        </LinearLayout>
-
-    </com.google.android.flexbox.FlexboxLayout>
-</LinearLayout>

+ 0 - 94
app/src/main/res/layout/item_arrange_book.xml

@@ -1,94 +0,0 @@
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:background="?attr/selectableItemBackground"
-    android:paddingStart="4dp"
-    android:paddingEnd="16dp"
-    android:paddingTop="2dp"
-    android:paddingBottom="8dp">
-
-    <com.google.android.material.checkbox.MaterialCheckBox
-        android:id="@+id/checkbox"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="4dp"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <TextView
-        android:id="@+id/tv_name"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:paddingBottom="2dp"
-        android:singleLine="true"
-        android:text="@string/book_name"
-        style="@style/ChapterNameText"
-        app:layout_constraintStart_toEndOf="@id/checkbox"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="@id/checkbox"
-        app:layout_constraintBottom_toBottomOf="@id/checkbox"/>
-
-    <TextView
-        android:id="@+id/tv_author"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="4dp"
-        android:singleLine="true"
-        android:text="@string/author"
-        android:textSize="12sp"
-        tools:ignore="RtlHardcoded"
-        app:layout_constraintStart_toStartOf="@id/tv_name"
-        app:layout_constraintEnd_toStartOf="@id/tv_group"
-        app:layout_constraintTop_toBottomOf="@id/tv_name" />
-
-    <TextView
-        android:id="@+id/tv_origin"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="4dp"
-        android:textSize="12sp"
-        tools:text="@string/book_source"
-        app:layout_constraintStart_toStartOf="@id/tv_name"
-        app:layout_constraintTop_toBottomOf="@id/tv_author" />
-
-    <TextView
-        android:id="@+id/tv_group_s"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:text="@string/ungroup"
-        android:layout_marginStart="8dp"
-        android:singleLine="true"
-        android:textSize="12sp"
-        app:layout_constraintStart_toEndOf="@id/tv_origin"
-        app:layout_constraintEnd_toStartOf="@id/tv_group"
-        app:layout_constraintBaseline_toBaselineOf="@id/tv_origin" />
-
-    <com.google.android.material.button.MaterialButton
-        android:id="@+id/tv_group"
-        android:layout_width="wrap_content"
-        android:layout_height="40dp"
-        style="@style/Widget.Material3Expressive.Button.TonalButton"
-        android:textSize="12sp"
-        android:layout_marginEnd="4dp"
-        android:minWidth="0dp"
-        android:padding="0dp"
-        android:text="@string/group"
-        app:layout_constraintTop_toTopOf="@id/tv_group_s"
-        app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
-        app:layout_constraintEnd_toStartOf="@id/tv_delete" />
-
-    <com.google.android.material.button.MaterialButton
-        android:id="@+id/tv_delete"
-        android:layout_width="40dp"
-        android:layout_height="40dp"
-        style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-        app:icon="@drawable/ic_clear_all"
-        app:iconSize="20dp"
-        app:iconGravity="textStart"
-        app:layout_constraintTop_toTopOf="@id/tv_group_s"
-        app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
-        app:layout_constraintEnd_toEndOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 32
app/src/main/res/menu/bookshelf_manage.xml

@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_book_group"
-        android:icon="@drawable/ic_groups"
-        android:title="@string/group"
-        app:showAsAction="always">
-
-        <menu>
-
-            <item
-                android:id="@+id/menu_group_manage"
-                android:title="@string/group_manage" />
-
-        </menu>
-
-    </item>
-
-    <item
-        android:id="@+id/menu_export_all_use_book_source"
-        android:title="@string/export_all_use_book_source" />
-
-    <item
-        android:id="@+id/menu_open_book_info_by_click_title"
-        android:checkable="true"
-        android:title="@string/open_book_info_by_click_title" />
-
-</menu>

+ 0 - 32
app/src/main/res/menu/bookshelf_menage_sel.xml

@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android">
-
-    <item
-        android:id="@+id/menu_del_selection"
-        android:title="@string/delete" />
-
-    <item
-        android:id="@+id/menu_update_enable"
-        android:title="@string/allow_update" />
-
-    <item
-        android:id="@+id/menu_update_disable"
-        android:title="@string/disable_update" />
-
-    <item
-        android:id="@+id/menu_add_to_group"
-        android:title="@string/add_to_group" />
-
-    <item
-        android:id="@+id/menu_change_source"
-        android:title="@string/change_source_batch" />
-
-    <item
-        android:id="@+id/menu_clear_cache"
-        android:title="@string/clear_cache" />
-
-    <item
-        android:id="@+id/menu_check_selected_interval"
-        android:title="@string/check_selected_interval" />
-
-</menu>

+ 0 - 8
app/src/main/res/menu/source_picker.xml

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android">
-
-    <item
-        android:id="@+id/menu_change_source_delay"
-        android:title="@string/change_source_delay" />
-
-</menu>

+ 7 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -243,10 +243,12 @@
     <string name="download_cancel">取消下载</string>
     <string name="no_download">暂无任务</string>
     <string name="download_count">%1$d/%2$d</string>
+    <string name="cache_download_failed">缓存失败:%1$s</string>
     <string name="import_select_book">导入选择书籍</string>
-    <string name="threads_num_title">更新和搜索线程数</string>
-    <string name="cache_book_threads_num_title">书籍缓存下载线程数</string>
-    <string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量</string>
+    <string name="threads_num_title">更新和搜索线程</string>
+    <string name="threads_num_summary">控制更新和搜索线程的并发数量。数值越高越不稳定</string>
+    <string name="cache_book_threads_num_title">书籍缓存下载线程</string>
+    <string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量。数值越高越不稳定</string>
     <string name="change_icon">切换图标</string>
     <string name="remove_from_bookshelf">已在书架</string>
     <string name="already_in_bookshelf">已在书架</string>
@@ -910,6 +912,8 @@
     <string name="style">样式</string>
     <string name="group_style">分组样式</string>
     <string name="export_file_name">导出文件名</string>
+    <string name="export_file_name_template_hint">{name} - {author}</string>
+    <string name="export_file_name_template_help">可用变量:{name} 书名、{author} 作者、{group} 分组、{source} 书源、{remark} 备注。\n\n示例:{name} - {author}\n输出:三体 - 刘慈欣\n\n变量前后可以添加固定文字。</string>
     <string name="reset">重置</string>
     <string name="null_url">URL 为空</string>
     <string name="dict">字典</string>

+ 5 - 1
app/src/main/res/values/strings.xml

@@ -246,10 +246,12 @@
     <string name="download_cancel">Download cancel</string>
     <string name="no_download">No download</string>
     <string name="download_count">Downloaded %1$d/%2$d</string>
+    <string name="cache_download_failed">Cache failed: %1$s</string>
     <string name="import_select_book">Import selected book(s)</string>
     <string name="threads_num_title">Number of Concurrent Tasks</string>
+    <string name="threads_num_summary">Controls concurrent download tasks. Higher values increase OOM risk</string>
     <string name="cache_book_threads_num_title">Cache Download Threads</string>
-    <string name="cache_book_threads_num_summary">Controls concurrent chapter downloads for offline book cache</string>
+    <string name="cache_book_threads_num_summary">Controls concurrent chapter downloads for offline book cache. Higher values increase OOM risk</string>
     <string name="change_icon">Change Icon</string>
     <string name="remove_from_bookshelf">In library</string>
     <string name="already_in_bookshelf">Already in Bookshelf</string>
@@ -919,6 +921,8 @@
     <string name="style">Style</string>
     <string name="group_style">Group style</string>
     <string name="export_file_name">Export file name</string>
+    <string name="export_file_name_template_hint">{name} - {author}</string>
+    <string name="export_file_name_template_help">Available variables: {name} title, {author} author, {group} group, {source} source, {remark} remark.\n\nExample: {name} - {author}\nResult: The Three-Body Problem - Liu Cixin\n\nYou can add fixed text before or after variables.</string>
     <string name="reset">Reset</string>
     <string name="null_url">Null URL</string>
     <string name="dict">dict</string>

+ 15 - 10
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
 
@@ -37,25 +38,29 @@ class CacheDownloadStateStoreTest {
     }
 
     @Test
-    fun duplicateSuccessDoesNotInflateCount() {
+    fun removeBookRecalculatesRunningState() {
         val store = CacheDownloadStateStore()
 
-        store.markSuccess("a", 1)
-        store.markSuccess("a", 1)
+        store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
+        store.removeBook("a")
 
-        assertEquals(1, store.state.books.getValue("a").successCount)
-        assertEquals(1, store.state.totalSuccess)
+        assertFalse(store.state.isRunning)
+        assertEquals(0, store.state.totalWaiting)
+        assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
     }
 
     @Test
-    fun removeBookRecalculatesRunningState() {
+    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())
-        store.removeBook("a")
 
-        assertFalse(store.state.isRunning)
-        assertEquals(0, store.state.totalWaiting)
-        assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
+        assertNull(store.state.books.getValue("a").failureMessage)
+        assertEquals(0, store.state.totalFailure)
     }
 }