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