Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ object CrashReportService {
val builder =
CoreConfigurationBuilder()
.withBuildConfigClass(com.ichi2.anki.BuildConfig::class.java) // AnkiDroid BuildConfig - Acrarium#319
.withExcludeMatchingSharedPreferencesKeys("username", "hkey")
.withExcludeMatchingSharedPreferencesKeys("username", "hkey", "browser_search_history")
.withSharedPreferencesName("acra")
.withReportContent(
ReportField.REPORT_ID,
Expand Down
105 changes: 105 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/browser/SearchHistory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2026 David Allison <[email protected]>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.browser

import com.ichi2.anki.R
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.settings.PrefsRepository
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import timber.log.Timber

/**
* The user's past searches in the Card Browser.
*
* Displayed in most recently used order.
*/
class SearchHistory(
private val prefs: PrefsRepository = Prefs,
private val maxEntries: Int = MAX_ENTRIES,
) {
/**
* The user's past searches in the Card Browser.
* Displayed in most recently used order.
*/
var entries: List<SearchHistoryEntry>
get() {
val jsonString = prefs.getString(R.string.pref_browser_search_history, "[]") ?: "[]"
return runCatching {
Json.decodeFromString<List<SearchHistoryEntry>>(jsonString)
}.getOrElse { emptyList() }
}
private set(value) {
Timber.i("updating history entries: %d values", value.size)
val json = Json.encodeToString(value)
prefs.putString(R.string.pref_browser_search_history, json)
}

/**
* Adds the provided entry to the head of the list. Returns the updated list.
*
* If the entry already exists, it will be moved to the head.
*/
fun addRecent(entry: SearchHistoryEntry): List<SearchHistoryEntry> {
val updatedEntries = entries.toMutableList()
updatedEntries.remove(entry)
updatedEntries.add(0, entry)
return updatedEntries.take(maxEntries).also {
this.entries = it.toMutableList()
Timber.d("updated history with '%s'", entry)
}
}

/**
* Removes [entry] from [entries]. Returning whether the element was contained in the collection.
*/
fun removeEntry(entry: SearchHistoryEntry): Boolean {
val newEntries = entries.toMutableList()
Timber.d("removing entry '%s'", entry)
return newEntries.remove(entry).also {
this.entries = newEntries
}
}

fun clear() {
Timber.i("clearing all entries")
this.entries = listOf()
}

/**
* An entry in the history of the card browser.
* This is user-supplied, so may contain PII.
* @see SearchHistory
*/
// !! When updating this, consider equality in addRecent
@Serializable
data class SearchHistoryEntry(
@SerialName("q")
val query: String,
) {
override fun toString() = query
}

companion object {
/**
* The maximum number of search history entries to store.
* https://github.com/ankitects/anki/blob/e9cc65569807771f548fc9c2634aabc7b2f90ed2/qt/aqt/browser/browser.py#L541
*/
const val MAX_ENTRIES = 30
}
}
19 changes: 19 additions & 0 deletions AnkiDroid/src/main/res/values/browser.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 David Allison <[email protected]>
~
~ This program is free software; you can redistribute it and/or modify it under
~ the terms of the GNU General Public License as published by the Free Software
~ Foundation; either version 3 of the License, or (at your option) any later
~ version.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT ANY
~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
~ PARTICULAR PURPOSE. See the GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources>
<string name="pref_browser_search_history">browser_search_history</string>
</resources>
139 changes: 139 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/browser/SearchHistoryTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2026 David Allison <[email protected]>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.ichi2.anki.browser

import androidx.annotation.CheckResult
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry
import com.ichi2.anki.settings.Prefs
import com.ichi2.testutils.getString
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.empty
import org.hamcrest.Matchers.equalTo
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Tests for [SearchHistory]
*/
@RunWith(AndroidJUnit4::class)
class SearchHistoryTest : RobolectricTest() {
private val history =
SearchHistory(maxEntries = 5).apply {
this.clear()
}

@Test
fun `entries is empty if no key is set`() {
assertThat(history.entries, empty())
}

@Test
fun `entries is empty if corrupt`() {
writeSearchHistoryRaw("A")
assertThat(history.entries, empty())
}

@Test
fun `entries returns written value`() {
history.addRecent(SearchHistoryEntry("A"))
assertEntriesEquals("A")
}

@Test
fun `entries skips duplicate values`() {
history.addRecent(SearchHistoryEntry("A"))
history.addRecent(SearchHistoryEntry("A"))
assertEntriesEquals("A")
}

@Test
fun `entries returns latest values first`() {
history.addRecent(SearchHistoryEntry("A"))
history.addRecent(SearchHistoryEntry("B"))
assertEntriesEquals("B", "A")
}

@Test
fun `entries truncates least recently used`() {
addNumberedEntries(6)
assertEntriesEquals("6", "5", "4", "3", "2")

// no more truncation occurs
history.addRecent(SearchHistoryEntry("2"))
assertEntriesEquals("2", "6", "5", "4", "3")
}

@Test
fun `clear on empty list does nothing`() {
history.clear()
assertThat(history.entries, empty())
}

@Test
fun `clear on full list empties list`() {
addNumberedEntries(6)
history.clear()
assertThat(history.entries, empty())
}

@Test
fun `remove non-existing entry`() {
assertFalse(history.removeEntry(SearchHistoryEntry("AA")))
}

@Test
fun `remove existing entry`() {
addNumberedEntries(6)
assertTrue(history.removeEntry(SearchHistoryEntry("5")))
assertEntriesEquals("6", "4", "3", "2")
}

@Test
fun `pref key is unchanged`() {
// this is checked in CrashReportService to ensure user data isn't sent to our servers.
assertThat(getString(R.string.pref_browser_search_history), equalTo("browser_search_history"))
}

@Test
fun `v1 serialization is unchanged`() {
// additional properties will be added; make sure we don't corrupt past entries
addNumberedEntries(1)
assertThat(readSearchHistoryRaw(), equalTo("""[{"q":"1"}]"""))
assertThat(history.entries.single().query, equalTo("1"))
}

/** Adds numbered entries from 1 to [count] inclusive */
fun addNumberedEntries(count: Int) =
repeat(count) {
history.addRecent(SearchHistoryEntry((it + 1).toString()))
}

fun assertEntriesEquals(vararg entries: String) {
val listOfEntities = entries.map(::SearchHistoryEntry)
assertThat(history.entries, equalTo(listOfEntities))
}
}

fun writeSearchHistoryRaw(value: String?) = Prefs.putString(R.string.pref_browser_search_history, value)

@CheckResult
fun readSearchHistoryRaw() = Prefs.getString(R.string.pref_browser_search_history, null)!!