HapeLee 4 месяцев назад
Родитель
Сommit
3fdea120da

+ 1 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -14,6 +14,7 @@ val Context.localDataStore: DataStore<Preferences> 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")

+ 108 - 18
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) {
 
+    private val parsed: ParsedSearchScope by lazy {
+        parse(raw)
+    }
+
     val items: List<String>
-        get() = raw.splitNotBlank(",").toList()
+        get() = when {
+            isSource -> sourceUrls
+            else -> groupNames
+        }
 
     val isAll: Boolean
-        get() = raw.isEmpty()
+        get() = parsed.isAll
 
     val isSource: Boolean
-        get() = items.isNotEmpty() && parseSourceItems().size == items.size
+        get() = parsed.sources.isNotEmpty()
 
     val groupNames: List<String>
-        get() = if (isSource) emptyList() else items
+        get() = parsed.groups
 
     val sourceUrls: List<String>
-        get() = parseSourceItems().map { it.url }
+        get() = parsed.sources.map { it.url }
 
-    private fun parseSourceItems(): List<ScopeSourceItem> {
-        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)
-                )
-            }
-        }
+    val sourceNames: List<String>
+        get() = parsed.sources.map { it.name }
+
+    private data class ParsedSearchScope(
+        val groups: List<String> = emptyList(),
+        val sources: List<ScopeSourceItem> = emptyList(),
+    ) {
+        val isAll: Boolean
+            get() = groups.isEmpty() && sources.isEmpty()
     }
 
-    private data class ScopeSourceItem(
+    data class ScopeSourceItem(
         val name: String,
         val url: String,
     )
+
+    private data class SerializedSearchScope(
+        val type: String = "",
+        val groups: List<String> = emptyList(),
+        val sources: List<ScopeSourceItem> = emptyList(),
+    )
+
+    companion object {
+
+        fun encodeGroups(groups: List<String>): String {
+            val selected = groups.filter { it.isNotBlank() }
+            return if (selected.isEmpty()) {
+                ""
+            } else {
+                scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected))
+            }
+        }
+
+        fun encodeSources(sources: List<ScopeSourceItem>): 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<SerializedSearchScope>(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<String>): List<ScopeSourceItem> {
+            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()
+    }
+
 }

+ 0 - 12
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) {

+ 36 - 73
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<String>) : this(groups.joinToString(","))
+    constructor(groups: List<String>) : 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<String>) {
-        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<BookSourcePart>) {
         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<String>()
             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<String>
-        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 scopeItems(): List<String> = scope.splitNotBlank(",").toList()
-
-    private fun parseSourceItems(
-        items: List<String> = scopeItems()
-    ): List<ScopeSourceItem> {
-        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 fun sourceItems(): List<ScopeSourceItem> {
+        val parsed = parsedScope()
+        return parsed.sourceNames.zip(parsed.sourceUrls) { name, url ->
+            ScopeSourceItem(name, url)
         }
     }
 
-    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<BookSourcePart>): String {
-            return sources.joinToString(",") { source ->
-                encodeSourceToken(source.bookSourceName, source.bookSourceUrl)
-            }
+            return BookSearchScope.encodeSources(
+                sources.map { source ->
+                    ScopeSourceItem(
+                        name = sanitizeSourceName(source.bookSourceName),
+                        url = source.bookSourceUrl
+                    )
+                }
+            )
         }
     }
 

+ 47 - 9
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<Set<BookShelfKey>>(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<SearchResultKey, SearchBook>()
 
@@ -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<SearchBook>.toSearchResultItems(
         shelf: Set<BookShelfKey>
     ): List<SearchResultItemUi> {

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