Răsfoiți Sursa

fix(search): 全文搜索历史只记录确认过的查询 (#2245)

输入框每敲一下都会触发自动搜索,而 executeSearch() 无条件
saveHistory(),于是拼音、半截词的中间态全被写进 search_content_history,
历史列表被灌满(issue #2113)。

- 新增 SearchContentIntent.SubmitSearch:输入法搜索键触发,确认查询并记录历史
- executeSearch(recordHistory) 默认不写历史,只有确认路径传 true
- 从阅读页选中文字进入时(initialSearchWord 非空)视为用户主动发起,仍然记录
- 搜索框接上 SearchBar 已有的 onSearch 回调
- 新增 SearchContentHistoryRecordingTest:输入不写历史 / 回车写一条 / 选中进入写一条
kungunier 1 lună în urmă
părinte
comite
19f8f5b102

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -202,6 +202,7 @@ fun SearchContentScreen(
                         autoFocus = autoFocus,
                         scrollState = listState,
                         onQueryChange = { onIntent(SearchContentIntent.UpdateQuery(it)) },
+                        onSearch = { onIntent(SearchContentIntent.SubmitSearch(it)) },
                         trailingIcon = {
                             if (searchQuery.isNotEmpty()) {
                                 SmallPlainButton(

+ 26 - 3
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt

@@ -40,6 +40,9 @@ data class SearchContentUiState(
 
 sealed interface SearchContentIntent {
     data class UpdateQuery(val value: String) : SearchContentIntent
+
+    /** 用户在输入法上按下「搜索」:确认这次查询并把关键词写入搜索历史。 */
+    data class SubmitSearch(val value: String) : SearchContentIntent
     data class ToggleReplace(val enabled: Boolean) : SearchContentIntent
     data class ToggleRegex(val enabled: Boolean) : SearchContentIntent
     data object ToggleHistoryScope : SearchContentIntent
@@ -74,6 +77,12 @@ class SearchContentViewModel(
         searchContentRepository.getLastSession(bookUrl)
     } else null
 
+    /**
+     * 从阅读页选中文字进入时,这次查询是用户主动发起的,允许记入历史;
+     * 其余情况(恢复上次会话、输入过程中的自动搜索)只做预览,不污染历史。
+     */
+    private val initialSearchSubmitted = initialSearchWord != null
+
     private val _uiState = MutableStateFlow(
         SearchContentUiState(
             searchQuery = initialSearchWord ?: restoredSession?.query.orEmpty(),
@@ -107,6 +116,10 @@ class SearchContentViewModel(
                 _uiState.update { it.copy(searchQuery = intent.value) }
                 executeSearch()
             }
+            is SearchContentIntent.SubmitSearch -> {
+                _uiState.update { it.copy(searchQuery = intent.value) }
+                executeSearch(recordHistory = true)
+            }
             is SearchContentIntent.ToggleReplace -> {
                 _uiState.update { it.copy(replaceEnabled = intent.enabled) }
                 executeSearch()
@@ -152,7 +165,9 @@ class SearchContentViewModel(
                 )
             }
             observeHistory()
-            if (cachedResults.isNullOrEmpty() && state.searchQuery.isNotBlank()) executeSearch()
+            if (cachedResults.isNullOrEmpty() && state.searchQuery.isNotBlank()) {
+                executeSearch(recordHistory = initialSearchSubmitted)
+            }
         }
     }
 
@@ -167,7 +182,13 @@ class SearchContentViewModel(
         }
     }
 
-    private fun executeSearch() {
+    /**
+     * 执行一次全文搜索。
+     *
+     * [recordHistory] 只在用户确认搜索(输入法搜索键、从阅读页选中文字发起)时为 true:
+     * 输入过程中的自动搜索只用于预览,早期实现会把拼音、半截词的中间态全部写进搜索历史。
+     */
+    private fun executeSearch(recordHistory: Boolean = false) {
         searchJob?.cancel()
         val state = _uiState.value
         if (state.searchQuery.isBlank()) {
@@ -186,7 +207,9 @@ class SearchContentViewModel(
         )
         searchJob = viewModelScope.launch {
             state.book?.let { book ->
-                searchContentRepository.saveHistory(book, state.searchQuery)
+                if (recordHistory) {
+                    searchContentRepository.saveHistory(book, state.searchQuery)
+                }
                 searchContentRepository.search(
                     book,
                     state.searchQuery,

+ 134 - 0
app/src/test/java/io/legado/app/ui/book/searchContent/SearchContentHistoryRecordingTest.kt

@@ -0,0 +1,134 @@
+package io.legado.app.ui.book.searchContent
+
+import android.app.Application
+import android.os.Looper
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.SearchContentHistory
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.SearchContentRepository
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.settings.ThemeSettings
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+
+/**
+ * 全文搜索的搜索历史只记录「用户确认过的查询」。
+ *
+ * 回归 issue #2113:输入过程中的自动搜索曾把每个拼音、半截词都写进 search_content_history,
+ * 历史列表被中间态灌满。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class SearchContentHistoryRecordingTest {
+
+    private lateinit var db: AppDatabase
+
+    @Before
+    fun setUp() {
+        db = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java,
+        ).allowMainThreadQueries().build()
+    }
+
+    @After
+    fun tearDown() {
+        db.close()
+    }
+
+    @Test
+    fun `typing only previews and never writes search history`() {
+        val viewModel = viewModel(initialSearchWord = null)
+        awaitBook(viewModel)
+
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yan"))
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yanjing"))
+        idle()
+
+        assertEquals(emptyList<String>(), awaitQueries(atLeast = 1, timeoutMs = 400))
+    }
+
+    @Test
+    fun `the keyboard search key records the committed query`() {
+        val viewModel = viewModel(initialSearchWord = null)
+        awaitBook(viewModel)
+
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yan"))
+        idle()
+        viewModel.onIntent(SearchContentIntent.SubmitSearch("眼镜"))
+
+        assertEquals(listOf("眼镜"), awaitQueries(atLeast = 1))
+    }
+
+    @Test
+    fun `a search started from a text selection is recorded`() {
+        val viewModel = viewModel(initialSearchWord = "眼镜")
+        awaitBook(viewModel)
+
+        assertEquals(listOf("眼镜"), awaitQueries(atLeast = 1))
+    }
+
+    private fun viewModel(initialSearchWord: String?): SearchContentViewModel {
+        runBlocking {
+            db.bookDao.insert(Book(bookUrl = BOOK_URL, name = "测试书", author = "作者"))
+        }
+        return SearchContentViewModel(
+            bookUrl = BOOK_URL,
+            initialSearchWord = initialSearchWord,
+            searchResultIndex = 0,
+            bookRepository = BookRepository(db.bookDao, db.bookChapterDao, db),
+            searchContentRepository = SearchContentRepository(historyDao = db.searchContentHistoryDao),
+            themeSettingsGateway = FakeThemeSettingsGateway(),
+        )
+    }
+
+    /** initBook() 在 IO 线程取书,书就位后才会有"确认搜索"的写入。 */
+    private fun awaitBook(viewModel: SearchContentViewModel, timeoutMs: Long = 3_000) {
+        val deadline = System.currentTimeMillis() + timeoutMs
+        while (System.currentTimeMillis() < deadline) {
+            idle()
+            if (viewModel.uiState.value.book != null) return
+            Thread.sleep(20)
+        }
+        error("书籍未在 ${timeoutMs}ms 内加载完成")
+    }
+
+    /** 轮询等待历史写入(Room 的挂起写入落在自己的执行器上,主线程 idle 不等它)。 */
+    private fun awaitQueries(atLeast: Int, timeoutMs: Long = 3_000): List<String> {
+        val deadline = System.currentTimeMillis() + timeoutMs
+        var queries = emptyList<String>()
+        while (System.currentTimeMillis() < deadline) {
+            idle()
+            queries = runBlocking { db.searchContentHistoryDao.getAll().first() }
+                .map(SearchContentHistory::query)
+            if (queries.size >= atLeast) break
+            Thread.sleep(20)
+        }
+        return queries
+    }
+
+    private fun idle() = Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+    private class FakeThemeSettingsGateway : ThemeSettingsGateway {
+        override val currentSettings: ThemeSettings = ThemeSettings()
+        override val settings: Flow<ThemeSettings> = MutableStateFlow(ThemeSettings())
+        override suspend fun update(transform: (ThemeSettings) -> ThemeSettings) = Unit
+    }
+
+    private companion object {
+        const val BOOK_URL = "test://book/1"
+    }
+}