[优化] 一些代码优化
This commit is contained in:
@@ -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.
|
||||
@@ -243,11 +243,6 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTop" />
|
||||
<!-- 书籍管理 -->
|
||||
<activity
|
||||
android:name=".ui.book.manage.BookshelfManageActivity"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:launchMode="singleTop" />
|
||||
<!-- 书源调试 -->
|
||||
<activity
|
||||
android:name=".ui.book.source.debug.BookSourceDebugActivity"
|
||||
@@ -263,11 +258,6 @@
|
||||
android:name=".ui.book.searchContent.SearchContentActivity"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:launchMode="singleTop" />
|
||||
<!-- 发现界面 -->
|
||||
<activity
|
||||
android:name=".ui.book.explore.ExploreShowActivity"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:launchMode="singleTop" />
|
||||
<!-- 订阅源调试 -->
|
||||
<activity
|
||||
android:name=".ui.rss.source.debug.RssSourceDebugActivity"
|
||||
|
||||
@@ -32,7 +32,6 @@ data class ChangeSourceMigrationOptions(
|
||||
data class ChangeBookSourceResult(
|
||||
val oldBookUrl: String,
|
||||
val book: Book,
|
||||
val chapters: List<BookChapter>,
|
||||
)
|
||||
|
||||
data class BatchChangeBookSourceResult(
|
||||
@@ -102,7 +101,7 @@ class ChangeBookSourceUseCase(
|
||||
bookChapterDao.insert(*chapters.toTypedArray())
|
||||
ReadBook.onChapterListUpdated(newBook)
|
||||
}
|
||||
return ChangeBookSourceResult(oldBookUrl, newBook, chapters)
|
||||
return ChangeBookSourceResult(oldBookUrl, newBook)
|
||||
}
|
||||
|
||||
suspend fun batchChangeTo(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.legado.app.domain.usecase
|
||||
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.data.entities.SearchBook
|
||||
@@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.min
|
||||
|
||||
data class BookSearchRequest(
|
||||
val keyword: String,
|
||||
@@ -83,7 +81,7 @@ class SearchBooksUseCase(
|
||||
}
|
||||
|
||||
val merger = SearchResultMerger(keyword, request.precision)
|
||||
val concurrency = min(request.concurrency.coerceAtLeast(1), AppConst.MAX_THREAD)
|
||||
val concurrency = request.concurrency.coerceAtLeast(1)
|
||||
var hasMore = false
|
||||
var processedSources = 0
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.JaccardSimilarity
|
||||
import splitties.init.appCtx
|
||||
@@ -48,6 +49,7 @@ import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
import java.util.zip.ZipFile
|
||||
@@ -62,6 +64,8 @@ object BookHelp {
|
||||
private const val cacheImageFolderName = "images"
|
||||
private const val cacheEpubFolderName = "epub"
|
||||
private val downloadImages = ConcurrentHashMap<String, Mutex>()
|
||||
private val imageDownloadSlots = Semaphore(2)
|
||||
private val imageDecodeSlots = Semaphore(1)
|
||||
|
||||
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
|
||||
|
||||
@@ -299,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
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取章节内容
|
||||
*/
|
||||
|
||||
@@ -44,15 +44,23 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
object CacheBook {
|
||||
|
||||
const val maxDownloadConcurrency = 8
|
||||
|
||||
private data class QueueStats(
|
||||
val waitingCount: Int,
|
||||
val downloadingCount: Int
|
||||
)
|
||||
|
||||
private data class ChapterKey(
|
||||
val bookUrl: String,
|
||||
val index: Int,
|
||||
)
|
||||
|
||||
private class CacheBookCoordinator {
|
||||
val taskMap = ConcurrentHashMap<String, CacheBookModel>()
|
||||
private val processMutex = Mutex()
|
||||
@@ -80,7 +88,7 @@ object CacheBook {
|
||||
}
|
||||
}.onStart {
|
||||
updateSummary()
|
||||
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) {
|
||||
}.onEachParallel(OtherConfig.cacheBookThreadCount.coerceIn(1, maxDownloadConcurrency)) {
|
||||
coroutineScope {
|
||||
it.download(this, context)
|
||||
}
|
||||
@@ -92,6 +100,8 @@ object CacheBook {
|
||||
|
||||
private val coordinator = CacheBookCoordinator()
|
||||
private val stateStore = CacheDownloadStateStore()
|
||||
private val pendingRequests = ConcurrentHashMap<Long, CacheDownloadRequest>()
|
||||
private val pendingRequestId = AtomicLong(0)
|
||||
val downloadStateFlow = stateStore.stateFlow
|
||||
|
||||
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
|
||||
@@ -113,9 +123,9 @@ object CacheBook {
|
||||
@Volatile
|
||||
private var lastQueueStats = QueueStats(0, 0)
|
||||
|
||||
val successDownloadSet = ConcurrentHashMap.newKeySet<String>()
|
||||
val errorDownloadMap = ConcurrentHashMap<String, Int>()
|
||||
private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>()
|
||||
@Volatile
|
||||
private var successDownloadCount = 0
|
||||
private val errorRetryMap = ConcurrentHashMap<ChapterKey, Int>()
|
||||
|
||||
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
|
||||
get() = coordinator.taskMap
|
||||
@@ -124,6 +134,12 @@ object CacheBook {
|
||||
return stateStore.bookState(bookUrl)?.failedIndices.orEmpty()
|
||||
}
|
||||
|
||||
fun markBookFailed(bookUrl: String, message: String) {
|
||||
stateStore.markBookFailed(bookUrl, message)
|
||||
updateSummary()
|
||||
_queueChangedFlow.tryEmit(bookUrl)
|
||||
}
|
||||
|
||||
private fun collectQueueStats(): QueueStats {
|
||||
var waiting = 0
|
||||
var downloading = 0
|
||||
@@ -139,7 +155,7 @@ object CacheBook {
|
||||
val stats = collectQueueStats()
|
||||
lastQueueStats = stats
|
||||
_downloadSummaryFlow.value =
|
||||
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
|
||||
"正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${stateStore.state.totalFailure}|成功:$successDownloadCount"
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -204,8 +220,11 @@ object CacheBook {
|
||||
}
|
||||
is ChapterSelection.Single -> Unit
|
||||
}
|
||||
val requestId = pendingRequestId.incrementAndGet()
|
||||
pendingRequests[requestId] = request
|
||||
context.startService<CacheBookService> {
|
||||
action = IntentAction.start
|
||||
putExtra("requestId", requestId)
|
||||
putExtra("bookUrl", request.bookUrl)
|
||||
putExtra("source", request.source.name)
|
||||
when (val selection = request.selection) {
|
||||
@@ -213,9 +232,7 @@ object CacheBook {
|
||||
putExtra("start", selection.start)
|
||||
putExtra("end", selection.end)
|
||||
}
|
||||
is ChapterSelection.Indices -> {
|
||||
putIntegerArrayListExtra("indices", ArrayList(selection.values))
|
||||
}
|
||||
is ChapterSelection.Indices -> Unit
|
||||
is ChapterSelection.Single -> {
|
||||
putExtra("start", selection.index)
|
||||
putExtra("end", selection.index)
|
||||
@@ -224,6 +241,10 @@ object CacheBook {
|
||||
}
|
||||
}
|
||||
|
||||
fun takePendingRequest(requestId: Long): CacheDownloadRequest? {
|
||||
return pendingRequests.remove(requestId)
|
||||
}
|
||||
|
||||
fun remove(context: Context, bookUrl: String) {
|
||||
context.startService<CacheBookService> {
|
||||
action = IntentAction.remove
|
||||
@@ -232,12 +253,12 @@ object CacheBook {
|
||||
}
|
||||
|
||||
fun removeBook(bookUrl: String): Boolean {
|
||||
val model = cacheBookMap.remove(bookUrl) ?: return false
|
||||
model.stop()
|
||||
val model = cacheBookMap.remove(bookUrl)
|
||||
model?.stop()
|
||||
stateStore.removeBook(bookUrl)
|
||||
updateSummary()
|
||||
_queueChangedFlow.tryEmit(bookUrl)
|
||||
return true
|
||||
return model != null
|
||||
}
|
||||
|
||||
fun removeChapter(bookUrl: String, chapterIndex: Int): Boolean {
|
||||
@@ -255,9 +276,9 @@ object CacheBook {
|
||||
fun close() {
|
||||
cacheBookMap.forEach { (_, model) -> model.stop() }
|
||||
cacheBookMap.clear()
|
||||
successDownloadSet.clear()
|
||||
errorDownloadMap.clear()
|
||||
errorIndexMap.clear()
|
||||
successDownloadCount = 0
|
||||
errorRetryMap.clear()
|
||||
pendingRequests.clear()
|
||||
stateStore.clear()
|
||||
updateSummary()
|
||||
}
|
||||
@@ -273,16 +294,16 @@ object CacheBook {
|
||||
val totalCount: Int
|
||||
get() {
|
||||
val stats = collectQueueStats()
|
||||
return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
|
||||
return stats.waitingCount + stats.downloadingCount + successDownloadCount + stateStore.state.totalFailure
|
||||
}
|
||||
|
||||
val completedCount: Int
|
||||
get() = successDownloadSet.size + errorDownloadMap.size
|
||||
get() = successDownloadCount + stateStore.state.totalFailure
|
||||
|
||||
val downloadSummary: String
|
||||
get() {
|
||||
val stats = collectQueueStats()
|
||||
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${errorDownloadMap.size} | 成功:${successDownloadSet.size}"
|
||||
return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${stateStore.state.totalFailure} | 成功:$successDownloadCount"
|
||||
}
|
||||
|
||||
val isRun: Boolean
|
||||
@@ -351,9 +372,6 @@ object CacheBook {
|
||||
@Synchronized
|
||||
fun isDownloading(index: Int): Boolean = onDownloadSet.contains(index)
|
||||
|
||||
@Synchronized
|
||||
fun waitingIndices(): Set<Int> = queue.waitingIndices()
|
||||
|
||||
@Synchronized
|
||||
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
|
||||
|
||||
@@ -438,9 +456,9 @@ object CacheBook {
|
||||
private fun onSuccess(chapter: BookChapter) {
|
||||
onDownloadSet.remove(chapter.index)
|
||||
chapterTasks.remove(chapter.index)
|
||||
successDownloadSet.add(chapter.primaryStr())
|
||||
errorDownloadMap.remove(chapter.primaryStr())
|
||||
errorIndexMap[book.bookUrl]?.remove(chapter.index)
|
||||
val chapterKey = ChapterKey(book.bookUrl, chapter.index)
|
||||
successDownloadCount++
|
||||
errorRetryMap.remove(chapterKey)
|
||||
stateStore.markSuccess(book.bookUrl, chapter.index)
|
||||
notifyDownloadSetChanged()
|
||||
notifyErrorChanged()
|
||||
@@ -451,9 +469,7 @@ object CacheBook {
|
||||
private fun onPreError(chapter: BookChapter, error: Throwable) {
|
||||
waitingRetry = true
|
||||
if (error !is ConcurrentException) {
|
||||
errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
|
||||
errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
|
||||
.add(chapter.index)
|
||||
errorRetryMap.merge(ChapterKey(book.bookUrl, chapter.index), 1) { old, inc -> old + inc }
|
||||
stateStore.markFailed(book.bookUrl, chapter.index)
|
||||
}
|
||||
onDownloadSet.remove(chapter.index)
|
||||
@@ -462,7 +478,7 @@ object CacheBook {
|
||||
|
||||
@Synchronized
|
||||
private fun onPostError(chapter: BookChapter, error: Throwable) {
|
||||
val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
|
||||
val retryCount = errorRetryMap[ChapterKey(book.bookUrl, chapter.index)] ?: 0
|
||||
if (retryCount < 3 && !isStopped) {
|
||||
queue.enqueue(ChapterSelection.Single(chapter.index))
|
||||
} else {
|
||||
|
||||
@@ -41,6 +41,6 @@ data class CacheBookDownloadState(
|
||||
val waitingCount: Int = 0,
|
||||
val runningIndices: Set<Int> = emptySet(),
|
||||
val failedIndices: Set<Int> = emptySet(),
|
||||
val successIndices: Set<Int> = emptySet(),
|
||||
val successCount: Int = 0,
|
||||
val failureMessage: String? = null,
|
||||
)
|
||||
|
||||
+106
-24
@@ -2,6 +2,91 @@ package io.legado.app.model.cache
|
||||
|
||||
class CacheDownloadQueue {
|
||||
|
||||
private class IntRangeSet {
|
||||
private val ranges = mutableListOf<IntRange>()
|
||||
|
||||
fun contains(value: Int): Boolean {
|
||||
return ranges.any { value in it }
|
||||
}
|
||||
|
||||
fun add(value: Int) {
|
||||
addRange(value, value)
|
||||
}
|
||||
|
||||
fun addRange(start: Int, end: Int) {
|
||||
if (end < start) return
|
||||
var newStart = start
|
||||
var newEnd = end
|
||||
var index = 0
|
||||
while (index < ranges.size) {
|
||||
val range = ranges[index]
|
||||
if (newEnd + 1 < range.first) break
|
||||
if (newStart > range.last + 1) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
newStart = minOf(newStart, range.first)
|
||||
newEnd = maxOf(newEnd, range.last)
|
||||
ranges.removeAt(index)
|
||||
}
|
||||
ranges.add(index, newStart..newEnd)
|
||||
}
|
||||
|
||||
fun remove(value: Int) {
|
||||
val index = ranges.indexOfFirst { value in it }
|
||||
if (index < 0) return
|
||||
val range = ranges.removeAt(index)
|
||||
if (range.first < value) {
|
||||
ranges.add(index, range.first until value)
|
||||
}
|
||||
if (value < range.last) {
|
||||
ranges.add(index + if (range.first < value) 1 else 0, value + 1..range.last)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRange(start: Int, end: Int) {
|
||||
if (end < start) return
|
||||
var index = 0
|
||||
while (index < ranges.size) {
|
||||
val range = ranges[index]
|
||||
if (range.last < start) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (range.first > end) break
|
||||
ranges.removeAt(index)
|
||||
if (range.first < start) {
|
||||
ranges.add(index, range.first until start)
|
||||
index++
|
||||
}
|
||||
if (end < range.last) {
|
||||
ranges.add(index, end + 1..range.last)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
ranges.clear()
|
||||
}
|
||||
|
||||
fun countInRange(start: Int, end: Int, excluding: IntRangeSet? = null): Int {
|
||||
if (end < start) return 0
|
||||
var count = 0
|
||||
ranges.forEach { range ->
|
||||
val overlapStart = maxOf(start, range.first)
|
||||
val overlapEnd = minOf(end, range.last)
|
||||
if (overlapEnd >= overlapStart) {
|
||||
count += overlapEnd - overlapStart + 1
|
||||
if (excluding != null) {
|
||||
count -= excluding.countInRange(overlapStart, overlapEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private data class RangeCursor(
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
@@ -9,13 +94,13 @@ class CacheDownloadQueue {
|
||||
) {
|
||||
fun contains(index: Int): Boolean = index in next..end
|
||||
fun remainingCount(
|
||||
emittedIndices: Set<Int>,
|
||||
removedIndices: Set<Int>,
|
||||
emittedIndices: IntRangeSet,
|
||||
removedIndices: IntRangeSet,
|
||||
): Int {
|
||||
if (next > end) return 0
|
||||
val rawCount = end - next + 1
|
||||
val emittedCount = emittedIndices.count { it in next..end }
|
||||
val removedCount = removedIndices.count { it in next..end && it !in emittedIndices }
|
||||
val emittedCount = emittedIndices.countInRange(next, end)
|
||||
val removedCount = removedIndices.countInRange(next, end, excluding = emittedIndices)
|
||||
val excludedCount = emittedCount + removedCount
|
||||
return rawCount - excludedCount
|
||||
}
|
||||
@@ -23,8 +108,8 @@ class CacheDownloadQueue {
|
||||
|
||||
private val ranges = ArrayDeque<RangeCursor>()
|
||||
private val indices = linkedSetOf<Int>()
|
||||
private val emittedIndices = hashSetOf<Int>()
|
||||
private val removedIndices = hashSetOf<Int>()
|
||||
private val emittedIndices = IntRangeSet()
|
||||
private val removedIndices = IntRangeSet()
|
||||
|
||||
fun enqueue(request: CacheDownloadRequest) {
|
||||
enqueue(request.selection)
|
||||
@@ -42,7 +127,7 @@ class CacheDownloadQueue {
|
||||
while (indices.isNotEmpty()) {
|
||||
val index = indices.first()
|
||||
indices.remove(index)
|
||||
if (index in runningIndices || index in removedIndices) continue
|
||||
if (index in runningIndices || removedIndices.contains(index)) continue
|
||||
emittedIndices.add(index)
|
||||
return CacheDownloadCandidate(bookUrl, index)
|
||||
}
|
||||
@@ -51,10 +136,15 @@ class CacheDownloadQueue {
|
||||
val cursor = ranges.first()
|
||||
while (cursor.next <= cursor.end) {
|
||||
val index = cursor.next++
|
||||
if (index in removedIndices || index in runningIndices) continue
|
||||
if (emittedIndices.add(index)) {
|
||||
return CacheDownloadCandidate(bookUrl, index)
|
||||
if (
|
||||
removedIndices.contains(index) ||
|
||||
emittedIndices.contains(index) ||
|
||||
index in runningIndices
|
||||
) {
|
||||
continue
|
||||
}
|
||||
emittedIndices.add(index)
|
||||
return CacheDownloadCandidate(bookUrl, index)
|
||||
}
|
||||
ranges.removeFirst()
|
||||
}
|
||||
@@ -79,30 +169,22 @@ class CacheDownloadQueue {
|
||||
}
|
||||
|
||||
fun waitingCount(): Int {
|
||||
val indexCount = indices.count { it !in emittedIndices && it !in removedIndices }
|
||||
val indexCount = indices.count {
|
||||
!emittedIndices.contains(it) && !removedIndices.contains(it)
|
||||
}
|
||||
val rangeCount = ranges.sumOf { it.remainingCount(emittedIndices, removedIndices) }
|
||||
return indexCount + rangeCount
|
||||
}
|
||||
|
||||
fun isWaiting(index: Int): Boolean {
|
||||
if (index in emittedIndices || index in removedIndices) return false
|
||||
if (emittedIndices.contains(index) || removedIndices.contains(index)) return false
|
||||
return indices.contains(index) || ranges.any { it.contains(index) }
|
||||
}
|
||||
|
||||
fun waitingIndices(): Set<Int> {
|
||||
return buildSet {
|
||||
indices.filterTo(this) { it !in emittedIndices && it !in removedIndices }
|
||||
ranges.forEach { cursor ->
|
||||
for (index in cursor.next..cursor.end) {
|
||||
if (index !in emittedIndices && index !in removedIndices) add(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addRange(start: Int, end: Int) {
|
||||
if (end < start) return
|
||||
removedIndices.removeAll { it in start..end }
|
||||
emittedIndices.removeRange(start, end)
|
||||
removedIndices.removeRange(start, end)
|
||||
ranges.add(RangeCursor(start, end))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,22 @@ class CacheDownloadStateStore {
|
||||
current.copy(
|
||||
waitingCount = waitingCount,
|
||||
runningIndices = runningIndices,
|
||||
failureMessage = if (waitingCount > 0 || runningIndices.isNotEmpty()) {
|
||||
null
|
||||
} else {
|
||||
current.failureMessage
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markSuccess(bookUrl: String, chapterIndex: Int) {
|
||||
updateBook(bookUrl) { current ->
|
||||
val successIndices = current.successIndices + chapterIndex
|
||||
current.copy(
|
||||
runningIndices = current.runningIndices - chapterIndex,
|
||||
failedIndices = current.failedIndices - chapterIndex,
|
||||
successIndices = successIndices,
|
||||
successCount = successIndices.size,
|
||||
successCount = current.successCount + 1,
|
||||
failureMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +50,16 @@ class CacheDownloadStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun markBookFailed(bookUrl: String, message: String) {
|
||||
updateBook(bookUrl) { current ->
|
||||
current.copy(
|
||||
waitingCount = 0,
|
||||
runningIndices = emptySet(),
|
||||
failureMessage = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFailure(bookUrl: String, chapterIndex: Int) {
|
||||
updateBook(bookUrl) { current ->
|
||||
current.copy(failedIndices = current.failedIndices - chapterIndex)
|
||||
@@ -79,7 +93,8 @@ class CacheDownloadStateStore {
|
||||
private fun CacheDownloadState.recalculate(): CacheDownloadState {
|
||||
val totalWaiting = books.values.sumOf { it.waitingCount }
|
||||
val totalRunning = books.values.sumOf { it.runningIndices.size }
|
||||
val totalFailure = books.values.sumOf { it.failedIndices.size }
|
||||
val totalFailure = books.values.sumOf { it.failedIndices.size } +
|
||||
books.values.count { it.failureMessage != null }
|
||||
val totalSuccess = books.values.sumOf { it.successCount }
|
||||
return copy(
|
||||
isRunning = totalWaiting > 0 || totalRunning > 0,
|
||||
|
||||
@@ -35,6 +35,8 @@ import kotlin.coroutines.coroutineContext
|
||||
*/
|
||||
object BookContent {
|
||||
|
||||
private const val maxNextPageConcurrency = 4
|
||||
|
||||
@Throws(Exception::class)
|
||||
suspend fun analyzeContent(
|
||||
bookSource: BookSource,
|
||||
@@ -50,14 +52,24 @@ object BookContent {
|
||||
appCtx.getString(R.string.error_get_web_content, baseUrl)
|
||||
)
|
||||
Debug.log(bookSource.bookSourceUrl, "≡获取成功:${baseUrl}")
|
||||
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("内容为空")
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class CacheBookService : BaseService() {
|
||||
private set
|
||||
}
|
||||
|
||||
private val threadCount = OtherConfig.cacheBookThreadCount
|
||||
private val threadCount = OtherConfig.cacheBookThreadCount.coerceIn(1, CacheBook.maxDownloadConcurrency)
|
||||
private var cachePool =
|
||||
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
|
||||
private var downloadJob: Job? = null
|
||||
@@ -84,11 +84,16 @@ class CacheBookService : BaseService() {
|
||||
intent?.action?.let { action ->
|
||||
when (action) {
|
||||
IntentAction.start -> {
|
||||
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
|
||||
val indices = intent.getIntegerArrayListExtra("indices")
|
||||
if (indices != null && indices.isNotEmpty()) {
|
||||
addDownloadData(bookUrl, indices)
|
||||
val requestId = intent.getLongExtra("requestId", -1L)
|
||||
val request = if (requestId >= 0) {
|
||||
CacheBook.takePendingRequest(requestId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (request != null) {
|
||||
addDownloadRequest(request)
|
||||
} else {
|
||||
val bookUrl = intent.getStringExtra("bookUrl") ?: return@let
|
||||
addDownloadData(
|
||||
bookUrl,
|
||||
intent.getIntExtra("start", 0),
|
||||
@@ -113,19 +118,6 @@ class CacheBookService : BaseService() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun addDownloadData(bookUrl: String?, indices: Iterable<Int>) {
|
||||
bookUrl ?: return
|
||||
val values = indices.toSet()
|
||||
if (values.isEmpty()) return
|
||||
addDownloadRequest(
|
||||
CacheDownloadRequest(
|
||||
bookUrl = bookUrl,
|
||||
selection = ChapterSelection.Indices(values),
|
||||
source = CacheDownloadSource.Manual,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
|
||||
bookUrl ?: return
|
||||
if (end < start) return
|
||||
@@ -140,7 +132,10 @@ class CacheBookService : BaseService() {
|
||||
|
||||
private fun addDownloadRequest(request: CacheDownloadRequest) {
|
||||
execute {
|
||||
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: return@execute
|
||||
val cacheBook = CacheBook.getOrCreate(request.bookUrl) ?: run {
|
||||
CacheBook.markBookFailed(request.bookUrl, getString(R.string.error_no_source))
|
||||
return@execute
|
||||
}
|
||||
|
||||
val book = cacheBook.book
|
||||
val chapterCount = appDb.bookChapterDao.getChapterCount(request.bookUrl)
|
||||
@@ -154,6 +149,10 @@ class CacheBookService : BaseService() {
|
||||
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
|
||||
}.onFailure {
|
||||
removeDownload(request.bookUrl)
|
||||
CacheBook.markBookFailed(
|
||||
request.bookUrl,
|
||||
getString(R.string.error_get_book_info)
|
||||
)
|
||||
AppLog.put(
|
||||
"《$name》目录为空且加载详情页失败\n${it.localizedMessage}",
|
||||
it,
|
||||
@@ -169,6 +168,10 @@ class CacheBookService : BaseService() {
|
||||
book.update()
|
||||
}
|
||||
removeDownload(request.bookUrl)
|
||||
CacheBook.markBookFailed(
|
||||
request.bookUrl,
|
||||
getString(R.string.error_get_chapter_list)
|
||||
)
|
||||
AppLog.put(
|
||||
"《$name》目录为空且加载目录失败\n${it.localizedMessage}",
|
||||
it,
|
||||
|
||||
@@ -45,7 +45,6 @@ import splitties.init.appCtx
|
||||
import splitties.systemservices.notificationManager
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 校验书源
|
||||
@@ -53,7 +52,7 @@ import kotlin.math.min
|
||||
class CheckSourceService : BaseService() {
|
||||
private var threadCount = OtherConfig.threadCount
|
||||
private var searchCoroutine =
|
||||
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
|
||||
Executors.newFixedThreadPool(threadCount).asCoroutineDispatcher()
|
||||
private var notificationMsg = appCtx.getString(R.string.service_starting)
|
||||
private var checkJob: Job? = null
|
||||
private var originSize = 0
|
||||
|
||||
@@ -20,12 +20,11 @@ import io.legado.app.databinding.DialogAddToBookshelfBinding
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
||||
import io.legado.app.model.webBook.WebBook
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.main.MainActivity
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.NetworkUtils
|
||||
import io.legado.app.utils.fromJsonObject
|
||||
import io.legado.app.utils.setLayout
|
||||
import io.legado.app.utils.startActivity
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
|
||||
@@ -82,11 +81,14 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe
|
||||
}
|
||||
viewModel.load(bookUrl) {
|
||||
viewModel.saveSearchBook(it) {
|
||||
startActivity<BookInfoActivity> {
|
||||
putExtra("name", it.name)
|
||||
putExtra("author", it.author)
|
||||
putExtra("bookUrl", it.bookUrl)
|
||||
}
|
||||
startActivity(
|
||||
MainActivity.createBookInfoIntent(
|
||||
context = requireContext(),
|
||||
name = it.name,
|
||||
author = it.author,
|
||||
bookUrl = it.bookUrl
|
||||
)
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -47,6 +47,7 @@ data class BookCacheBookItem(
|
||||
val author: String,
|
||||
val totalCount: Int,
|
||||
val cachedCount: Int,
|
||||
val cachedFileCount: Int,
|
||||
val waitingCount: Int,
|
||||
val downloadingCount: Int,
|
||||
val errorCount: Int,
|
||||
@@ -188,9 +189,7 @@ class BookCacheManageViewModel(
|
||||
sourceBooks
|
||||
.filterNot { it.isLocal || it.isAudio }
|
||||
.mapNotNull { book -> buildBookItem(book) }
|
||||
.filter { item ->
|
||||
item.cachedCount > 0 || item.isDownloading || item.errorCount > 0
|
||||
}
|
||||
.filter(::shouldShowItem)
|
||||
)
|
||||
val booksByUrl = items.associateBy { it.bookUrl }
|
||||
val retainedExpandedBookUrls = expandedBookUrls.filterTo(linkedSetOf()) {
|
||||
@@ -265,9 +264,7 @@ class BookCacheManageViewModel(
|
||||
val item = book
|
||||
?.takeUnless { it.isLocal || it.isAudio }
|
||||
?.let { buildBookItem(it) }
|
||||
?.takeIf {
|
||||
it.cachedCount > 0 || it.isDownloading || it.errorCount > 0
|
||||
}
|
||||
?.takeIf(::shouldShowItem)
|
||||
val chapters = if (expanded && item != null) {
|
||||
buildChapterItems(item.bookUrl)
|
||||
} else {
|
||||
@@ -320,6 +317,7 @@ class BookCacheManageViewModel(
|
||||
author = book.getRealAuthor(),
|
||||
totalCount = totalCount,
|
||||
cachedCount = cachedCount,
|
||||
cachedFileCount = cachedFileCount,
|
||||
waitingCount = waitingCount,
|
||||
downloadingCount = downloadingCount,
|
||||
errorCount = errorIndices.size,
|
||||
@@ -327,6 +325,10 @@ class BookCacheManageViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldShowItem(item: BookCacheBookItem): Boolean {
|
||||
return item.cachedFileCount > 0 || item.isDownloading || item.errorCount > 0
|
||||
}
|
||||
|
||||
private fun buildChapterItems(bookUrl: String): List<BookCacheChapterItem> {
|
||||
val book = bookDao.getBook(bookUrl) ?: return emptyList()
|
||||
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
||||
@@ -472,7 +474,7 @@ class BookCacheManageViewModel(
|
||||
}
|
||||
|
||||
private fun deleteBookCache(bookUrl: String) {
|
||||
CacheBook.remove(context, bookUrl)
|
||||
CacheBook.removeBook(bookUrl)
|
||||
execute {
|
||||
clearBookCacheUseCase.execute(bookUrl)
|
||||
}.onSuccess {
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Bundle
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.data.appDb
|
||||
@@ -33,7 +32,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.min
|
||||
|
||||
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
|
||||
private val threadCount = OtherConfig.threadCount
|
||||
@@ -105,7 +103,7 @@ class ChangeCoverViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
private fun initSearchPool() {
|
||||
searchPool = Executors
|
||||
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
|
||||
.newFixedThreadPool(threadCount).asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
private fun startSearch() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.annotation.CallSuper
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppConst
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.AppPattern
|
||||
import io.legado.app.data.appDb
|
||||
@@ -56,7 +55,6 @@ import kotlinx.coroutines.withTimeout
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
|
||||
@@ -181,7 +179,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
|
||||
|
||||
private fun initSearchPool() {
|
||||
searchPool = Executors
|
||||
.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
|
||||
.newFixedThreadPool(threadCount).asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
fun refresh(): Boolean {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package io.legado.app.ui.book.explore
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.utils.startActivity
|
||||
|
||||
/**
|
||||
* 发现列表
|
||||
*/
|
||||
class ExploreShowActivity : BaseComposeActivity() {
|
||||
|
||||
private lateinit var screenTitle: String
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
screenTitle = intent.getStringExtra("exploreName") ?: "探索"
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
MaterialTheme {
|
||||
ExploreShowScreen(
|
||||
title = screenTitle,
|
||||
intent = intent,
|
||||
onBack = { finish() },
|
||||
onBookClick = { book ->
|
||||
startActivity<BookInfoActivity> {
|
||||
putExtra("name", book.name)
|
||||
putExtra("author", book.author)
|
||||
putExtra("bookUrl", book.bookUrl)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.legado.app.ui.book.explore
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -95,14 +94,15 @@ import org.koin.compose.koinInject
|
||||
@Composable
|
||||
fun ExploreShowScreen(
|
||||
title: String,
|
||||
intent: Intent,
|
||||
sourceUrl: String?,
|
||||
exploreUrl: String?,
|
||||
onBack: () -> Unit,
|
||||
onBookClick: (SearchBook) -> Unit,
|
||||
viewModel: ExploreShowViewModel = koinViewModel()
|
||||
) {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initData(intent)
|
||||
LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
|
||||
viewModel.initData(sourceUrl, exploreUrl)
|
||||
}
|
||||
|
||||
val books by viewModel.uiBooks.collectAsState()
|
||||
@@ -124,7 +124,6 @@ fun ExploreShowScreen(
|
||||
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? AppCompatActivity
|
||||
val sourceUrl = remember(intent) { intent.getStringExtra("sourceUrl") }
|
||||
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
|
||||
|
||||
LaunchedEffect(sourceUrl) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package io.legado.app.ui.book.explore
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.data.entities.BookSource
|
||||
@@ -124,10 +123,18 @@ class ExploreShowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun initData(intent: Intent) {
|
||||
val incomingSourceUrl = intent.getStringExtra("sourceUrl")
|
||||
fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
|
||||
if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
|
||||
return
|
||||
}
|
||||
sourceUrl = incomingSourceUrl
|
||||
exploreUrl = intent.getStringExtra("exploreUrl")
|
||||
exploreUrl = incomingExploreUrl
|
||||
page = 1
|
||||
bookSource = null
|
||||
_rawBooks.value = emptyList()
|
||||
_isEndStateFlow.value = false
|
||||
_errorMsg.value = null
|
||||
_selectedKindTitle.value = null
|
||||
|
||||
viewModelScope.launch {
|
||||
if (bookSource == null && incomingSourceUrl != null) {
|
||||
|
||||
@@ -1,193 +1,37 @@
|
||||
package io.legado.app.ui.book.info
|
||||
|
||||
import io.legado.app.ui.config.otherConfig.OtherConfig
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.help.book.isAudio
|
||||
import io.legado.app.help.book.isImage
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.model.SourceCallBack
|
||||
import io.legado.app.ui.book.audio.AudioPlayActivity
|
||||
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
|
||||
import io.legado.app.ui.book.manga.ReadMangaActivity
|
||||
import io.legado.app.ui.book.read.ReadBookActivity
|
||||
import io.legado.app.ui.book.search.SearchActivity
|
||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||
import io.legado.app.ui.book.toc.TocActivityResult
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.ui.login.SourceLoginActivity
|
||||
import io.legado.app.ui.main.MainActivity
|
||||
import io.legado.app.ui.widget.dialog.VariableDialog
|
||||
import io.legado.app.utils.StartActivityContract
|
||||
import io.legado.app.utils.openFileUri
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.startActivity
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
|
||||
|
||||
private val viewModel: BookInfoViewModel by viewModel()
|
||||
|
||||
private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
|
||||
viewModel.onTocResult(it)
|
||||
}
|
||||
private val localBookTreeSelect = registerForActivityResult(HandleFileContract()) {
|
||||
it.uri?.let { treeUri ->
|
||||
OtherConfig.defaultBookTreeUri = treeUri.toString()
|
||||
}
|
||||
}
|
||||
private val infoEditResult = registerForActivityResult(
|
||||
StartActivityContract(BookInfoEditActivity::class.java)
|
||||
) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
viewModel.onInfoEdited()
|
||||
}
|
||||
}
|
||||
private val editSourceResult = registerForActivityResult(
|
||||
StartActivityContract(BookSourceEditActivity::class.java)
|
||||
) {
|
||||
if (it.resultCode != RESULT_CANCELED) {
|
||||
viewModel.onSourceEdited()
|
||||
}
|
||||
}
|
||||
private val readBookResult = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
viewModel.onReaderResult(it.resultCode)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.initData(intent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collectLatest { effect ->
|
||||
when (effect) {
|
||||
is BookInfoEffect.Finish -> {
|
||||
effect.resultCode?.let { setResult(it) }
|
||||
if (effect.afterTransition) finishAfterTransition() else finish()
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenBookInfoEdit -> {
|
||||
infoEditResult.launch {
|
||||
putExtra("bookUrl", effect.bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenReader -> startReadActivity(
|
||||
book = effect.book,
|
||||
inBookshelf = effect.inBookshelf,
|
||||
chapterChanged = effect.chapterChanged,
|
||||
)
|
||||
is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl)
|
||||
is BookInfoEffect.OpenBookSourceEdit -> {
|
||||
editSourceResult.launch {
|
||||
putExtra("sourceUrl", effect.sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenSourceLogin -> {
|
||||
startActivity<SourceLoginActivity> {
|
||||
putExtra("type", "bookSource")
|
||||
putExtra("key", effect.sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
|
||||
title = getString(R.string.select_book_folder)
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenFile -> openFileUri(effect.uri, effect.mimeType)
|
||||
is BookInfoEffect.RunSourceCallback -> runSourceCallback(effect)
|
||||
is BookInfoEffect.ShowVariableDialog -> {
|
||||
showDialogFragment(
|
||||
VariableDialog(
|
||||
effect.title,
|
||||
effect.key,
|
||||
effect.variable,
|
||||
effect.comment,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BookInfoScreen(
|
||||
state = state,
|
||||
onIntent = viewModel::onIntent,
|
||||
BookInfoRouteScreen(
|
||||
bookUrl = intent.getStringExtra("bookUrl").orEmpty(),
|
||||
viewModel = viewModel,
|
||||
onBack = { finishAfterTransition() },
|
||||
onFinish = { resultCode, afterTransition ->
|
||||
resultCode?.let { setResult(it) }
|
||||
if (afterTransition) finishAfterTransition() else finish()
|
||||
},
|
||||
onOpenSearch = { keyword ->
|
||||
startActivity(MainActivity.createSearchIntent(this, key = keyword))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun setVariable(key: String, variable: String?) {
|
||||
viewModel.setVariable(key, variable)
|
||||
}
|
||||
|
||||
private fun runSourceCallback(effect: BookInfoEffect.RunSourceCallback) {
|
||||
SourceCallBack.callBackBtn(
|
||||
this,
|
||||
effect.event,
|
||||
effect.source,
|
||||
effect.book,
|
||||
null,
|
||||
) {
|
||||
when (val action = effect.action) {
|
||||
is BookInfoCallbackAction.Search -> {
|
||||
startActivity<SearchActivity> {
|
||||
putExtra("key", action.keyword)
|
||||
}
|
||||
}
|
||||
|
||||
is BookInfoCallbackAction.ShareText -> {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_TEXT, action.text)
|
||||
type = "text/plain"
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, action.chooserTitle))
|
||||
}
|
||||
|
||||
is BookInfoCallbackAction.CopyText -> {
|
||||
sendToClip(action.text)
|
||||
}
|
||||
|
||||
BookInfoCallbackAction.ClearCache -> {
|
||||
viewModel.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startReadActivity(
|
||||
book: io.legado.app.data.entities.Book,
|
||||
inBookshelf: Boolean,
|
||||
chapterChanged: Boolean,
|
||||
) {
|
||||
val cls = when {
|
||||
book.isAudio -> AudioPlayActivity::class.java
|
||||
!book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java
|
||||
else -> ReadBookActivity::class.java
|
||||
}
|
||||
readBookResult.launch(
|
||||
Intent(this, cls).apply {
|
||||
putExtra("bookUrl", book.bookUrl)
|
||||
putExtra("inBookshelf", inBookshelf)
|
||||
putExtra("chapterChanged", chapterChanged)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package io.legado.app.ui.book.info
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.help.book.isAudio
|
||||
import io.legado.app.help.book.isImage
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.model.SourceCallBack
|
||||
import io.legado.app.ui.book.audio.AudioPlayActivity
|
||||
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
|
||||
import io.legado.app.ui.book.manga.ReadMangaActivity
|
||||
import io.legado.app.ui.book.read.ReadBookActivity
|
||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||
import io.legado.app.ui.book.toc.TocActivityResult
|
||||
import io.legado.app.ui.config.otherConfig.OtherConfig
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.ui.login.SourceLoginActivity
|
||||
import io.legado.app.ui.widget.dialog.VariableDialog
|
||||
import io.legado.app.utils.StartActivityContract
|
||||
import io.legado.app.utils.openFileUri
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.startActivity
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
@Composable
|
||||
fun BookInfoRouteScreen(
|
||||
bookUrl: String,
|
||||
viewModel: BookInfoViewModel,
|
||||
onBack: () -> Unit,
|
||||
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
|
||||
onOpenSearch: (String) -> Unit,
|
||||
onRegisterVariableSetter: (((String, String?) -> Unit)?) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as AppCompatActivity
|
||||
|
||||
val tocActivityResult = rememberLauncherForActivityResult(TocActivityResult()) {
|
||||
viewModel.onTocResult(it)
|
||||
}
|
||||
val localBookTreeSelect = rememberLauncherForActivityResult(HandleFileContract()) {
|
||||
it.uri?.let { treeUri ->
|
||||
OtherConfig.defaultBookTreeUri = treeUri.toString()
|
||||
}
|
||||
}
|
||||
val infoEditResult = rememberLauncherForActivityResult(
|
||||
StartActivityContract(BookInfoEditActivity::class.java)
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
viewModel.onInfoEdited()
|
||||
}
|
||||
}
|
||||
val editSourceResult = rememberLauncherForActivityResult(
|
||||
StartActivityContract(BookSourceEditActivity::class.java)
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_CANCELED) {
|
||||
viewModel.onSourceEdited()
|
||||
}
|
||||
}
|
||||
val readBookResult = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
viewModel.onReaderResult(it.resultCode)
|
||||
}
|
||||
|
||||
LaunchedEffect(bookUrl, viewModel) {
|
||||
viewModel.initData(bookUrl)
|
||||
}
|
||||
|
||||
DisposableEffect(viewModel) {
|
||||
onRegisterVariableSetter(viewModel::setVariable)
|
||||
onDispose {
|
||||
onRegisterVariableSetter(null)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel, activity) {
|
||||
viewModel.effects.collectLatest { effect ->
|
||||
when (effect) {
|
||||
is BookInfoEffect.Finish -> {
|
||||
onFinish(effect.resultCode, effect.afterTransition)
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenBookInfoEdit -> {
|
||||
infoEditResult.launch {
|
||||
putExtra("bookUrl", effect.bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenReader -> {
|
||||
val cls = when {
|
||||
effect.book.isAudio -> AudioPlayActivity::class.java
|
||||
!effect.book.isLocal && effect.book.isImage && AppConfig.showMangaUi -> {
|
||||
ReadMangaActivity::class.java
|
||||
}
|
||||
|
||||
else -> ReadBookActivity::class.java
|
||||
}
|
||||
readBookResult.launch(
|
||||
Intent(activity, cls).apply {
|
||||
putExtra("bookUrl", effect.book.bookUrl)
|
||||
putExtra("inBookshelf", effect.inBookshelf)
|
||||
putExtra("chapterChanged", effect.chapterChanged)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenToc -> tocActivityResult.launch(effect.bookUrl)
|
||||
is BookInfoEffect.OpenBookSourceEdit -> {
|
||||
editSourceResult.launch {
|
||||
putExtra("sourceUrl", effect.sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenSourceLogin -> {
|
||||
activity.startActivity<SourceLoginActivity> {
|
||||
putExtra("type", "bookSource")
|
||||
putExtra("key", effect.sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
BookInfoEffect.OpenSelectBooksDir -> localBookTreeSelect.launch {
|
||||
title = activity.getString(R.string.select_book_folder)
|
||||
}
|
||||
|
||||
is BookInfoEffect.OpenFile -> activity.openFileUri(effect.uri, effect.mimeType)
|
||||
is BookInfoEffect.RunSourceCallback -> {
|
||||
runSourceCallback(activity, effect, viewModel, onOpenSearch)
|
||||
}
|
||||
|
||||
is BookInfoEffect.ShowVariableDialog -> {
|
||||
activity.showDialogFragment(
|
||||
VariableDialog(
|
||||
effect.title,
|
||||
effect.key,
|
||||
effect.variable,
|
||||
effect.comment,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BookInfoScreen(
|
||||
state = viewModel.uiState.collectAsStateWithLifecycle().value,
|
||||
onIntent = viewModel::onIntent,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
private fun runSourceCallback(
|
||||
activity: AppCompatActivity,
|
||||
effect: BookInfoEffect.RunSourceCallback,
|
||||
viewModel: BookInfoViewModel,
|
||||
onOpenSearch: (String) -> Unit,
|
||||
) {
|
||||
SourceCallBack.callBackBtn(
|
||||
activity,
|
||||
effect.event,
|
||||
effect.source,
|
||||
effect.book,
|
||||
null,
|
||||
) {
|
||||
when (val action = effect.action) {
|
||||
is BookInfoCallbackAction.Search -> {
|
||||
onOpenSearch(action.keyword)
|
||||
}
|
||||
|
||||
is BookInfoCallbackAction.ShareText -> {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_TEXT, action.text)
|
||||
type = "text/plain"
|
||||
}
|
||||
activity.startActivity(Intent.createChooser(intent, action.chooserTitle))
|
||||
}
|
||||
|
||||
is BookInfoCallbackAction.CopyText -> {
|
||||
activity.sendToClip(action.text)
|
||||
}
|
||||
|
||||
BookInfoCallbackAction.ClearCache -> {
|
||||
viewModel.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ private fun BookInfoScreenContent(
|
||||
text = { Text(stringResource(R.string.reading)) },
|
||||
)
|
||||
},
|
||||
alwaysDrawBehindBars = true,
|
||||
) { paddingValues ->
|
||||
val book = state.book
|
||||
if (book == null) {
|
||||
@@ -826,7 +827,7 @@ private fun BookInfoSummary(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(LegadoTheme.colorScheme.surface)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
AppText(
|
||||
|
||||
@@ -101,9 +101,23 @@ class BookInfoViewModel(
|
||||
private var readRecordObserveJob: Job? = null
|
||||
|
||||
fun initData(intent: Intent) {
|
||||
if (currentBook != null) return
|
||||
initData(intent.getStringExtra("bookUrl") ?: "")
|
||||
}
|
||||
|
||||
fun initData(bookUrl: String) {
|
||||
if (currentBook?.bookUrl == bookUrl) return
|
||||
currentBook = null
|
||||
currentChapterList = emptyList()
|
||||
currentWebFiles = emptyList()
|
||||
currentKindLabels = emptyList()
|
||||
currentGroupNames = null
|
||||
currentHasCustomGroup = false
|
||||
inBookshelf = false
|
||||
bookSource = null
|
||||
chapterChanged = false
|
||||
clearReadRecordObserve()
|
||||
_uiState.value = BookInfoUiState()
|
||||
execute {
|
||||
val bookUrl = intent.getStringExtra("bookUrl") ?: ""
|
||||
appDb.bookDao.getBook(bookUrl)?.let {
|
||||
inBookshelf = !it.isNotShelf
|
||||
return@execute it
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package io.legado.app.ui.book.manage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.adapter.ItemViewHolder
|
||||
import io.legado.app.base.adapter.RecyclerAdapter
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.BookGroup
|
||||
import io.legado.app.databinding.ItemArrangeBookBinding
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.config.AppConfig
|
||||
//import io.legado.app.lib.theme.backgroundColor
|
||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
||||
import java.util.*
|
||||
|
||||
class BookAdapter(context: Context, val callBack: CallBack) :
|
||||
RecyclerAdapter<Book, ItemArrangeBookBinding>(context),
|
||||
|
||||
ItemTouchCallback.Callback {
|
||||
val groupRequestCode = 12
|
||||
private val selectedBooks: HashSet<Book> = hashSetOf()
|
||||
var actionItem: Book? = null
|
||||
|
||||
val selection: List<Book>
|
||||
get() {
|
||||
return getItems().filter {
|
||||
selectedBooks.contains(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): ItemArrangeBookBinding {
|
||||
return ItemArrangeBookBinding.inflate(inflater, parent, false)
|
||||
}
|
||||
|
||||
override fun onCurrentListChanged() {
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: ItemArrangeBookBinding,
|
||||
item: Book,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
binding.apply {
|
||||
//root.setBackgroundColor(context.backgroundColor)
|
||||
tvName.text = item.name
|
||||
tvAuthor.text = item.author
|
||||
tvAuthor.visibility = if (item.author.isEmpty()) View.GONE else View.VISIBLE
|
||||
tvGroupS.text = getGroupName(item.group)
|
||||
checkbox.isChecked = selectedBooks.contains(item)
|
||||
if (item.isLocal) {
|
||||
tvOrigin.setText(R.string.local_book)
|
||||
} else {
|
||||
tvOrigin.text = item.originName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: ItemArrangeBookBinding) {
|
||||
binding.apply {
|
||||
checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
if (buttonView.isPressed) {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
if (buttonView.isPressed) {
|
||||
if (isChecked) {
|
||||
selectedBooks.add(it)
|
||||
} else {
|
||||
selectedBooks.remove(it)
|
||||
}
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
root.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
checkbox.isChecked = !checkbox.isChecked
|
||||
if (checkbox.isChecked) {
|
||||
selectedBooks.add(it)
|
||||
} else {
|
||||
selectedBooks.remove(it)
|
||||
}
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
}
|
||||
if (AppConfig.openBookInfoByClickTitle) {
|
||||
tvName.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
callBack.openBook(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
tvDelete.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
callBack.deleteBook(it)
|
||||
}
|
||||
}
|
||||
tvGroup.setOnClickListener {
|
||||
getItem(holder.layoutPosition)?.let {
|
||||
actionItem = it
|
||||
callBack.selectGroup(groupRequestCode, it.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun selectAll(selectAll: Boolean) {
|
||||
if (selectAll) {
|
||||
getItems().forEach {
|
||||
selectedBooks.add(it)
|
||||
}
|
||||
} else {
|
||||
selectedBooks.clear()
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun revertSelection() {
|
||||
getItems().forEach {
|
||||
if (selectedBooks.contains(it)) {
|
||||
selectedBooks.remove(it)
|
||||
} else {
|
||||
selectedBooks.add(it)
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
|
||||
fun checkSelectedInterval() {
|
||||
val selectedPosition = linkedSetOf<Int>()
|
||||
getItems().forEachIndexed { index, it ->
|
||||
if (selectedBooks.contains(it)) {
|
||||
selectedPosition.add(index)
|
||||
}
|
||||
}
|
||||
val minPosition = Collections.min(selectedPosition)
|
||||
val maxPosition = Collections.max(selectedPosition)
|
||||
val itemCount = maxPosition - minPosition + 1
|
||||
for (i in minPosition..maxPosition) {
|
||||
getItem(i)?.let {
|
||||
selectedBooks.add(it)
|
||||
}
|
||||
}
|
||||
notifyItemRangeChanged(minPosition, itemCount, bundleOf(Pair("selected", null)))
|
||||
callBack.upSelectCount()
|
||||
}
|
||||
|
||||
private fun getGroupList(groupId: Long): List<String> {
|
||||
val groupNames = arrayListOf<String>()
|
||||
callBack.groupList.forEach {
|
||||
if (it.groupId > 0 && it.groupId and groupId > 0) {
|
||||
groupNames.add(it.groupName)
|
||||
}
|
||||
}
|
||||
return groupNames
|
||||
}
|
||||
|
||||
private fun getGroupName(groupId: Long): String {
|
||||
val groupNames = getGroupList(groupId)
|
||||
if (groupNames.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
return groupNames.joinToString(",")
|
||||
}
|
||||
|
||||
private var isMoved = false
|
||||
|
||||
override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
|
||||
val srcItem = getItem(srcPosition)
|
||||
val targetItem = getItem(targetPosition)
|
||||
if (srcItem != null && targetItem != null) {
|
||||
if (srcItem.order == targetItem.order) {
|
||||
for ((index, item) in getItems().withIndex()) {
|
||||
item.order = index + 1
|
||||
}
|
||||
} else {
|
||||
val pos = srcItem.order
|
||||
srcItem.order = targetItem.order
|
||||
targetItem.order = pos
|
||||
}
|
||||
}
|
||||
swapItem(srcPosition, targetPosition)
|
||||
isMoved = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
if (isMoved) {
|
||||
callBack.updateBook(*getItems().toTypedArray())
|
||||
}
|
||||
isMoved = false
|
||||
}
|
||||
|
||||
val dragSelectCallback: DragSelectTouchHelper.Callback =
|
||||
object : DragSelectTouchHelper.AdvanceCallback<Book>(Mode.ToggleAndReverse) {
|
||||
override fun currentSelectedId(): MutableSet<Book> {
|
||||
return selectedBooks
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Book {
|
||||
return getItem(position)!!
|
||||
}
|
||||
|
||||
override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
|
||||
getItem(position)?.let {
|
||||
if (isSelected) {
|
||||
selectedBooks.add(it)
|
||||
} else {
|
||||
selectedBooks.remove(it)
|
||||
}
|
||||
notifyItemChanged(position, bundleOf(Pair("selected", null)))
|
||||
callBack.upSelectCount()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface CallBack {
|
||||
val groupList: List<BookGroup>
|
||||
|
||||
fun upSelectCount()
|
||||
|
||||
fun updateBook(vararg book: Book)
|
||||
|
||||
fun deleteBook(book: Book)
|
||||
|
||||
fun selectGroup(requestCode: Int, groupId: Long)
|
||||
|
||||
fun openBook(book: Book)
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package io.legado.app.ui.book.manage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.widget.CheckBox
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.VMBaseActivity
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.BookGroup
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.databinding.ActivityArrangeBookBinding
|
||||
import io.legado.app.databinding.DialogEditTextBinding
|
||||
import io.legado.app.help.DirectLinkUpload
|
||||
import io.legado.app.help.book.contains
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.help.config.LocalConfig
|
||||
import io.legado.app.lib.dialogs.alert
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
//import io.legado.app.lib.theme.primaryTextColor
|
||||
import io.legado.app.ui.book.group.GroupManageDialog
|
||||
import io.legado.app.ui.book.group.GroupSelectDialog
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.file.HandleFileContract
|
||||
import io.legado.app.ui.widget.SelectActionBar
|
||||
import io.legado.app.ui.widget.dialog.WaitDialog
|
||||
import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
|
||||
import io.legado.app.ui.widget.recycler.ItemTouchCallback
|
||||
import io.legado.app.ui.widget.recycler.VerticalDivider
|
||||
import io.legado.app.utils.cnCompare
|
||||
import io.legado.app.utils.dpToPx
|
||||
import io.legado.app.utils.hideSoftInput
|
||||
import io.legado.app.utils.isAbsUrl
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.shouldHideSoftInput
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.startActivity
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* 书架管理
|
||||
*/
|
||||
class BookshelfManageActivity :
|
||||
VMBaseActivity<ActivityArrangeBookBinding, BookshelfManageViewModel>(),
|
||||
PopupMenu.OnMenuItemClickListener,
|
||||
SelectActionBar.CallBack,
|
||||
BookAdapter.CallBack,
|
||||
SourcePickerDialog.Callback,
|
||||
GroupSelectDialog.CallBack {
|
||||
|
||||
override val binding by viewBinding(ActivityArrangeBookBinding::inflate)
|
||||
override val viewModel by viewModels<BookshelfManageViewModel>()
|
||||
override val groupList: ArrayList<BookGroup> = arrayListOf()
|
||||
private val groupRequestCode = 22
|
||||
private val addToGroupRequestCode = 34
|
||||
private val adapter by lazy { BookAdapter(this, this) }
|
||||
private val itemTouchCallback by lazy { ItemTouchCallback(adapter) }
|
||||
private var booksFlowJob: Job? = null
|
||||
private var menu: Menu? = null
|
||||
private val searchView: SearchView by lazy {
|
||||
binding.titleBar.findViewById(R.id.search_view)
|
||||
}
|
||||
private var books: List<Book>? = null
|
||||
private val waitDialog by lazy { WaitDialog(this) }
|
||||
private val exportDir = registerForActivityResult(HandleFileContract()) {
|
||||
it.uri?.let { uri ->
|
||||
alert(R.string.export_success) {
|
||||
if (uri.toString().isAbsUrl()) {
|
||||
setMessage(DirectLinkUpload.getSummary())
|
||||
}
|
||||
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
|
||||
editView.hint = getString(R.string.path)
|
||||
editView.setText(uri.toString())
|
||||
}
|
||||
customView { alertBinding.root }
|
||||
okButton {
|
||||
sendToClip(uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.groupId = intent.getLongExtra("groupId", -1)
|
||||
lifecycleScope.launch {
|
||||
viewModel.groupName = withContext(IO) {
|
||||
appDb.bookGroupDao.getByID(viewModel.groupId)?.groupName
|
||||
?: getString(R.string.no_group)
|
||||
}
|
||||
upTitle()
|
||||
}
|
||||
initSearchView()
|
||||
initRecyclerView()
|
||||
initOtherView()
|
||||
initGroupData()
|
||||
upBookDataByGroupId()
|
||||
}
|
||||
|
||||
override fun observeLiveBus() {
|
||||
viewModel.batchChangeSourceState.observe(this) {
|
||||
if (it) {
|
||||
waitDialog.setText(R.string.change_source_batch)
|
||||
waitDialog.show()
|
||||
} else {
|
||||
waitDialog.dismiss()
|
||||
}
|
||||
}
|
||||
viewModel.batchChangeSourceProcessLiveData.observe(this) {
|
||||
waitDialog.setText(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.bookshelf_manage, menu)
|
||||
return super.onCompatCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
this.menu = menu
|
||||
menu.findItem(R.id.menu_open_book_info_by_click_title)?.isChecked =
|
||||
AppConfig.openBookInfoByClickTitle
|
||||
upMenu()
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun selectAll(selectAll: Boolean) {
|
||||
adapter.selectAll(selectAll)
|
||||
}
|
||||
|
||||
override fun revertSelection() {
|
||||
adapter.revertSelection()
|
||||
}
|
||||
|
||||
override fun onClickSelectBarMainAction() {
|
||||
selectGroup(groupRequestCode, 0)
|
||||
}
|
||||
|
||||
private fun upTitle() {
|
||||
searchView.queryHint = getString(R.string.screen) + " • " + viewModel.groupName
|
||||
}
|
||||
|
||||
private fun initSearchView() {
|
||||
//searchView.applyTint(primaryTextColor)
|
||||
searchView.isSubmitButtonEnabled = true
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
upBookData()
|
||||
return false
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private fun initRecyclerView() {
|
||||
//binding.recyclerView.setEdgeEffectColor(primaryColor)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerView.addItemDecoration(VerticalDivider(this))
|
||||
binding.recyclerView.adapter = adapter
|
||||
itemTouchCallback.isCanDrag = AppConfig.bookshelfSort == 3
|
||||
val dragSelectTouchHelper: DragSelectTouchHelper =
|
||||
DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
|
||||
dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
|
||||
// When this page is opened, it is in selection mode
|
||||
dragSelectTouchHelper.activeSlideSelect()
|
||||
// Note: need judge selection first, so add ItemTouchHelper after it.
|
||||
ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
|
||||
}
|
||||
|
||||
private fun initOtherView() {
|
||||
binding.selectActionBar.setMainActionText(R.string.move_to_group)
|
||||
binding.selectActionBar.inflateMenu(R.menu.bookshelf_menage_sel)
|
||||
binding.selectActionBar.setOnMenuItemClickListener(this)
|
||||
binding.selectActionBar.setCallBack(this)
|
||||
waitDialog.setOnCancelListener {
|
||||
viewModel.batchChangeSourceCoroutine?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun initGroupData() {
|
||||
lifecycleScope.launch {
|
||||
appDb.bookGroupDao.flowAll().catch {
|
||||
AppLog.put("书架管理界面获取分组数据失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO).conflate().collect {
|
||||
groupList.clear()
|
||||
groupList.addAll(it)
|
||||
adapter.notifyDataSetChanged()
|
||||
upMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookDataByGroupId() {
|
||||
booksFlowJob?.cancel()
|
||||
booksFlowJob = lifecycleScope.launch {
|
||||
val bookSort = AppConfig.getBookSortByGroupId(viewModel.groupId)
|
||||
appDb.bookDao.flowByGroup(viewModel.groupId).map { list ->
|
||||
when (bookSort) {
|
||||
1 -> list.sortedByDescending {
|
||||
it.latestChapterTime
|
||||
}
|
||||
|
||||
2 -> list.sortedWith { o1, o2 ->
|
||||
o1.name.cnCompare(o2.name)
|
||||
}
|
||||
|
||||
3 -> list.sortedBy {
|
||||
it.order
|
||||
}
|
||||
|
||||
4 -> list.sortedByDescending {
|
||||
max(it.latestChapterTime, it.durChapterTime)
|
||||
}
|
||||
|
||||
else -> list.sortedByDescending {
|
||||
it.durChapterTime
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
AppLog.put("书架管理界面获取书籍列表失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO)
|
||||
.conflate().collect {
|
||||
books = it
|
||||
upBookData()
|
||||
itemTouchCallback.isCanDrag = bookSort == 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upBookData() {
|
||||
books?.let { books ->
|
||||
val searchKey = searchView.query
|
||||
if (searchKey.isNullOrEmpty()) {
|
||||
adapter.setItems(books)
|
||||
} else {
|
||||
books.filter {
|
||||
it.contains(searchKey.toString())
|
||||
}.let {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
|
||||
R.id.menu_open_book_info_by_click_title -> {
|
||||
AppConfig.openBookInfoByClickTitle = !item.isChecked
|
||||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
|
||||
R.id.menu_export_all_use_book_source -> viewModel.saveAllUseBookSourceToFile { file ->
|
||||
exportDir.launch {
|
||||
mode = HandleFileContract.EXPORT
|
||||
fileData = HandleFileContract.FileData(
|
||||
"bookSource.json",
|
||||
file,
|
||||
"application/json"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> if (item.groupId == R.id.menu_group) {
|
||||
viewModel.groupName = item.title.toString()
|
||||
upTitle()
|
||||
viewModel.groupId =
|
||||
appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
|
||||
upBookDataByGroupId()
|
||||
}
|
||||
}
|
||||
return super.onCompatOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_del_selection -> alertDelSelection()
|
||||
R.id.menu_update_enable ->
|
||||
viewModel.upCanUpdate(adapter.selection, true)
|
||||
|
||||
R.id.menu_update_disable ->
|
||||
viewModel.upCanUpdate(adapter.selection, false)
|
||||
|
||||
R.id.menu_add_to_group -> selectGroup(addToGroupRequestCode, 0)
|
||||
R.id.menu_change_source -> showDialogFragment<SourcePickerDialog>()
|
||||
R.id.menu_clear_cache -> viewModel.clearCache(adapter.selection)
|
||||
R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun upMenu() {
|
||||
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
|
||||
subMenu.removeGroup(R.id.menu_group)
|
||||
groupList.forEach { bookGroup ->
|
||||
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun alertDelSelection() {
|
||||
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
|
||||
val checkBox = CheckBox(this@BookshelfManageActivity).apply {
|
||||
setText(R.string.delete_book_file)
|
||||
isChecked = LocalConfig.deleteBookOriginal
|
||||
}
|
||||
val view = LinearLayout(this@BookshelfManageActivity).apply {
|
||||
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
|
||||
addView(checkBox)
|
||||
}
|
||||
customView { view }
|
||||
okButton {
|
||||
LocalConfig.deleteBookOriginal = checkBox.isChecked
|
||||
viewModel.deleteBook(adapter.selection, checkBox.isChecked)
|
||||
}
|
||||
noButton()
|
||||
}
|
||||
}
|
||||
|
||||
override fun selectGroup(requestCode: Int, groupId: Long) {
|
||||
showDialogFragment(
|
||||
GroupSelectDialog(groupId, requestCode)
|
||||
)
|
||||
}
|
||||
|
||||
override fun upGroup(requestCode: Int, groupId: Long) {
|
||||
when (requestCode) {
|
||||
groupRequestCode -> adapter.selection.let { books ->
|
||||
val array = Array(books.size) {
|
||||
books[it].copy(group = groupId)
|
||||
}
|
||||
viewModel.updateBook(*array)
|
||||
}
|
||||
|
||||
adapter.groupRequestCode -> {
|
||||
adapter.actionItem?.let {
|
||||
viewModel.updateBook(it.copy(group = groupId))
|
||||
}
|
||||
}
|
||||
|
||||
addToGroupRequestCode -> adapter.selection.let { books ->
|
||||
val array = Array(books.size) { index ->
|
||||
val book = books[index]
|
||||
book.copy(group = book.group or groupId)
|
||||
}
|
||||
viewModel.updateBook(*array)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun upSelectCount() {
|
||||
binding.selectActionBar.upCountView(adapter.selection.size, adapter.getItems().size)
|
||||
}
|
||||
|
||||
override fun updateBook(vararg book: Book) {
|
||||
viewModel.updateBook(*book)
|
||||
}
|
||||
|
||||
override fun deleteBook(book: Book) {
|
||||
alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
|
||||
var checkBox: CheckBox? = null
|
||||
if (book.isLocal) {
|
||||
checkBox = CheckBox(this@BookshelfManageActivity).apply {
|
||||
setText(R.string.delete_book_file)
|
||||
isChecked = LocalConfig.deleteBookOriginal
|
||||
}
|
||||
val view = LinearLayout(this@BookshelfManageActivity).apply {
|
||||
setPadding(16.dpToPx(), 0, 16.dpToPx(), 0)
|
||||
addView(checkBox)
|
||||
}
|
||||
customView { view }
|
||||
}
|
||||
okButton {
|
||||
if (checkBox != null) {
|
||||
LocalConfig.deleteBookOriginal = checkBox.isChecked
|
||||
}
|
||||
viewModel.deleteBook(listOf(book), LocalConfig.deleteBookOriginal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun openBook(book: Book) {
|
||||
startActivity<BookInfoActivity> {
|
||||
putExtra("name", book.name)
|
||||
putExtra("author", book.author)
|
||||
putExtra("bookUrl", book.bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sourceOnClick(source: BookSource) {
|
||||
viewModel.changeSource(adapter.selection, source)
|
||||
viewModel.batchChangeSourceState.value = true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -82,7 +82,6 @@ import io.legado.app.help.book.tryParesExportFileName
|
||||
import io.legado.app.service.ExportBookService
|
||||
import io.legado.app.ui.about.AppLogSheet
|
||||
import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.book.info.ChangeSourceSheet
|
||||
import io.legado.app.ui.book.info.GroupSelectSheet
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
@@ -141,19 +140,25 @@ private data class BookshelfManageListState(
|
||||
fun BookshelfManageRouteScreen(
|
||||
groupId: Long,
|
||||
onBackClick: () -> Unit,
|
||||
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
|
||||
viewModel: BookshelfManageScreenViewModel = koinViewModel()
|
||||
) {
|
||||
LaunchedEffect(groupId) {
|
||||
viewModel.dispatch(BookshelfManageScreenIntent.Initialize(groupId))
|
||||
}
|
||||
BookshelfManageScreen(viewModel = viewModel, onBackClick = onBackClick)
|
||||
BookshelfManageScreen(
|
||||
viewModel = viewModel,
|
||||
onBackClick = onBackClick,
|
||||
onOpenBookInfo = onOpenBookInfo
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun BookshelfManageScreen(
|
||||
viewModel: BookshelfManageScreenViewModel,
|
||||
onBackClick: () -> Unit
|
||||
onBackClick: () -> Unit,
|
||||
onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -205,18 +210,8 @@ private fun BookshelfManageScreen(
|
||||
val resultAnalyzedText = stringResource(R.string.result_analyzed)
|
||||
val errorScopeInputText = stringResource(R.string.error_scope_input)
|
||||
val noGroupText = stringResource(R.string.no_group)
|
||||
val exportFileNameHintText = "书名:《{name}》 作者:{author}"
|
||||
val exportFileNameHelpText = """
|
||||
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
|
||||
可以在字段前后加任意字符。
|
||||
|
||||
示例:
|
||||
书名:《{name}》 作者:{author}
|
||||
输出:书名:《三体》 作者:刘慈欣
|
||||
|
||||
书名:《{name}》-作者:{author}_备注
|
||||
输出:书名:《三体》-作者:刘慈欣_备注
|
||||
""".trimIndent()
|
||||
val exportFileNameHintText = stringResource(R.string.export_file_name_template_hint)
|
||||
val exportFileNameHelpText = stringResource(R.string.export_file_name_template_help)
|
||||
val booksByUrl = remember(state.books) { state.books.associateBy { it.bookUrl } }
|
||||
val userGroups = remember(state.groupList) { state.groupList.filter { it.groupId > 0L } }
|
||||
|
||||
@@ -331,10 +326,8 @@ private fun BookshelfManageScreen(
|
||||
when (effect) {
|
||||
is BookshelfManageScreenEffect.ShowMessage -> context.toastOnUi(effect.message)
|
||||
is BookshelfManageScreenEffect.NotifyBookChanged -> Unit
|
||||
is BookshelfManageScreenEffect.OpenBookInfo -> context.startActivity<BookInfoActivity> {
|
||||
putExtra("name", effect.name)
|
||||
putExtra("author", effect.author)
|
||||
putExtra("bookUrl", effect.bookUrl)
|
||||
is BookshelfManageScreenEffect.OpenBookInfo -> {
|
||||
onOpenBookInfo(effect.name, effect.author, effect.bookUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,8 +679,17 @@ private fun BookshelfManageScreen(
|
||||
val cacheCount = remember(renderVersion, book.bookUrl) {
|
||||
viewModel.getCacheCount(book.bookUrl) ?: 0
|
||||
}
|
||||
val isDownloading = remember(renderVersion, book.bookUrl) {
|
||||
viewModel.isBookDownloading(book.bookUrl)
|
||||
val downloadState = state.downloadStates[book.bookUrl]
|
||||
val isPreparingDownload = state.pendingDownloadBookUrls.contains(book.bookUrl)
|
||||
val waitingDownloadCount = downloadState?.waitingCount ?: 0
|
||||
val runningDownloadCount = downloadState?.runningIndices?.size ?: 0
|
||||
val isDownloadingInCacheModel = viewModel.isBookDownloading(book.bookUrl)
|
||||
val isDownloading = isPreparingDownload ||
|
||||
waitingDownloadCount > 0 ||
|
||||
runningDownloadCount > 0 ||
|
||||
isDownloadingInCacheModel
|
||||
val downloadFailureText = state.downloadFailureMessages[book.bookUrl]?.let {
|
||||
stringResource(R.string.cache_download_failed, it)
|
||||
}
|
||||
val isSelected = selectedBookUrls.contains(book.bookUrl)
|
||||
val exportMsg = remember(renderVersion, book.bookUrl) {
|
||||
@@ -761,8 +763,20 @@ private fun BookshelfManageScreen(
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (downloadFailureText != null) {
|
||||
AppText(
|
||||
text = downloadFailureText,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = LegadoTheme.typography.labelSmall,
|
||||
color = LegadoTheme.colorScheme.error,
|
||||
maxLines = 1
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SmallTonalIconButton(
|
||||
onClick = {
|
||||
@@ -912,7 +926,7 @@ private fun BookshelfManageScreen(
|
||||
oldBookUrl = book.bookUrl,
|
||||
source = source,
|
||||
book = newBook,
|
||||
chapters = toc,
|
||||
chapterCount = toc.size,
|
||||
)
|
||||
)
|
||||
manualSearchPreviewBook = null
|
||||
|
||||
@@ -27,6 +27,7 @@ import io.legado.app.help.book.isAudio
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.book.removeType
|
||||
import io.legado.app.model.CacheBook
|
||||
import io.legado.app.model.cache.CacheBookDownloadState
|
||||
import io.legado.app.help.config.LocalConfig
|
||||
import io.legado.app.service.ExportBookService
|
||||
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
|
||||
@@ -72,6 +73,9 @@ data class BookshelfManageScreenUiState(
|
||||
val bookSort: Int = BookshelfConfig.bookshelfSort,
|
||||
val bookSortOrder: Int = BookshelfConfig.bookshelfSortOrder,
|
||||
val isDownloadRunning: Boolean = false,
|
||||
val pendingDownloadBookUrls: Set<String> = emptySet(),
|
||||
val downloadStates: Map<String, CacheBookDownloadState> = emptyMap(),
|
||||
val downloadFailureMessages: Map<String, String> = emptyMap(),
|
||||
val isChangingSource: Boolean = false,
|
||||
val changeSourceProgress: String? = null,
|
||||
val changeSourceMessage: String? = null,
|
||||
@@ -118,7 +122,7 @@ sealed interface BookshelfManageScreenIntent {
|
||||
val oldBookUrl: String,
|
||||
val source: BookSource,
|
||||
val book: Book,
|
||||
val chapters: List<BookChapter>,
|
||||
val chapterCount: Int,
|
||||
) : BookshelfManageScreenIntent
|
||||
data class AddPreviewItemToShelf(val oldBookUrl: String) : BookshelfManageScreenIntent
|
||||
data class OpenBookInfoPreview(val book: Book, val inBookshelf: Boolean) : BookshelfManageScreenIntent
|
||||
@@ -220,7 +224,7 @@ class BookshelfManageScreenViewModel(
|
||||
intent.oldBookUrl,
|
||||
intent.source,
|
||||
intent.book,
|
||||
intent.chapters
|
||||
intent.chapterCount
|
||||
)
|
||||
|
||||
is BookshelfManageScreenIntent.AddPreviewItemToShelf -> addPreviewItemToShelf(intent.oldBookUrl)
|
||||
@@ -380,8 +384,33 @@ class BookshelfManageScreenViewModel(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
CacheBook.downloadStateFlow.collect { state ->
|
||||
state.books.keys.forEach { bookUrl ->
|
||||
CacheBook.downloadStateFlow.collect { downloadState ->
|
||||
_uiState.update { state ->
|
||||
val successfulBookUrls = downloadState.books
|
||||
.filterValues {
|
||||
it.successCount > 0 &&
|
||||
it.failedIndices.isEmpty() &&
|
||||
it.failureMessage == null
|
||||
}
|
||||
.keys
|
||||
val failureMessages = downloadState.books.mapNotNull { (bookUrl, bookState) ->
|
||||
val message = bookState.failureMessage ?: if (bookState.failedIndices.isNotEmpty()) {
|
||||
"${bookState.failedIndices.size} 章"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
message?.let { bookUrl to it }
|
||||
}.toMap()
|
||||
state.copy(
|
||||
isDownloadRunning = downloadState.isRunning,
|
||||
pendingDownloadBookUrls = state.pendingDownloadBookUrls - downloadState.books.keys,
|
||||
downloadStates = downloadState.books,
|
||||
downloadFailureMessages = (
|
||||
state.downloadFailureMessages - successfulBookUrls
|
||||
) + failureMessages,
|
||||
)
|
||||
}
|
||||
downloadState.books.keys.forEach { bookUrl ->
|
||||
scheduleDownloadStatusRefresh(bookUrl)
|
||||
}
|
||||
scheduleDownloadStatusRefresh()
|
||||
@@ -509,9 +538,13 @@ class BookshelfManageScreenViewModel(
|
||||
}
|
||||
|
||||
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
|
||||
val bookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
|
||||
_uiState.update {
|
||||
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
|
||||
}
|
||||
execute {
|
||||
batchCacheDownloadUseCase.execute(
|
||||
bookUrls = books.map { it.bookUrl }.toSet(),
|
||||
bookUrls = bookUrls,
|
||||
downloadAllChapters = downloadAllChapters,
|
||||
skipAudioBooks = true
|
||||
)
|
||||
@@ -522,12 +555,41 @@ class BookshelfManageScreenViewModel(
|
||||
|
||||
private fun toggleBookDownload(book: Book) {
|
||||
if (book.isLocal) return
|
||||
if (isBookDownloading(book.bookUrl)) {
|
||||
if (isBookDownloading(book.bookUrl) || uiState.value.pendingDownloadBookUrls.contains(book.bookUrl)) {
|
||||
CacheBook.remove(context, book.bookUrl)
|
||||
_uiState.update {
|
||||
it.copy(pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl)
|
||||
}
|
||||
syncDownloadRunning()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingDownloadBookUrls = it.pendingDownloadBookUrls + book.bookUrl,
|
||||
downloadFailureMessages = it.downloadFailureMessages - book.bookUrl
|
||||
)
|
||||
}
|
||||
execute {
|
||||
cacheBookChaptersUseCase.executeRange(book.bookUrl, 0, book.lastChapterIndex)
|
||||
}.onSuccess { count ->
|
||||
if (count <= 0) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
|
||||
downloadFailureMessages = it.downloadFailureMessages +
|
||||
(book.bookUrl to "没有可缓存的章节")
|
||||
)
|
||||
}
|
||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("没有可缓存的章节"))
|
||||
}
|
||||
}.onError { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingDownloadBookUrls = it.pendingDownloadBookUrls - book.bookUrl,
|
||||
downloadFailureMessages = it.downloadFailureMessages +
|
||||
(book.bookUrl to (error.localizedMessage ?: "未知错误"))
|
||||
)
|
||||
}
|
||||
_effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("缓存失败\n${error.localizedMessage}"))
|
||||
}.onFinally {
|
||||
syncDownloadRunning()
|
||||
}
|
||||
@@ -599,6 +661,9 @@ class BookshelfManageScreenViewModel(
|
||||
|
||||
private fun downloadBooks(bookUrls: Set<String>, downloadAllChapters: Boolean) {
|
||||
if (bookUrls.isEmpty()) return
|
||||
_uiState.update {
|
||||
it.copy(downloadFailureMessages = it.downloadFailureMessages - bookUrls)
|
||||
}
|
||||
execute {
|
||||
batchCacheDownloadUseCase.execute(
|
||||
bookUrls = bookUrls,
|
||||
@@ -652,7 +717,7 @@ class BookshelfManageScreenViewModel(
|
||||
return
|
||||
}
|
||||
execute {
|
||||
val concurrency = OtherConfig.threadCount.coerceIn(1, 4)
|
||||
val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isChangingSource = true,
|
||||
@@ -775,19 +840,19 @@ class BookshelfManageScreenViewModel(
|
||||
oldBookUrl: String,
|
||||
source: BookSource,
|
||||
book: Book,
|
||||
chapters: List<BookChapter>,
|
||||
chapterCount: Int,
|
||||
) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
batchChangePreviewItems = state.batchChangePreviewItems.map { item ->
|
||||
if (item.oldBook.bookUrl == oldBookUrl) {
|
||||
book.totalChapterNum = chapters.size
|
||||
book.totalChapterNum = chapterCount
|
||||
item.copy(
|
||||
candidates = listOf(
|
||||
BatchChangeSourceCandidate(
|
||||
source = source,
|
||||
book = book,
|
||||
chapterCount = chapters.size
|
||||
chapterCount = chapterCount
|
||||
)
|
||||
) +
|
||||
item.candidates,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package io.legado.app.ui.book.manage
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.constant.BookType
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.help.book.BookHelp
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.book.removeType
|
||||
import io.legado.app.help.config.AppConfig
|
||||
import io.legado.app.help.coroutine.Coroutine
|
||||
import io.legado.app.model.SourceCallBack
|
||||
import io.legado.app.model.localBook.LocalBook
|
||||
import io.legado.app.model.webBook.WebBook
|
||||
import io.legado.app.utils.FileUtils
|
||||
import io.legado.app.utils.GSON
|
||||
import io.legado.app.utils.stackTraceStr
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import io.legado.app.utils.writeToOutputStream
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
|
||||
|
||||
class BookshelfManageViewModel(application: Application) : BaseViewModel(application) {
|
||||
var groupId: Long = -1L
|
||||
var groupName: String? = null
|
||||
val batchChangeSourceState = MutableLiveData<Boolean>()
|
||||
val batchChangeSourceProcessLiveData = MutableLiveData<String>()
|
||||
var batchChangeSourceCoroutine: Coroutine<Unit>? = null
|
||||
|
||||
fun upCanUpdate(books: List<Book>, canUpdate: Boolean) {
|
||||
execute {
|
||||
val array = Array(books.size) {
|
||||
books[it].copy(canUpdate = canUpdate).apply {
|
||||
if (!canUpdate) {
|
||||
removeType(BookType.updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
appDb.bookDao.update(*array)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBook(vararg book: Book) {
|
||||
execute {
|
||||
appDb.bookDao.update(*book)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBook(books: List<Book>, deleteOriginal: Boolean = false) {
|
||||
execute {
|
||||
books.forEach {
|
||||
if (it.isLocal) {
|
||||
LocalBook.deleteBook(it, deleteOriginal)
|
||||
} else {
|
||||
val source = appDb.bookSourceDao.getBookSource(it.origin)
|
||||
SourceCallBack.callBackBook(SourceCallBack.DEL_BOOK_SHELF, source, it)
|
||||
}
|
||||
appDb.bookChapterDao.delByBook(it.bookUrl)
|
||||
}
|
||||
appDb.bookDao.delete(*books.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAllUseBookSourceToFile(success: (file: File) -> Unit) {
|
||||
execute {
|
||||
val path = "${context.filesDir}/shareBookSource.json"
|
||||
FileUtils.delete(path)
|
||||
val file = FileUtils.createFileWithReplace(path)
|
||||
val sources = appDb.bookDao.getAllUseBookSource()
|
||||
file.outputStream().buffered().use {
|
||||
GSON.writeToOutputStream(it, sources)
|
||||
}
|
||||
file
|
||||
}.onSuccess {
|
||||
success.invoke(it)
|
||||
}.onError {
|
||||
context.toastOnUi(it.stackTraceStr)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeSource(books: List<Book>, source: BookSource) {
|
||||
batchChangeSourceCoroutine?.cancel()
|
||||
batchChangeSourceCoroutine = execute {
|
||||
val changeSourceDelay = AppConfig.batchChangeSourceDelay * 1000L
|
||||
books.forEachIndexed { index, book ->
|
||||
batchChangeSourceProcessLiveData.postValue("${index + 1} / ${books.size}")
|
||||
if (book.isLocal) return@forEachIndexed
|
||||
if (book.origin == source.bookSourceUrl) return@forEachIndexed
|
||||
val newBook = WebBook.preciseSearchAwait(source, book.name, book.author)
|
||||
.onFailure {
|
||||
AppLog.put("搜索书籍出错\n${it.localizedMessage}", it, true)
|
||||
}.getOrNull() ?: return@forEachIndexed
|
||||
kotlin.runCatching {
|
||||
if (newBook.tocUrl.isEmpty()) {
|
||||
WebBook.getBookInfoAwait(source, newBook)
|
||||
}
|
||||
}.onFailure {
|
||||
AppLog.put("获取书籍详情出错\n${it.localizedMessage}", it, true)
|
||||
return@forEachIndexed
|
||||
}
|
||||
WebBook.getChapterListAwait(source, newBook)
|
||||
.onFailure {
|
||||
AppLog.put("获取目录出错\n${it.localizedMessage}", it, true)
|
||||
}.getOrNull()?.let { toc ->
|
||||
book.migrateTo(newBook, toc)
|
||||
book.removeType(BookType.updateError)
|
||||
appDb.bookDao.insert(newBook)
|
||||
appDb.bookChapterDao.insert(*toc.toTypedArray())
|
||||
}
|
||||
delay(changeSourceDelay)
|
||||
}
|
||||
}.onStart {
|
||||
batchChangeSourceState.postValue(true)
|
||||
}.onFinally {
|
||||
batchChangeSourceState.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache(books: List<Book>) {
|
||||
execute {
|
||||
books.forEach {
|
||||
BookHelp.clearCache(it)
|
||||
}
|
||||
}.onSuccess {
|
||||
context.toastOnUi(R.string.clear_cache_success)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package io.legado.app.ui.book.manage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import io.legado.app.R
|
||||
import io.legado.app.base.BaseDialogFragment
|
||||
import io.legado.app.base.adapter.ItemViewHolder
|
||||
import io.legado.app.base.adapter.RecyclerAdapter
|
||||
import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.databinding.DialogSourcePickerBinding
|
||||
import io.legado.app.databinding.Item1lineTextBinding
|
||||
import io.legado.app.help.config.AppConfig
|
||||
//import io.legado.app.lib.theme.primaryColor
|
||||
//import io.legado.app.lib.theme.primaryTextColor
|
||||
import io.legado.app.ui.widget.number.NumberPickerDialog
|
||||
import io.legado.app.utils.dpToPx
|
||||
import io.legado.app.utils.setLayout
|
||||
import io.legado.app.utils.viewbindingdelegate.viewBinding
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.views.onClick
|
||||
|
||||
/**
|
||||
* 书源选择
|
||||
*/
|
||||
class SourcePickerDialog : BaseDialogFragment(R.layout.dialog_source_picker),
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private val binding by viewBinding(DialogSourcePickerBinding::bind)
|
||||
private val searchView: SearchView by lazy {
|
||||
binding.toolBar.findViewById(R.id.search_view)
|
||||
}
|
||||
private val toolBar: Toolbar by lazy {
|
||||
binding.toolBar.toolbar
|
||||
}
|
||||
private val adapter by lazy {
|
||||
SourceAdapter(requireContext())
|
||||
}
|
||||
private var sourceFlowJob: Job? = null
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||
initView()
|
||||
initData()
|
||||
initMenu()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
//binding.toolBar.setBackgroundColor(primaryColor)
|
||||
binding.toolBar.title = "选择书源"
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
//searchView.applyTint(primaryTextColor)
|
||||
searchView.isSubmitButtonEnabled = true
|
||||
searchView.queryHint = getString(R.string.search_book_source)
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
initData(newText)
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun initData(searchKey: String? = null) {
|
||||
sourceFlowJob?.cancel()
|
||||
sourceFlowJob = lifecycleScope.launch {
|
||||
when {
|
||||
searchKey.isNullOrEmpty() -> appDb.bookSourceDao.flowEnabled()
|
||||
else -> appDb.bookSourceDao.flowSearchEnabled(searchKey)
|
||||
}.catch {
|
||||
AppLog.put("书源选择界面获取书源数据失败\n${it.localizedMessage}", it)
|
||||
}.flowOn(IO).collect {
|
||||
adapter.setItems(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initMenu() {
|
||||
toolBar.setOnMenuItemClickListener(this)
|
||||
toolBar.inflateMenu(R.menu.source_picker)
|
||||
//toolBar.menu.applyTint(requireContext())
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
when (item?.itemId) {
|
||||
R.id.menu_change_source_delay -> NumberPickerDialog(requireContext())
|
||||
.setTitle(getString(R.string.change_source_delay))
|
||||
.setMaxValue(9999)
|
||||
.setMinValue(0)
|
||||
.setValue(AppConfig.batchChangeSourceDelay)
|
||||
.show {
|
||||
AppConfig.batchChangeSourceDelay = it
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
inner class SourceAdapter(context: Context) :
|
||||
RecyclerAdapter<BookSourcePart, Item1lineTextBinding>(context) {
|
||||
|
||||
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
|
||||
return Item1lineTextBinding.inflate(inflater, parent, false).apply {
|
||||
root.setPadding(16.dpToPx())
|
||||
}
|
||||
}
|
||||
|
||||
override fun convert(
|
||||
holder: ItemViewHolder,
|
||||
binding: Item1lineTextBinding,
|
||||
item: BookSourcePart,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
binding.textView.text = item.getDisPlayNameGroup()
|
||||
}
|
||||
|
||||
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
|
||||
binding.root.onClick {
|
||||
getItemByLayoutPosition(holder.layoutPosition)?.let {
|
||||
it.getBookSource()?.let { source ->
|
||||
callback?.sourceOnClick(source)
|
||||
}
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val callback: Callback?
|
||||
get() {
|
||||
return (parentFragment as? Callback) ?: activity as? Callback
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun sourceOnClick(source: BookSource)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.legado.app.base.BaseComposeActivity
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.book.source.manage.BookSourceActivity
|
||||
import io.legado.app.ui.main.MainActivity
|
||||
import io.legado.app.utils.startActivity
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
@@ -41,11 +41,14 @@ class SearchActivity : BaseComposeActivity() {
|
||||
viewModel = viewModel,
|
||||
onBack = { finish() },
|
||||
onOpenBookInfo = { name, author, bookUrl ->
|
||||
startActivity<BookInfoActivity> {
|
||||
putExtra("name", name)
|
||||
putExtra("author", author)
|
||||
putExtra("bookUrl", bookUrl)
|
||||
}
|
||||
startActivity(
|
||||
MainActivity.createBookInfoIntent(
|
||||
context = this,
|
||||
name = name,
|
||||
author = author,
|
||||
bookUrl = bookUrl
|
||||
)
|
||||
)
|
||||
},
|
||||
onOpenSourceManage = {
|
||||
startActivity<BookSourceActivity>()
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.R
|
||||
import io.legado.app.model.CacheBook
|
||||
import io.legado.app.service.WebService
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||
@@ -366,6 +367,7 @@ fun OtherConfigScreen(
|
||||
|
||||
SliderSettingItem(
|
||||
title = stringResource(R.string.threads_num_title),
|
||||
description = stringResource(R.string.threads_num_summary),
|
||||
value = OtherConfig.threadCount.toFloat(),
|
||||
defaultValue = 8f,
|
||||
valueRange = 1f..256f,
|
||||
@@ -375,10 +377,15 @@ fun OtherConfigScreen(
|
||||
SliderSettingItem(
|
||||
title = stringResource(R.string.cache_book_threads_num_title),
|
||||
description = stringResource(R.string.cache_book_threads_num_summary),
|
||||
value = OtherConfig.cacheBookThreadCount.toFloat(),
|
||||
defaultValue = 16f,
|
||||
valueRange = 1f..256f,
|
||||
onValueChange = { OtherConfig.cacheBookThreadCount = it.toInt() }
|
||||
value = OtherConfig.cacheBookThreadCount
|
||||
.coerceIn(1, CacheBook.maxDownloadConcurrency)
|
||||
.toFloat(),
|
||||
defaultValue = CacheBook.maxDownloadConcurrency.toFloat(),
|
||||
valueRange = 1f..CacheBook.maxDownloadConcurrency.toFloat(),
|
||||
onValueChange = {
|
||||
OtherConfig.cacheBookThreadCount =
|
||||
it.toInt().coerceIn(1, CacheBook.maxDownloadConcurrency)
|
||||
}
|
||||
)
|
||||
|
||||
SwitchSettingItem(
|
||||
|
||||
@@ -41,7 +41,9 @@ import io.legado.app.service.WebService
|
||||
import io.legado.app.ui.about.CrashLogsDialog
|
||||
import io.legado.app.ui.about.UpdateDialog
|
||||
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.book.explore.ExploreShowScreen
|
||||
import io.legado.app.ui.book.info.BookInfoRouteScreen
|
||||
import io.legado.app.ui.book.info.BookInfoViewModel
|
||||
import io.legado.app.ui.book.import.local.ImportBookScreen
|
||||
import io.legado.app.ui.book.import.remote.RemoteBookScreen
|
||||
import io.legado.app.ui.book.search.SearchIntent
|
||||
@@ -64,6 +66,7 @@ import io.legado.app.ui.rss.read.MainRouteRssRead
|
||||
import io.legado.app.ui.rss.read.RssReadRouteScreen
|
||||
import io.legado.app.ui.welcome.WelcomeActivity
|
||||
import io.legado.app.ui.widget.dialog.TextDialog
|
||||
import io.legado.app.ui.widget.dialog.VariableDialog
|
||||
import io.legado.app.utils.getPrefBoolean
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import io.legado.app.utils.startActivity
|
||||
@@ -80,7 +83,7 @@ import kotlin.coroutines.suspendCoroutine
|
||||
/**
|
||||
* 主界面
|
||||
*/
|
||||
open class MainActivity : BaseComposeActivity() {
|
||||
open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_START_ROUTE = "startRoute"
|
||||
@@ -96,11 +99,19 @@ open class MainActivity : BaseComposeActivity() {
|
||||
private const val ROUTE_CACHE = "cache"
|
||||
private const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage"
|
||||
private const val ROUTE_SEARCH = "search"
|
||||
private const val ROUTE_BOOK_INFO = "book/info"
|
||||
private const val ROUTE_EXPLORE_SHOW = "explore/show"
|
||||
private const val ROUTE_RSS_SORT = "rss/sort"
|
||||
private const val ROUTE_RSS_READ = "rss/read"
|
||||
private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id"
|
||||
private const val EXTRA_SEARCH_KEY = "extra_search_key"
|
||||
private const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
|
||||
private const val EXTRA_BOOK_NAME = "name"
|
||||
private const val EXTRA_BOOK_AUTHOR = "author"
|
||||
private const val EXTRA_BOOK_URL = "bookUrl"
|
||||
private const val EXTRA_EXPLORE_NAME = "exploreName"
|
||||
private const val EXTRA_SOURCE_URL = "sourceUrl"
|
||||
private const val EXTRA_EXPLORE_URL = "exploreUrl"
|
||||
|
||||
private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
|
||||
private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
|
||||
@@ -196,6 +207,34 @@ open class MainActivity : BaseComposeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createBookInfoIntent(
|
||||
context: Context,
|
||||
name: String? = null,
|
||||
author: String? = null,
|
||||
bookUrl: String
|
||||
): Intent {
|
||||
return createLauncherIntent(context).apply {
|
||||
putExtra(EXTRA_START_ROUTE, ROUTE_BOOK_INFO)
|
||||
putExtra(EXTRA_BOOK_NAME, name)
|
||||
putExtra(EXTRA_BOOK_AUTHOR, author)
|
||||
putExtra(EXTRA_BOOK_URL, bookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
fun createExploreShowIntent(
|
||||
context: Context,
|
||||
exploreName: String? = null,
|
||||
sourceUrl: String,
|
||||
exploreUrl: String? = null
|
||||
): Intent {
|
||||
return createLauncherIntent(context).apply {
|
||||
putExtra(EXTRA_START_ROUTE, ROUTE_EXPLORE_SHOW)
|
||||
putExtra(EXTRA_EXPLORE_NAME, exploreName)
|
||||
putExtra(EXTRA_SOURCE_URL, sourceUrl)
|
||||
putExtra(EXTRA_EXPLORE_URL, exploreUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeForConfigTag(configTag: String?): String {
|
||||
return when (configTag) {
|
||||
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
|
||||
@@ -210,6 +249,7 @@ open class MainActivity : BaseComposeActivity() {
|
||||
|
||||
private val viewModel by viewModel<MainViewModel>()
|
||||
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
|
||||
private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
|
||||
|
||||
@Serializable
|
||||
private sealed interface MainRoute : NavKey
|
||||
@@ -253,6 +293,20 @@ open class MainActivity : BaseComposeActivity() {
|
||||
val scopeRaw: String? = null
|
||||
) : MainRoute
|
||||
|
||||
@Serializable
|
||||
private data class MainRouteBookInfo(
|
||||
val name: String?,
|
||||
val author: String?,
|
||||
val bookUrl: String,
|
||||
) : MainRoute
|
||||
|
||||
@Serializable
|
||||
private data class MainRouteExploreShow(
|
||||
val title: String?,
|
||||
val sourceUrl: String,
|
||||
val exploreUrl: String?,
|
||||
) : MainRoute
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -414,6 +468,26 @@ open class MainActivity : BaseComposeActivity() {
|
||||
onNavigateToBookCacheManage = {
|
||||
navigateToRoute(backStack, MainRouteBookCacheManage)
|
||||
},
|
||||
onNavigateToBookInfo = { name, author, bookUrl ->
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
MainRouteBookInfo(
|
||||
name = name,
|
||||
author = author,
|
||||
bookUrl = bookUrl
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToExploreShow = { title, sourceUrl, exploreUrl ->
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
MainRouteExploreShow(
|
||||
title = title,
|
||||
sourceUrl = sourceUrl,
|
||||
exploreUrl = exploreUrl
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToRssSort = { sourceUrl, sortUrl, key ->
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
@@ -484,7 +558,17 @@ open class MainActivity : BaseComposeActivity() {
|
||||
entry<MainRouteCache> { route ->
|
||||
BookshelfManageRouteScreen(
|
||||
groupId = route.groupId,
|
||||
onBackClick = { navigateBack(backStack) }
|
||||
onBackClick = { navigateBack(backStack) },
|
||||
onOpenBookInfo = { name, author, bookUrl ->
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
MainRouteBookInfo(
|
||||
name = name,
|
||||
author = author,
|
||||
bookUrl = bookUrl
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -533,11 +617,14 @@ open class MainActivity : BaseComposeActivity() {
|
||||
viewModel = searchViewModel,
|
||||
onBack = { navigateBack(backStack) },
|
||||
onOpenBookInfo = { name, author, bookUrl ->
|
||||
this@MainActivity.startActivity<BookInfoActivity> {
|
||||
putExtra("name", name)
|
||||
putExtra("author", author)
|
||||
putExtra("bookUrl", bookUrl)
|
||||
}
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
MainRouteBookInfo(
|
||||
name = name,
|
||||
author = author,
|
||||
bookUrl = bookUrl
|
||||
)
|
||||
)
|
||||
},
|
||||
onOpenSourceManage = {
|
||||
this@MainActivity.startActivity<BookSourceActivity>()
|
||||
@@ -573,6 +660,41 @@ open class MainActivity : BaseComposeActivity() {
|
||||
onBackClick = { navigateBack(backStack) }
|
||||
)
|
||||
}
|
||||
|
||||
entry<MainRouteBookInfo> { route ->
|
||||
val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
|
||||
BookInfoRouteScreen(
|
||||
bookUrl = route.bookUrl,
|
||||
viewModel = bookInfoViewModel,
|
||||
onBack = { navigateBack(backStack) },
|
||||
onFinish = { _, _ -> navigateBack(backStack) },
|
||||
onOpenSearch = { keyword ->
|
||||
navigateToRoute(backStack, MainRouteSearch(key = keyword))
|
||||
},
|
||||
onRegisterVariableSetter = { setter ->
|
||||
bookInfoVariableSetter = setter
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
entry<MainRouteExploreShow> { route ->
|
||||
ExploreShowScreen(
|
||||
title = route.title ?: "探索",
|
||||
sourceUrl = route.sourceUrl,
|
||||
exploreUrl = route.exploreUrl,
|
||||
onBack = { navigateBack(backStack) },
|
||||
onBookClick = { book ->
|
||||
navigateToRoute(
|
||||
backStack,
|
||||
MainRouteBookInfo(
|
||||
name = book.name,
|
||||
author = book.author,
|
||||
bookUrl = book.bookUrl
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -631,6 +753,30 @@ open class MainActivity : BaseComposeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
is MainRouteBookInfo -> {
|
||||
if (
|
||||
currentRoute == MainRouteHome ||
|
||||
currentRoute is MainRouteSearch ||
|
||||
currentRoute is MainRouteExploreShow
|
||||
) {
|
||||
backStack.add(route)
|
||||
} else {
|
||||
backStack.clear()
|
||||
backStack.add(MainRouteHome)
|
||||
backStack.add(route)
|
||||
}
|
||||
}
|
||||
|
||||
is MainRouteExploreShow -> {
|
||||
if (currentRoute == MainRouteHome) {
|
||||
backStack.add(route)
|
||||
} else {
|
||||
backStack.clear()
|
||||
backStack.add(MainRouteHome)
|
||||
backStack.add(route)
|
||||
}
|
||||
}
|
||||
|
||||
is MainRouteRssSort -> {
|
||||
if (currentRoute == MainRouteHome) {
|
||||
backStack.add(route)
|
||||
@@ -827,10 +973,32 @@ open class MainActivity : BaseComposeActivity() {
|
||||
key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
|
||||
scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE)
|
||||
)
|
||||
ROUTE_BOOK_INFO -> intent?.getStringExtra(EXTRA_BOOK_URL)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { bookUrl ->
|
||||
MainRouteBookInfo(
|
||||
name = intent.getStringExtra(EXTRA_BOOK_NAME),
|
||||
author = intent.getStringExtra(EXTRA_BOOK_AUTHOR),
|
||||
bookUrl = bookUrl
|
||||
)
|
||||
} ?: MainRouteHome
|
||||
ROUTE_EXPLORE_SHOW -> intent?.getStringExtra(EXTRA_SOURCE_URL)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { sourceUrl ->
|
||||
MainRouteExploreShow(
|
||||
title = intent.getStringExtra(EXTRA_EXPLORE_NAME),
|
||||
sourceUrl = sourceUrl,
|
||||
exploreUrl = intent.getStringExtra(EXTRA_EXPLORE_URL)
|
||||
)
|
||||
} ?: MainRouteHome
|
||||
else -> MainRouteHome
|
||||
}
|
||||
}
|
||||
|
||||
override fun setVariable(key: String, variable: String?) {
|
||||
bookInfoVariableSetter?.invoke(key, variable)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LauncherW : MainActivity()
|
||||
|
||||
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.book.info.BookInfoActivity
|
||||
import io.legado.app.ui.config.mainConfig.MainConfig
|
||||
import io.legado.app.ui.main.bookshelf.BookshelfScreen
|
||||
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
||||
@@ -76,7 +75,6 @@ import io.legado.app.ui.widget.components.icon.AppIcons
|
||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.utils.startActivity
|
||||
import io.legado.app.utils.startActivityForBook
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
@@ -95,6 +93,8 @@ fun MainScreen(
|
||||
onNavigateToLocalImport: () -> Unit,
|
||||
onNavigateToCache: (Long) -> Unit,
|
||||
onNavigateToBookCacheManage: () -> Unit,
|
||||
onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit,
|
||||
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
|
||||
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
|
||||
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
|
||||
) {
|
||||
@@ -374,11 +374,7 @@ fun MainScreen(
|
||||
context.startActivityForBook(book)
|
||||
},
|
||||
onBookLongClick = { book ->
|
||||
context.startActivity<BookInfoActivity> {
|
||||
putExtra("name", book.name)
|
||||
putExtra("author", book.author)
|
||||
putExtra("bookUrl", book.bookUrl)
|
||||
}
|
||||
onNavigateToBookInfo(book.name, book.author, book.bookUrl)
|
||||
},
|
||||
onNavigateToSearch = { query -> onNavigateToSearch(query) },
|
||||
onNavigateToRemoteImport = onNavigateToRemoteImport,
|
||||
@@ -386,7 +382,9 @@ fun MainScreen(
|
||||
onNavigateToCache = onNavigateToCache
|
||||
)
|
||||
|
||||
MainDestination.Explore -> ExploreScreen()
|
||||
MainDestination.Explore -> ExploreScreen(
|
||||
onOpenExploreShow = onNavigateToExploreShow
|
||||
)
|
||||
MainDestination.Rss -> RssScreen(
|
||||
onOpenSort = { sourceUrl, sortUrl, key ->
|
||||
onNavigateToRssSort(sourceUrl, sortUrl, key)
|
||||
|
||||
@@ -107,7 +107,7 @@ class BookshelfViewModel(
|
||||
|
||||
// 更新相关
|
||||
private var threadCount = AppConfig.threadCount
|
||||
private var poolSize = min(threadCount, AppConst.MAX_THREAD)
|
||||
private var poolSize = threadCount
|
||||
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
|
||||
private val waitUpTocBooks = LinkedList<String>()
|
||||
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
|
||||
@@ -495,7 +495,7 @@ class BookshelfViewModel(
|
||||
|
||||
private fun upPool() {
|
||||
threadCount = AppConfig.threadCount
|
||||
val newPoolSize = min(threadCount, AppConst.MAX_THREAD)
|
||||
val newPoolSize = threadCount
|
||||
if (poolSize == newPoolSize) return
|
||||
poolSize = newPoolSize
|
||||
upTocPool.close()
|
||||
|
||||
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.R
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
|
||||
import io.legado.app.ui.book.explore.ExploreShowActivity
|
||||
import io.legado.app.ui.book.search.SearchActivity
|
||||
import io.legado.app.ui.book.search.SearchScope
|
||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||
@@ -84,7 +83,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ExploreScreen(
|
||||
viewModel: ExploreViewModel = koinViewModel()
|
||||
viewModel: ExploreViewModel = koinViewModel(),
|
||||
onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? AppCompatActivity
|
||||
@@ -238,11 +238,7 @@ fun ExploreScreen(
|
||||
sourceUrl = item.bookSourceUrl,
|
||||
activity = activity,
|
||||
onOpenUrl = { url ->
|
||||
context.startActivity<ExploreShowActivity> {
|
||||
putExtra("exploreName", kind.title)
|
||||
putExtra("sourceUrl", item.bookSourceUrl)
|
||||
putExtra("exploreUrl", url)
|
||||
}
|
||||
onOpenExploreShow(kind.title, item.bookSourceUrl, url)
|
||||
},
|
||||
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
|
||||
modifier = Modifier.weight(span.toFloat()),
|
||||
|
||||
@@ -10,7 +10,6 @@ import io.legado.app.data.entities.RssReadRecord
|
||||
import io.legado.app.data.entities.RssSource
|
||||
import io.legado.app.help.JsExtensions
|
||||
import io.legado.app.ui.association.AddToBookshelfDialog
|
||||
import io.legado.app.ui.book.explore.ExploreShowActivity
|
||||
import io.legado.app.ui.book.search.SearchActivity
|
||||
import io.legado.app.ui.login.SourceLoginActivity
|
||||
import io.legado.app.ui.main.MainActivity
|
||||
@@ -184,11 +183,14 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
|
||||
} ?: (source as? BookSource) ?: return@launch
|
||||
val sourceUrl = toSource.bookSourceUrl
|
||||
withContext(Main) {
|
||||
activity.startActivity<ExploreShowActivity> {
|
||||
putExtra("exploreName", title)
|
||||
putExtra("sourceUrl", sourceUrl)
|
||||
putExtra("exploreUrl", url)
|
||||
}
|
||||
activity.startActivity(
|
||||
MainActivity.createExploreShowIntent(
|
||||
context = activity,
|
||||
exploreName = title,
|
||||
sourceUrl = sourceUrl,
|
||||
exploreUrl = url
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,15 @@ fun AppScaffold(
|
||||
floatingActionButtonPosition: FabPosition = FabPosition.End,
|
||||
contentColor: Color = contentColorFor(MiuixTheme.colorScheme.surface),
|
||||
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
alwaysDrawBehindBars: Boolean = false,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val hasImageBg = ThemeConfig.hasImageBg(isDark)
|
||||
val hazeState = remember { HazeState() }
|
||||
val composeEngine = LegadoTheme.composeEngine
|
||||
val contentDrawsBehindBars = ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur
|
||||
val contentDrawsBehindBars =
|
||||
alwaysDrawBehindBars || ThemeConfig.enableBlur || ThemeConfig.enableProgressiveBlur
|
||||
|
||||
val containerColor = if (hasImageBg) {
|
||||
Color.Transparent
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<io.legado.app.ui.widget.TitleBar
|
||||
android:id="@+id/title_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:contentInsetStartWithNavigation="0dp"
|
||||
app:contentLayout="@layout/view_search"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:title="@string/bookshelf_management" />
|
||||
|
||||
<io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:scrollbars="none"
|
||||
app:layout_constraintBottom_toTopOf="@id/select_action_bar"
|
||||
app:layout_constraintTop_toBottomOf="@id/title_bar" />
|
||||
|
||||
<io.legado.app.ui.widget.SelectActionBar
|
||||
android:id="@+id/select_action_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,98 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<io.legado.app.ui.widget.TitleBar
|
||||
android:id="@+id/tool_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="?attr/actionBarStyle"
|
||||
app:attachToActivity="false"
|
||||
app:contentLayout="@layout/view_search"
|
||||
app:fitStatusBar="false"
|
||||
|
||||
tools:ignore="RtlHardcoded,RtlSymmetry" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<io.legado.app.ui.widget.recycler.scroller.FastScrollRecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="none"
|
||||
tools:ignore="SpeakableTextPresentCheck" />
|
||||
|
||||
<com.google.android.material.loadingindicator.LoadingIndicator
|
||||
android:id="@+id/rotate_loading"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_margin="6dp"
|
||||
android:visibility="gone"
|
||||
app:loading_width="2dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_msg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:padding="16dp"
|
||||
android:textColor="@color/secondaryText"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<com.google.android.flexbox.FlexboxLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp"
|
||||
app:flexWrap="wrap"
|
||||
app:justifyContent="space_between">
|
||||
|
||||
<io.legado.app.ui.widget.text.AccentTextView
|
||||
android:id="@+id/tv_footer_left"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:visibility="gone"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<io.legado.app.ui.widget.text.AccentTextView
|
||||
android:id="@+id/tv_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:text="@string/cancel"
|
||||
android:visibility="gone"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<io.legado.app.ui.widget.text.AccentTextView
|
||||
android:id="@+id/tv_ok"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:text="@string/ok"
|
||||
android:visibility="gone"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.flexbox.FlexboxLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,94 +0,0 @@
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/checkbox"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="2dp"
|
||||
android:singleLine="true"
|
||||
android:text="@string/book_name"
|
||||
style="@style/ChapterNameText"
|
||||
app:layout_constraintStart_toEndOf="@id/checkbox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/checkbox"
|
||||
app:layout_constraintBottom_toBottomOf="@id/checkbox"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_author"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:singleLine="true"
|
||||
android:text="@string/author"
|
||||
android:textSize="12sp"
|
||||
tools:ignore="RtlHardcoded"
|
||||
app:layout_constraintStart_toStartOf="@id/tv_name"
|
||||
app:layout_constraintEnd_toStartOf="@id/tv_group"
|
||||
app:layout_constraintTop_toBottomOf="@id/tv_name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_origin"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textSize="12sp"
|
||||
tools:text="@string/book_source"
|
||||
app:layout_constraintStart_toStartOf="@id/tv_name"
|
||||
app:layout_constraintTop_toBottomOf="@id/tv_author" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_group_s"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/ungroup"
|
||||
android:layout_marginStart="8dp"
|
||||
android:singleLine="true"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@id/tv_origin"
|
||||
app:layout_constraintEnd_toStartOf="@id/tv_group"
|
||||
app:layout_constraintBaseline_toBaselineOf="@id/tv_origin" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/tv_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
style="@style/Widget.Material3Expressive.Button.TonalButton"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:minWidth="0dp"
|
||||
android:padding="0dp"
|
||||
android:text="@string/group"
|
||||
app:layout_constraintTop_toTopOf="@id/tv_group_s"
|
||||
app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
|
||||
app:layout_constraintEnd_toStartOf="@id/tv_delete" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/tv_delete"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
|
||||
app:icon="@drawable/ic_clear_all"
|
||||
app:iconSize="20dp"
|
||||
app:iconGravity="textStart"
|
||||
app:layout_constraintTop_toTopOf="@id/tv_group_s"
|
||||
app:layout_constraintBottom_toBottomOf="@id/tv_group_s"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AlwaysShowAction">
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_book_group"
|
||||
android:icon="@drawable/ic_groups"
|
||||
android:title="@string/group"
|
||||
app:showAsAction="always">
|
||||
|
||||
<menu>
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_group_manage"
|
||||
android:title="@string/group_manage" />
|
||||
|
||||
</menu>
|
||||
|
||||
</item>
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_export_all_use_book_source"
|
||||
android:title="@string/export_all_use_book_source" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_open_book_info_by_click_title"
|
||||
android:checkable="true"
|
||||
android:title="@string/open_book_info_by_click_title" />
|
||||
|
||||
</menu>
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_del_selection"
|
||||
android:title="@string/delete" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_update_enable"
|
||||
android:title="@string/allow_update" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_update_disable"
|
||||
android:title="@string/disable_update" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_add_to_group"
|
||||
android:title="@string/add_to_group" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_change_source"
|
||||
android:title="@string/change_source_batch" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_clear_cache"
|
||||
android:title="@string/clear_cache" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_check_selected_interval"
|
||||
android:title="@string/check_selected_interval" />
|
||||
|
||||
</menu>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+16
-11
@@ -2,6 +2,7 @@ package io.legado.app.model.cache
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -36,17 +37,6 @@ class CacheDownloadStateStoreTest {
|
||||
assertEquals(1, store.state.totalSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateSuccessDoesNotInflateCount() {
|
||||
val store = CacheDownloadStateStore()
|
||||
|
||||
store.markSuccess("a", 1)
|
||||
store.markSuccess("a", 1)
|
||||
|
||||
assertEquals(1, store.state.books.getValue("a").successCount)
|
||||
assertEquals(1, store.state.totalSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeBookRecalculatesRunningState() {
|
||||
val store = CacheDownloadStateStore()
|
||||
@@ -58,4 +48,19 @@ class CacheDownloadStateStoreTest {
|
||||
assertEquals(0, store.state.totalWaiting)
|
||||
assertEquals(emptyMap<String, CacheBookDownloadState>(), store.state.books)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bookFailureIsVisibleUntilQueueStartsAgain() {
|
||||
val store = CacheDownloadStateStore()
|
||||
|
||||
store.markBookFailed("a", "source unavailable")
|
||||
|
||||
assertEquals("source unavailable", store.state.books.getValue("a").failureMessage)
|
||||
assertEquals(1, store.state.totalFailure)
|
||||
|
||||
store.updateBookQueue("a", waitingCount = 1, runningIndices = emptySet())
|
||||
|
||||
assertNull(store.state.books.getValue("a").failureMessage)
|
||||
assertEquals(0, store.state.totalFailure)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user