Skip to content

Commit 695a2bc

Browse files
committed
feat: browser search history
`SharedPreferences` are used as they're a common AnkiDroid abstraction. I defined a new `.xml` file, as the key was not a preference key A JSON Object enables adding properties at a later date Part of 18709: to be used in the new material SearchView
1 parent bcdb7a9 commit 695a2bc

File tree

4 files changed

+264
-1
lines changed

4 files changed

+264
-1
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CrashReportService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ object CrashReportService {
9393
val builder =
9494
CoreConfigurationBuilder()
9595
.withBuildConfigClass(com.ichi2.anki.BuildConfig::class.java) // AnkiDroid BuildConfig - Acrarium#319
96-
.withExcludeMatchingSharedPreferencesKeys("username", "hkey")
96+
.withExcludeMatchingSharedPreferencesKeys("username", "hkey", "browser_search_history")
9797
.withSharedPreferencesName("acra")
9898
.withReportContent(
9999
ReportField.REPORT_ID,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.browser
18+
19+
import com.ichi2.anki.R
20+
import com.ichi2.anki.settings.Prefs
21+
import com.ichi2.anki.settings.PrefsRepository
22+
import kotlinx.serialization.SerialName
23+
import kotlinx.serialization.Serializable
24+
import kotlinx.serialization.json.Json
25+
import timber.log.Timber
26+
27+
/**
28+
* The user's past searches in the Card Browser.
29+
*
30+
* Displayed in most recently used order.
31+
*/
32+
class SearchHistory(
33+
private val prefs: PrefsRepository = Prefs,
34+
private val maxEntries: Int = MAX_ENTRIES,
35+
) {
36+
/**
37+
* The user's past searches in the Card Browser.
38+
* Displayed in most recently used order.
39+
*/
40+
var entries: List<SearchHistoryEntry>
41+
get() {
42+
val jsonString = prefs.getString(R.string.pref_browser_search_history, "[]") ?: "[]"
43+
return runCatching {
44+
Json.decodeFromString<List<SearchHistoryEntry>>(jsonString)
45+
}.getOrElse { emptyList() }
46+
}
47+
private set(value) {
48+
Timber.i("updating history entries: %d values", value.size)
49+
val json = Json.encodeToString(value)
50+
prefs.putString(R.string.pref_browser_search_history, json)
51+
}
52+
53+
/**
54+
* Adds the provided entry to the head of the list. Returns the updated list.
55+
*
56+
* If the entry already exists, it will be moved to the head.
57+
*/
58+
fun addRecent(entry: SearchHistoryEntry): List<SearchHistoryEntry> {
59+
val updatedEntries = entries.toMutableList()
60+
updatedEntries.remove(entry)
61+
updatedEntries.add(0, entry)
62+
return updatedEntries.take(maxEntries).also {
63+
this.entries = it.toMutableList()
64+
Timber.d("updated history with '%s'", entry)
65+
}
66+
}
67+
68+
/**
69+
* Removes [entry] from [entries]. Returning whether the element was contained in the collection.
70+
*/
71+
fun removeEntry(entry: SearchHistoryEntry): Boolean {
72+
val newEntries = entries.toMutableList()
73+
Timber.d("removing entry '%s'", entry)
74+
return newEntries.remove(entry).also {
75+
this.entries = newEntries
76+
}
77+
}
78+
79+
fun clear() {
80+
Timber.i("clearing all entries")
81+
this.entries = listOf()
82+
}
83+
84+
/**
85+
* An entry in the history of the card browser.
86+
* This is user-supplied, so may contain PII.
87+
* @see SearchHistory
88+
*/
89+
// !! When updating this, consider equality in addRecent
90+
@Serializable
91+
data class SearchHistoryEntry(
92+
@SerialName("q")
93+
val query: String,
94+
) {
95+
override fun toString() = query
96+
}
97+
98+
companion object {
99+
/**
100+
* The maximum number of search history entries to store.
101+
* https://github.com/ankitects/anki/blob/e9cc65569807771f548fc9c2634aabc7b2f90ed2/qt/aqt/browser/browser.py#L541
102+
*/
103+
const val MAX_ENTRIES = 30
104+
}
105+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
4+
~
5+
~ This program is free software; you can redistribute it and/or modify it under
6+
~ the terms of the GNU General Public License as published by the Free Software
7+
~ Foundation; either version 3 of the License, or (at your option) any later
8+
~ version.
9+
~
10+
~ This program is distributed in the hope that it will be useful, but WITHOUT ANY
11+
~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
~ PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
~
14+
~ You should have received a copy of the GNU General Public License along with
15+
~ this program. If not, see <http://www.gnu.org/licenses/>.
16+
-->
17+
<resources>
18+
<string name="pref_browser_search_history">browser_search_history</string>
19+
</resources>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.browser
18+
19+
import androidx.annotation.CheckResult
20+
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import com.ichi2.anki.R
22+
import com.ichi2.anki.RobolectricTest
23+
import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry
24+
import com.ichi2.anki.settings.Prefs
25+
import com.ichi2.testutils.getString
26+
import org.hamcrest.MatcherAssert.assertThat
27+
import org.hamcrest.Matchers.empty
28+
import org.hamcrest.Matchers.equalTo
29+
import org.junit.Assert.assertFalse
30+
import org.junit.Assert.assertTrue
31+
import org.junit.Test
32+
import org.junit.runner.RunWith
33+
34+
/**
35+
* Tests for [SearchHistory]
36+
*/
37+
@RunWith(AndroidJUnit4::class)
38+
class SearchHistoryTest : RobolectricTest() {
39+
private val history =
40+
SearchHistory(maxEntries = 5).apply {
41+
this.clear()
42+
}
43+
44+
@Test
45+
fun `entries is empty if no key is set`() {
46+
assertThat(history.entries, empty())
47+
}
48+
49+
@Test
50+
fun `entries is empty if corrupt`() {
51+
writeSearchHistoryRaw("A")
52+
assertThat(history.entries, empty())
53+
}
54+
55+
@Test
56+
fun `entries returns written value`() {
57+
history.addRecent(SearchHistoryEntry("A"))
58+
assertEntriesEquals("A")
59+
}
60+
61+
@Test
62+
fun `entries skips duplicate values`() {
63+
history.addRecent(SearchHistoryEntry("A"))
64+
history.addRecent(SearchHistoryEntry("A"))
65+
assertEntriesEquals("A")
66+
}
67+
68+
@Test
69+
fun `entries returns latest values first`() {
70+
history.addRecent(SearchHistoryEntry("A"))
71+
history.addRecent(SearchHistoryEntry("B"))
72+
assertEntriesEquals("B", "A")
73+
}
74+
75+
@Test
76+
fun `entries truncates least recently used`() {
77+
addNumberedEntries(6)
78+
assertEntriesEquals("6", "5", "4", "3", "2")
79+
80+
// no more truncation occurs
81+
history.addRecent(SearchHistoryEntry("2"))
82+
assertEntriesEquals("2", "6", "5", "4", "3")
83+
}
84+
85+
@Test
86+
fun `clear on empty list does nothing`() {
87+
history.clear()
88+
assertThat(history.entries, empty())
89+
}
90+
91+
@Test
92+
fun `clear on full list empties list`() {
93+
addNumberedEntries(6)
94+
history.clear()
95+
assertThat(history.entries, empty())
96+
}
97+
98+
@Test
99+
fun `remove non-existing entry`() {
100+
assertFalse(history.removeEntry(SearchHistoryEntry("AA")))
101+
}
102+
103+
@Test
104+
fun `remove existing entry`() {
105+
addNumberedEntries(6)
106+
assertTrue(history.removeEntry(SearchHistoryEntry("5")))
107+
assertEntriesEquals("6", "4", "3", "2")
108+
}
109+
110+
@Test
111+
fun `pref key is unchanged`() {
112+
// this is checked in CrashReportService to ensure user data isn't sent to our servers.
113+
assertThat(getString(R.string.pref_browser_search_history), equalTo("browser_search_history"))
114+
}
115+
116+
@Test
117+
fun `v1 serialization is unchanged`() {
118+
// additional properties will be added; make sure we don't corrupt past entries
119+
addNumberedEntries(1)
120+
assertThat(readSearchHistoryRaw(), equalTo("""[{"q":"1"}]"""))
121+
assertThat(history.entries.single().query, equalTo("1"))
122+
}
123+
124+
/** Adds numbered entries from 1 to [count] inclusive */
125+
fun addNumberedEntries(count: Int) =
126+
repeat(count) {
127+
history.addRecent(SearchHistoryEntry((it + 1).toString()))
128+
}
129+
130+
fun assertEntriesEquals(vararg entries: String) {
131+
val listOfEntities = entries.map(::SearchHistoryEntry)
132+
assertThat(history.entries, equalTo(listOfEntities))
133+
}
134+
}
135+
136+
fun writeSearchHistoryRaw(value: String?) = Prefs.putString(R.string.pref_browser_search_history, value)
137+
138+
@CheckResult
139+
fun readSearchHistoryRaw() = Prefs.getString(R.string.pref_browser_search_history, null)!!

0 commit comments

Comments
 (0)