Skip to content

Commit 0ce8a4d

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 Part of 18709: to be used in the new material SearchView
1 parent bcdb7a9 commit 0ce8a4d

File tree

4 files changed

+247
-1
lines changed

4 files changed

+247
-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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 org.json.JSONArray
23+
import timber.log.Timber
24+
25+
/**
26+
* The user's past searches in the Card Browser.
27+
*
28+
* Displayed in most recently used order.
29+
*/
30+
class SearchHistory(
31+
private val prefs: PrefsRepository = Prefs,
32+
private val maxEntries: Int = MAX_ENTRIES,
33+
) {
34+
/**
35+
* The user's past searches in the Card Browser.
36+
* Displayed in most recently used order.
37+
*/
38+
var entries: List<SearchHistoryEntry>
39+
get() {
40+
val data = prefs.getString(R.string.pref_browser_search_history, "[]") ?: "[]"
41+
val json = runCatching { JSONArray(data) }.getOrElse { JSONArray() }
42+
return List(json.length()) { json.getString(it) }
43+
.map(::SearchHistoryEntry)
44+
}
45+
private set(value) {
46+
Timber.i("updating history entries: %d values", value.size)
47+
val json = JSONArray(value.map { it.query }).toString()
48+
prefs.putString(R.string.pref_browser_search_history, json)
49+
}
50+
51+
/**
52+
* Adds the provided entry to the head of the list.
53+
*
54+
* If the entry already exists, it will be moved to the head.
55+
*/
56+
fun addRecent(entry: SearchHistoryEntry) {
57+
var newEntries = entries.toMutableList()
58+
newEntries.remove(entry)
59+
newEntries.add(0, entry)
60+
newEntries = newEntries.take(maxEntries).toMutableList()
61+
this.entries = newEntries
62+
Timber.d("updated history with '%s'", entry)
63+
}
64+
65+
/**
66+
* Removes [entry] from [entries]. Returning whether the element was contained in the collection.
67+
*/
68+
fun removeEntry(entry: SearchHistoryEntry): Boolean {
69+
val newEntries = entries.toMutableList()
70+
Timber.d("removing entry '%s'", entry)
71+
return newEntries.remove(entry).also {
72+
this.entries = newEntries
73+
}
74+
}
75+
76+
fun clear() {
77+
Timber.i("clearing all entries")
78+
this.entries = listOf()
79+
}
80+
81+
/**
82+
* An entry in the history of the card browser.
83+
* This is user-supplied, so may contain PII.
84+
* @see SearchHistory
85+
*/
86+
@JvmInline
87+
value class SearchHistoryEntry(
88+
val query: String,
89+
) {
90+
override fun toString() = query
91+
}
92+
93+
companion object {
94+
/**
95+
* The maximum number of search history entries to store.
96+
* https://github.com/ankitects/anki/blob/e9cc65569807771f548fc9c2634aabc7b2f90ed2/qt/aqt/browser/browser.py#L541
97+
*/
98+
const val MAX_ENTRIES = 30
99+
}
100+
}
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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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.test.ext.junit.runners.AndroidJUnit4
20+
import com.ichi2.anki.R
21+
import com.ichi2.anki.RobolectricTest
22+
import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry
23+
import com.ichi2.anki.settings.Prefs
24+
import com.ichi2.testutils.getString
25+
import org.hamcrest.MatcherAssert.assertThat
26+
import org.hamcrest.Matchers.empty
27+
import org.hamcrest.Matchers.equalTo
28+
import org.junit.Assert.assertFalse
29+
import org.junit.Assert.assertTrue
30+
import org.junit.Test
31+
import org.junit.runner.RunWith
32+
33+
/**
34+
* Tests for [SearchHistory]
35+
*/
36+
@RunWith(AndroidJUnit4::class)
37+
class SearchHistoryTest : RobolectricTest() {
38+
private val history =
39+
SearchHistory(maxEntries = 5).apply {
40+
this.clear()
41+
}
42+
43+
@Test
44+
fun `entries is empty if no key is set`() {
45+
assertThat(history.entries, empty())
46+
}
47+
48+
@Test
49+
fun `entries is empty if corrupt`() {
50+
writeSearchHistoryRaw("A")
51+
assertThat(history.entries, empty())
52+
}
53+
54+
@Test
55+
fun `entries returns written value`() {
56+
history.addRecent(SearchHistoryEntry("A"))
57+
assertEntriesEquals("A")
58+
}
59+
60+
@Test
61+
fun `entries skips duplicate values`() {
62+
history.addRecent(SearchHistoryEntry("A"))
63+
history.addRecent(SearchHistoryEntry("A"))
64+
assertEntriesEquals("A")
65+
}
66+
67+
@Test
68+
fun `entries returns latest values first`() {
69+
history.addRecent(SearchHistoryEntry("A"))
70+
history.addRecent(SearchHistoryEntry("B"))
71+
assertEntriesEquals("B", "A")
72+
}
73+
74+
@Test
75+
fun `entries truncates least recently used`() {
76+
addNumberedEntries(6)
77+
assertEntriesEquals("6", "5", "4", "3", "2")
78+
79+
// no more truncation occurs
80+
history.addRecent(SearchHistoryEntry("2"))
81+
assertEntriesEquals("2", "6", "5", "4", "3")
82+
}
83+
84+
@Test
85+
fun `clear on empty list does nothing`() {
86+
history.clear()
87+
assertThat(history.entries, empty())
88+
}
89+
90+
@Test
91+
fun `clear on full list empties list`() {
92+
addNumberedEntries(6)
93+
history.clear()
94+
assertThat(history.entries, empty())
95+
}
96+
97+
@Test
98+
fun `remove non-existing entry`() {
99+
assertFalse(history.removeEntry(SearchHistoryEntry("AA")))
100+
}
101+
102+
@Test
103+
fun `remove existing entry`() {
104+
addNumberedEntries(6)
105+
assertTrue(history.removeEntry(SearchHistoryEntry("5")))
106+
assertEntriesEquals("6", "4", "3", "2")
107+
}
108+
109+
@Test
110+
fun `pref key is unchanged`() {
111+
// this is checked in CrashReportService to ensure user data isn't sent to our servers.
112+
assertThat(getString(R.string.pref_browser_search_history), equalTo("browser_search_history"))
113+
}
114+
115+
/** Adds numbered entries from 1 to [count] inclusive */
116+
fun addNumberedEntries(count: Int) =
117+
repeat(count) {
118+
history.addRecent(SearchHistoryEntry((it + 1).toString()))
119+
}
120+
121+
fun assertEntriesEquals(vararg entries: String) {
122+
val listOfEntities = entries.map(::SearchHistoryEntry)
123+
assertThat(history.entries, equalTo(listOfEntities))
124+
}
125+
}
126+
127+
fun writeSearchHistoryRaw(value: String?) = Prefs.putString(R.string.pref_browser_search_history, value)

0 commit comments

Comments
 (0)