From 3fdea120daeefe5a8e0c15f04ddc1bdb9b477045 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:10:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=90=9C=E7=B4=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../local/preferences/LocalPreferences.kt | 1 + .../app/domain/model/BookSearchScope.kt | 146 ++++++++++++++---- .../io/legado/app/help/config/AppConfig.kt | 12 -- .../legado/app/ui/book/search/SearchScope.kt | 111 +++++-------- .../app/ui/book/search/SearchViewModel.kt | 56 +++++-- .../app/web/socket/BookSearchWebSocket.kt | 7 +- 6 files changed, 208 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt index e3ca05f50..7411cd662 100644 --- a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt @@ -14,6 +14,7 @@ val Context.localDataStore: DataStore by preferencesDataStore(name object LocalPreferencesKeys { val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip") val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode") + val SEARCH_SCOPE = stringPreferencesKey("search_scope") val MATCH_MODE = intPreferencesKey("match_mode") val EXPLORE_LAYOUT_MODE = intPreferencesKey("explore_layout_mode") val EXPLORE_LAYOUT_GRID_PORTRAIT = intPreferencesKey("explore_layout_grid_portrait") diff --git a/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt index 0ef91c6c1..6af0a41cb 100644 --- a/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt +++ b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt @@ -1,40 +1,130 @@ package io.legado.app.domain.model +import com.google.gson.GsonBuilder +import io.legado.app.utils.fromJsonObject import io.legado.app.utils.splitNotBlank data class BookSearchScope(val raw: String) { - val items: List - get() = raw.splitNotBlank(",").toList() - - val isAll: Boolean - get() = raw.isEmpty() - - val isSource: Boolean - get() = items.isNotEmpty() && parseSourceItems().size == items.size - - val groupNames: List - get() = if (isSource) emptyList() else items - - val sourceUrls: List - get() = parseSourceItems().map { it.url } - - private fun parseSourceItems(): List { - return items.mapNotNull { item -> - val splitIndex = item.indexOf("::") - if (splitIndex <= 0 || splitIndex >= item.lastIndex) { - null - } else { - ScopeSourceItem( - name = item.substring(0, splitIndex), - url = item.substring(splitIndex + 2) - ) - } - } + private val parsed: ParsedSearchScope by lazy { + parse(raw) } - private data class ScopeSourceItem( + val items: List + get() = when { + isSource -> sourceUrls + else -> groupNames + } + + val isAll: Boolean + get() = parsed.isAll + + val isSource: Boolean + get() = parsed.sources.isNotEmpty() + + val groupNames: List + get() = parsed.groups + + val sourceUrls: List + get() = parsed.sources.map { it.url } + + val sourceNames: List + get() = parsed.sources.map { it.name } + + private data class ParsedSearchScope( + val groups: List = emptyList(), + val sources: List = emptyList(), + ) { + val isAll: Boolean + get() = groups.isEmpty() && sources.isEmpty() + } + + data class ScopeSourceItem( val name: String, val url: String, ) + + private data class SerializedSearchScope( + val type: String = "", + val groups: List = emptyList(), + val sources: List = emptyList(), + ) + + companion object { + + fun encodeGroups(groups: List): String { + val selected = groups.filter { it.isNotBlank() } + return if (selected.isEmpty()) { + "" + } else { + scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected)) + } + } + + fun encodeSources(sources: List): String { + val selected = sources.filter { it.url.isNotBlank() } + return if (selected.isEmpty()) { + "" + } else { + scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected)) + } + } + + private fun parse(raw: String): ParsedSearchScope { + if (raw.isEmpty()) return ParsedSearchScope() + + parseJson(raw)?.let { + return it + } + + return parseLegacy(raw) + } + + private fun parseJson(raw: String): ParsedSearchScope? { + val json = raw.trim() + if (!json.startsWith("{") || !json.endsWith("}")) return null + + return scopeGson.fromJsonObject(json).getOrNull()?.let { scope -> + when (scope.type) { + TYPE_SOURCE -> ParsedSearchScope( + sources = scope.sources.filter { it.url.isNotBlank() } + ) + + TYPE_GROUP -> ParsedSearchScope( + groups = scope.groups.filter { it.isNotBlank() } + ) + + else -> null + } + } + } + + private fun parseLegacy(raw: String): ParsedSearchScope { + val rawItems = raw.split(",").filter { it.isNotBlank() } + val sourceItems = parseLegacySourceItems(rawItems) + if (rawItems.isNotEmpty() && sourceItems.size == rawItems.size) { + return ParsedSearchScope(sources = sourceItems) + } + return ParsedSearchScope(groups = raw.splitNotBlank(",").toList()) + } + + private fun parseLegacySourceItems(items: List): List { + return items.mapNotNull { item -> + val splitIndex = item.indexOf("::") + if (splitIndex <= 0 || splitIndex >= item.lastIndex) { + null + } else { + ScopeSourceItem( + name = item.substring(0, splitIndex), + url = item.substring(splitIndex + 2) + ) + } + } + } + + private const val TYPE_GROUP = "group" + private const val TYPE_SOURCE = "source" + private val scopeGson = GsonBuilder().disableHtmlEscaping().create() + } + } diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 02ba1bb2f..3cdc6241b 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -634,18 +634,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val paddingDisplayCutouts get() = ReadConfig.paddingDisplayCutouts - var searchScope: String - get() = appCtx.getPrefString("searchScope") ?: "" - set(value) { - appCtx.putPrefString("searchScope", value) - } - - var searchGroup: String - get() = appCtx.getPrefString("searchGroup") ?: "" - set(value) { - appCtx.putPrefString("searchGroup", value) - } - var pageTouchSlop: Int get() = ReadConfig.pageTouchSlop set(value) { diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt index 4d21deb37..d9185f2f6 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt @@ -5,8 +5,8 @@ import io.legado.app.R import io.legado.app.data.appDb import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart -import io.legado.app.help.config.AppConfig -import io.legado.app.utils.splitNotBlank +import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.model.BookSearchScope.ScopeSourceItem import splitties.init.appCtx /** @@ -15,14 +15,14 @@ import splitties.init.appCtx @Suppress("unused") data class SearchScope(private var scope: String) { - constructor(groups: List) : this(groups.joinToString(",")) + constructor(groups: List) : this(BookSearchScope.encodeGroups(groups)) constructor(source: BookSource) : this( - encodeSourceToken(source.bookSourceName, source.bookSourceUrl) + encodeSourceScope(source.bookSourceName, source.bookSourceUrl) ) constructor(source: BookSourcePart) : this( - encodeSourceToken(source.bookSourceName, source.bookSourceUrl) + encodeSourceScope(source.bookSourceName, source.bookSourceUrl) ) override fun toString(): String { @@ -34,43 +34,36 @@ data class SearchScope(private var scope: String) { fun update(scope: String, postValue: Boolean = true) { this.scope = scope if (postValue) stateLiveData.postValue(scope) - save() } fun update(groups: List) { - scope = groups.joinToString(",") + scope = BookSearchScope.encodeGroups(groups) stateLiveData.postValue(scope) - save() } fun update(source: BookSource) { - scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl) + scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl) stateLiveData.postValue(scope) - save() } fun update(source: BookSourcePart) { - scope = encodeSourceToken(source.bookSourceName, source.bookSourceUrl) + scope = encodeSourceScope(source.bookSourceName, source.bookSourceUrl) stateLiveData.postValue(scope) - save() } fun updateSources(sources: List) { scope = encodeSourceScope(sources) stateLiveData.postValue(scope) - save() } fun isSource(): Boolean { - val items = scopeItems() - if (items.isEmpty()) return false - return parseSourceItems(items).size == items.size + return parsedScope().isSource } val display: String get() { if (isSource()) { - val sourceNames = parseSourceItems().map { it.name } + val sourceNames = parsedScope().sourceNames if (sourceNames.isEmpty()) return appCtx.getString(R.string.all_source) return sourceNames.joinToString(",") } @@ -87,40 +80,26 @@ data class SearchScope(private var scope: String) { get() { val list = arrayListOf() if (isSource()) { - parseSourceItems().forEach { - list.add(it.name) - } + list.addAll(parsedScope().sourceNames) } else { - scopeItems().forEach { - list.add(it) - } + list.addAll(parsedScope().groupNames) } return list } val sourceUrls: List - get() = parseSourceItems().map { it.url } + get() = parsedScope().sourceUrls fun remove(scope: String) { if (isSource()) { - val sourceItems = parseSourceItems().filterNot { + val sourceItems = sourceItems().filterNot { it.name == scope || it.url == scope } - this.scope = sourceItems.joinToString(",") { "${it.name}::${it.url}" } + this.scope = BookSearchScope.encodeSources(sourceItems) } else { - val stringBuilder = StringBuilder() - scopeItems().forEach { - if (it != scope) { - if (stringBuilder.isNotEmpty()) { - stringBuilder.append(",") - } - stringBuilder.append(it) - } - } - this.scope = stringBuilder.toString() + this.scope = BookSearchScope.encodeGroups(displayNames.filterNot { it == scope }) } stateLiveData.postValue(this.scope) - save() } /** @@ -132,13 +111,13 @@ data class SearchScope(private var scope: String) { list.addAll(appDb.bookSourceDao.allEnabledPart) } else { if (isSource()) { - parseSourceItems().forEach { sourceItem -> + sourceItems().forEach { sourceItem -> appDb.bookSourceDao.getBookSourcePart(sourceItem.url)?.let { source -> list.add(source) } } } else { - val oldScope = scopeItems() + val oldScope = parsedScope().groupNames val newScope = oldScope.filter { val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it) list.addAll(bookSources) @@ -163,54 +142,38 @@ data class SearchScope(private var scope: String) { } fun isAll(): Boolean { - return scope.isEmpty() + return parsedScope().isAll } - fun save() { - AppConfig.searchScope = scope - if (isAll() || isSource() || scope.contains(",")) { - AppConfig.searchGroup = "" - } else { - AppConfig.searchGroup = scope + private fun parsedScope(): BookSearchScope = BookSearchScope(scope) + + private fun sourceItems(): List { + val parsed = parsedScope() + return parsed.sourceNames.zip(parsed.sourceUrls) { name, url -> + ScopeSourceItem(name, url) } } - private fun scopeItems(): List = scope.splitNotBlank(",").toList() - - private fun parseSourceItems( - items: List = scopeItems() - ): List { - return items.mapNotNull { item -> - val splitIndex = item.indexOf("::") - if (splitIndex <= 0 || splitIndex >= item.lastIndex) { - null - } else { - ScopeSourceItem( - name = item.substring(0, splitIndex), - url = item.substring(splitIndex + 2) - ) - } - } - } - - private data class ScopeSourceItem( - val name: String, - val url: String, - ) - companion object { private fun sanitizeSourceName(name: String): String { return name.replace(":", "").replace(",", "") } - private fun encodeSourceToken(name: String, url: String): String { - return "${sanitizeSourceName(name)}::${url}" + private fun encodeSourceScope(name: String, url: String): String { + return BookSearchScope.encodeSources( + listOf(ScopeSourceItem(sanitizeSourceName(name), url)) + ) } private fun encodeSourceScope(sources: List): String { - return sources.joinToString(",") { source -> - encodeSourceToken(source.bookSourceName, source.bookSourceUrl) - } + return BookSearchScope.encodeSources( + sources.map { source -> + ScopeSourceItem( + name = sanitizeSourceName(source.bookSourceName), + url = source.bookSourceUrl + ) + } + ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 384912bc0..2b3865185 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -17,7 +17,6 @@ import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchRunEvent -import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -69,10 +68,10 @@ class SearchViewModel( private val _uiState = MutableStateFlow( SearchUiState( - scopeDisplay = SearchScope(AppConfig.searchScope).display, - scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(), - isAllScope = SearchScope(AppConfig.searchScope).isAll(), - isSourceScope = SearchScope(AppConfig.searchScope).isSource(), + scopeDisplay = SearchScope("").display, + scopeDisplayNames = SearchScope("").displayNames.toImmutableList(), + isAllScope = SearchScope("").isAll(), + isSourceScope = SearchScope("").isSource(), ) ) val uiState = _uiState.asStateFlow() @@ -82,7 +81,9 @@ class SearchViewModel( private val queryFlow = MutableStateFlow("") private val bookshelfKeys = MutableStateFlow>(emptySet()) - private val searchScope = SearchScope(AppConfig.searchScope) + private var persistedSearchScopeRaw = "" + private var hasTemporaryScope = false + private val searchScope = SearchScope("") private val searchControl = BookSearchControl() private val searchResultBooks = LinkedHashMap() @@ -93,6 +94,7 @@ class SearchViewModel( init { syncScopeState() + observeSearchScope() observeEnabledGroups() observeEnabledSources() observeBookshelf() @@ -205,6 +207,7 @@ class SearchViewModel( SearchIntent.SelectAllScope -> { val oldScope = searchScope.toString() searchScope.update("") + persistSearchScope() syncScopeState(restartSearch = true, oldScope = oldScope) } @@ -213,6 +216,7 @@ class SearchViewModel( is SearchIntent.RemoveScopeItem -> { val oldScope = searchScope.toString() searchScope.remove(intent.scopeName) + persistSearchScope() syncScopeState(restartSearch = true, oldScope = oldScope) } @@ -313,10 +317,15 @@ class SearchViewModel( } private fun initialize(key: String?, scopeRaw: String?) { - scopeRaw?.let { - searchScope.update(it, postValue = false) + if (scopeRaw != null) { + hasTemporaryScope = true + searchScope.update(scopeRaw, postValue = false) + syncScopeState() + } else if (hasTemporaryScope) { + hasTemporaryScope = false + searchScope.update(persistedSearchScopeRaw, postValue = false) + syncScopeState() } - syncScopeState() // When the ViewModel already holds a non-empty committed query, // it means a search session is in progress or completed. @@ -613,6 +622,7 @@ class SearchViewModel( selected.add(groupName) } searchScope.update(selected.toList()) + persistSearchScope() syncScopeState(restartSearch = true, oldScope = oldScope) } @@ -638,6 +648,7 @@ class SearchViewModel( } searchScope.updateSources(selectedSources) } + persistSearchScope() syncScopeState(restartSearch = true, oldScope = oldScope) } @@ -653,6 +664,7 @@ class SearchViewModel( } } else { searchScope.update("") + persistSearchScope() syncScopeState() } @@ -685,6 +697,32 @@ class SearchViewModel( } } + private fun observeSearchScope() { + viewModelScope.launch { + localPreferencesRepository + .getPreference(LocalPreferencesKeys.SEARCH_SCOPE, "") + .distinctUntilChanged() + .collect { scopeRaw -> + persistedSearchScopeRaw = scopeRaw + if (!hasTemporaryScope && scopeRaw != searchScope.toString()) { + searchScope.update(scopeRaw, postValue = false) + syncScopeState() + } + } + } + } + + private fun persistSearchScope() { + hasTemporaryScope = false + persistedSearchScopeRaw = searchScope.toString() + viewModelScope.launch { + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.SEARCH_SCOPE, + searchScope.toString() + ) + } + } + private fun List.toSearchResultItems( shelf: Set ): List { diff --git a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt index d70755ef0..79f658093 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt @@ -15,7 +15,6 @@ import io.legado.app.domain.usecase.BookSearchControl import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchRunEvent -import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject @@ -82,7 +81,11 @@ class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) : BookSearchRequest( keyword = key, page = 1, - scope = BookSearchScope(AppConfig.searchScope), + scope = BookSearchScope( + localPreferencesRepository + .getPreference(LocalPreferencesKeys.SEARCH_SCOPE, "") + .first() + ), matchMode = MatchMode.of( localPreferencesRepository .getPreference(