From 49422dc4b502eb7dcd081a52b28a47247f014783 Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 12:49:55 +0100 Subject: [PATCH 1/9] Keep the token out of the dashboard page once its cookie is installed When the HttpOnly session cookie was installed, the page script now hands the dashboard only the member name; the token is still handed over when the cookie could not be installed, as for older servers without /auth/session. --- .../ichi2/anki/ankiquest/AnkiquestActivity.kt | 8 ++++++-- .../anki/ankiquest/AnkiquestWebSession.kt | 18 +++++++++++++++--- .../anki/ankiquest/AnkiquestSettingsTest.kt | 12 ++++++++++++ .../anki/ankiquest/AnkiquestWebSessionTest.kt | 11 +++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt index e8ca0a9436b4..46b9eef4ff8c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt @@ -77,6 +77,7 @@ internal class AnkiquestBrowserSessionBridge { class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { private lateinit var webView: WebView private var session: AnkiquestWebSession? = null + private var sessionCookie = false private var account = "" private var refreshAfterSettings = false private var clearHistoryAfterLoad = false @@ -193,7 +194,7 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { clearHistoryAfterLoad = false back.isEnabled = view.canGoBack() } - current.script(url)?.let { view.evaluateJavascript(it, null) } + current.script(url, sessionCookie)?.let { view.evaluateJavascript(it, null) } } override fun doUpdateVisitedHistory( @@ -222,8 +223,10 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { val expected = session ?: return val expectedAccount = account prepareSession?.cancel() + sessionCookie = false prepareSession = lifecycleScope.launch { + var installed = false val ready = sessionBridge.prepare( current = { @@ -241,13 +244,14 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { null } }, - install = { cookie -> setSessionCookie(expected.dashboard, cookie) }, + install = { cookie -> setSessionCookie(expected.dashboard, cookie).also { installed = it } }, ) if (expected != session || expected != Ankiquest.webSession() || expectedAccount != account || expectedAccount != AnkiquestNavigation.accountFingerprint() ) { return@launch } + sessionCookie = ready && installed if (!ready) { findViewById(R.id.progress_bar).visibility = View.GONE val message = getString(R.string.ankiquest_web_prepare_failed).htmlEncode() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt index fb01609c7219..9d82c364d04e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt @@ -38,8 +38,15 @@ internal data class AnkiquestWebSession( actual.encodedPath in paths && queryAllowed } - /** The page script; a navigation can finish after the native check, so it checks the actual document again. */ - fun script(url: String?): String? { + /** + * The page script; a navigation can finish after the native check, so it checks the actual document again. + * With [cookie], the HttpOnly session cookie is installed, so the page learns only who is signed in and + * never sees the token. + */ + fun script( + url: String?, + cookie: Boolean = false, + ): String? { if (!allows(url)) return null val expected = checkNotNull(base) val origin = @@ -51,7 +58,12 @@ internal data class AnkiquestWebSession( .build() .toString() .removeSuffix("/") - val session = if (token.isEmpty()) "null" else JSONObject().put("user", user).put("token", token).toString() + val session = + when { + token.isEmpty() -> "null" + cookie -> JSONObject().put("user", user).toString() + else -> JSONObject().put("user", user).put("token", token).toString() + } return """ (() => { const page = new URL(window.location.href); diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt index 186c506c0d1a..2e78958bde90 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt @@ -106,6 +106,18 @@ class AnkiquestSettingsTest : RobolectricTest() { assertTrue((0 until menu.size()).any { menu.getItem(it).title == targetContext.getString(CommonString.settings) }) } + @Test + fun `dashboard with an installed session cookie learns the member but never the token`() { + coEvery { Ankiquest.dashboardSession(any()) } returns "ankiquest_session=opaque; Path=/; HttpOnly; Secure; SameSite=Lax" + val controller = Robolectric.buildActivity(AnkiquestActivity::class.java).create().also { shadowOf(Looper.getMainLooper()).idle() } + saveControllerForCleanup(controller) + val webView = controller.get().findViewById(R.id.web_view) + shadowOf(webView).webViewClient.onPageFinished(webView, "https://quest.example/anki/?embed=1#cerro") + val script = assertNotNull(shadowOf(webView).lastEvaluatedJavascript) + assertTrue(script.contains("window.ankiquestSession = {\"user\":\"cerro\"};")) + assertFalse(script.contains("saved-test-token")) + } + @Test fun `dashboard receives the saved account without asking for its token again`() { val controller = Robolectric.buildActivity(AnkiquestActivity::class.java).create().also { shadowOf(Looper.getMainLooper()).idle() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt index 334f737654c6..01d7ee831b8a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt @@ -87,6 +87,17 @@ class AnkiquestWebSessionTest { assertTrue(script.contains("new CustomEvent('ankiquest-auth')")) } + @Test + fun `with the session cookie installed the page learns the member but not the token`() { + val script = assertNotNull(session.script("https://quest.example/anki/", cookie = true)) + + val handed = JSONObject(script.substringAfter("window.ankiquestSession = ").substringBefore(";\n")) + assertEquals("cerro", handed.getString("user")) + assertFalse(handed.has("token")) + assertFalse(script.contains("secret")) + assertNull(session.script("https://other.example/anki/", cookie = true)) + } + @Test fun `without a token the page is told it is signed out`() { val signedOut = AnkiquestWebSession("https://quest.example/anki/#cerro", "cerro", "") From deb52b9593b605ade65ad64f12a85e4e31f0528b Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 15:22:15 +0100 Subject: [PATCH 2/9] Hand the dashboard only the member once the server reports cookie sessions AnkiquestCapabilities reads GET /api/capabilities per server, keeps the answer in memory and preferences with its fetch time, and asks again after six hours, for another account, and from the poll worker. A server without the endpoint or with an unreadable answer has no features; an unreachable one keeps the last known set. The dashboard keeps receiving the token until the server reports cookie_session, so this works against servers that still need it. --- .../ichi2/anki/ankiquest/AnkiquestActivity.kt | 4 +- .../anki/ankiquest/AnkiquestCapabilities.kt | 139 +++++++++++++++++ .../com/ichi2/anki/ankiquest/AnkiquestPoll.kt | 4 +- .../anki/ankiquest/AnkiquestWebSession.kt | 4 +- .../ankiquest/AnkiquestCapabilitiesTest.kt | 145 ++++++++++++++++++ .../ichi2/anki/ankiquest/AnkiquestPollTest.kt | 14 ++ .../anki/ankiquest/AnkiquestSettingsTest.kt | 30 +++- 7 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt index 46b9eef4ff8c..a90303a20070 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt @@ -34,6 +34,7 @@ import com.ichi2.anki.snackbar.showSnackbar import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -227,6 +228,7 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { prepareSession = lifecycleScope.launch { var installed = false + val features = async { AnkiquestHomeData.account()?.let { AnkiquestCapabilities.current(it) }.orEmpty() } val ready = sessionBridge.prepare( current = { @@ -251,7 +253,7 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { ) { return@launch } - sessionCookie = ready && installed + sessionCookie = ready && installed && AnkiquestCapabilities.COOKIE_SESSION in features.await() if (!ready) { findViewById(R.id.progress_bar).visibility = View.GONE val message = getString(R.string.ankiquest_web_prepare_failed).htmlEncode() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt new file mode 100644 index 000000000000..4c807e4ee792 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.common.time.TimeManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * The optional features a server reports at `GET /api/capabilities`, so the app can use a feature as + * soon as the server it talks to has it, whatever order the app and the server are updated in. A + * server too old to answer reports nothing; while a server cannot be reached, the features it last + * reported still count. + */ +internal object AnkiquestCapabilities { + /** The server keeps a dashboard signed in by its HttpOnly cookie alone, without the token. */ + const val COOKIE_SESSION = "cookie_session" + + @VisibleForTesting + internal const val PREF_CAPABILITIES = "ankiquestCapabilities" + + @VisibleForTesting + internal const val MAX_AGE_MS = 6L * 60 * 60 * 1000 + + private data class Known( + val features: Set, + val fetchedAt: Long, + val scope: String, + ) + + private val known = ConcurrentHashMap() + + /** Whether [account]'s server last reported [feature], without asking it again. */ + fun has( + account: HomeAccount, + feature: String, + ): Boolean = feature in features(account) + + /** The features [account]'s server last reported, without asking it again. */ + fun features(account: HomeAccount): Set = lookup(account.server)?.features.orEmpty() + + /** + * The features of [account]'s server, asked again when they were last fetched over [MAX_AGE_MS] + * ago or for a different account. + */ + suspend fun current(account: HomeAccount): Set { + val last = lookup(account.server) + if (last != null && last.scope == account.scope && age(last) in 0 until MAX_AGE_MS) return last.features + return refresh(account) + } + + /** Asks [account]'s server for its features; when it cannot be reached, answers the ones it last reported. */ + suspend fun refresh(account: HomeAccount): Set = + withContext(Dispatchers.IO) { + val fetched = + try { + fetch(account) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d(e, "ankiquest capabilities unavailable") + return@withContext features(account) + } + store(account, fetched) + fetched + } + + /** The features in a `/api/capabilities` answer; anything unreadable counts as none. */ + fun parse(body: String): Set = + try { + parse(JSONObject(body)) + } catch (_: JSONException) { + emptySet() + } + + private fun parse(answer: JSONObject): Set { + val features = answer.optJSONArray("features") ?: return emptySet() + return (0 until features.length()).mapNotNull { features.opt(it) as? String }.filter { it.isNotEmpty() }.toSet() + } + + private fun fetch(account: HomeAccount): Set { + val request = AnkiquestApi.request(account.url("api/capabilities"), "", account.language).build() + return try { + AnkiquestApi.call(request, AnkiquestApi.Timeout.QUICK) { parse(it.body.string()) } + } catch (e: AnkiquestHttpException) { + if (e.code == 404) emptySet() else throw e + } + } + + private fun age(entry: Known): Long = TimeManager.time.intTimeMS() - entry.fetchedAt + + private fun preferences(): SharedPreferences = AnkiDroidApp.sharedPrefs() + + private fun key(server: String) = scopedKey(PREF_CAPABILITIES, server) + + private fun lookup(server: String): Known? = known[server] ?: stored(server)?.also { known[server] = it } + + private fun stored(server: String): Known? = + try { + val value = JSONObject(preferences().getString(key(server), null) ?: return null) + Known(parse(value), value.optLong("at"), value.optString("scope")) + } catch (_: JSONException) { + null + } catch (_: ClassCastException) { + null + } + + private fun store( + account: HomeAccount, + features: Set, + ) { + val entry = Known(features, TimeManager.time.intTimeMS(), account.scope) + known[account.server] = entry + preferences().edit { + putString( + key(account.server), + JSONObject() + .put("features", JSONArray(features.sorted())) + .put("at", entry.fetchedAt) + .put("scope", entry.scope) + .toString(), + ) + } + } + + /** Forgets the features held in memory, as a restarted app would. */ + @VisibleForTesting + internal fun forgetMemory() = known.clear() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt index ff451d6eff7f..de7d901ef6b0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt @@ -25,7 +25,8 @@ import java.util.concurrent.TimeUnit /** * Uploads pending reviews and fetches the leaderboard and profile in the background, redraws the - * widget and raises rank and streak notifications. Runs every 15 minutes and on demand. + * widget and raises rank and streak notifications, and keeps the server's capabilities current. + * Runs every 15 minutes and on demand. */ object AnkiquestPoll { private const val PERIODIC_NAME = "ankiquestPoll" @@ -158,6 +159,7 @@ object AnkiquestPoll { Timber.d(e, "ankiquest optional photos could not refresh") } } + AnkiquestHomeData.account(prefs)?.let { AnkiquestCapabilities.current(it) } return result } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt index 9d82c364d04e..6838c80b0dd3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt @@ -40,8 +40,8 @@ internal data class AnkiquestWebSession( /** * The page script; a navigation can finish after the native check, so it checks the actual document again. - * With [cookie], the HttpOnly session cookie is installed, so the page learns only who is signed in and - * never sees the token. + * With [cookie], the HttpOnly session cookie is installed and the server reports + * [AnkiquestCapabilities.COOKIE_SESSION], so the page learns only who is signed in and never sees the token. */ fun script( url: String?, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt new file mode 100644 index 000000000000..7ecfbd6257d8 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.testutils.EmptyApplication +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class AnkiquestCapabilitiesTest : RobolectricTest() { + private lateinit var server: HttpServer + private lateinit var account: HomeAccount + private val clock = MockTime(1_750_000_000_000) + private val requests = CopyOnWriteArrayList>() + + @Volatile private var status = 200 + + @Volatile private var body = """{"version":1,"features":["activity","cookie_session","wins"]}""" + + @Before + fun prepare() { + AnkiDroidApp.sharedPreferencesTestingOverride = targetContext.sharedPrefs() + TimeManager.resetWith(clock) + AnkiquestCapabilities.forgetMemory() + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + requests += exchange.requestURI.path to exchange.requestHeaders.getFirst("Authorization") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + exchange.close() + } + server.start() + account = HomeAccount("http://127.0.0.1:${server.address.port}", "cerro", "secret") + } + + @After + fun stop() { + runCatching { server.stop(0) } + AnkiquestCapabilities.forgetMemory() + AnkiDroidApp.sharedPreferencesTestingOverride = null + } + + @Test + fun `features are read from the answer and anything unreadable counts as none`() { + assertEquals(setOf("activity", "wins"), AnkiquestCapabilities.parse("""{"version":1,"features":["activity",7,"","wins"]}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("""{"version":1}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("""{"features":"wins"}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("not found")) + } + + @Test + fun `capabilities are asked for without credentials`() = + runBlocking { + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + assertEquals(listOf("/api/capabilities" to null), requests.toList()) + assertTrue(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + assertFalse(AnkiquestCapabilities.has(account, "devices")) + } + + @Test + fun `a server without the endpoint has no features`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + status = 404 + body = """{"error":"not found"}""" + assertEquals(emptySet(), AnkiquestCapabilities.refresh(account)) + assertFalse(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + } + + @Test + fun `an unreadable answer replaces the known features with none`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + body = "not json" + assertEquals(emptySet(), AnkiquestCapabilities.refresh(account)) + } + + @Test + fun `an unreachable server keeps the features it last reported`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + status = 503 + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + server.stop(0) + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + } + + @Test + fun `fresh features are reused and asked again once they are old`() = + runBlocking { + AnkiquestCapabilities.current(account) + clock.addM(60) + body = """{"version":1,"features":["wins"]}""" + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.current(account)) + assertEquals(1, requests.size) + clock.addM(360) + assertEquals(setOf("wins"), AnkiquestCapabilities.current(account)) + assertEquals(2, requests.size) + } + + @Test + fun `features survive a restart through the stored copy`() = + runBlocking { + AnkiquestCapabilities.current(account) + AnkiquestCapabilities.forgetMemory() + assertTrue(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.current(account)) + assertEquals(1, requests.size) + } + + @Test + fun `another account or server asks again`() = + runBlocking { + AnkiquestCapabilities.current(account) + body = """{"version":1,"features":["wins"]}""" + assertEquals(setOf("wins"), AnkiquestCapabilities.current(account.copy(user = "hill", token = "other"))) + assertEquals(2, requests.size) + val elsewhere = account.copy(server = "http://127.0.0.1:${server.address.port}/other") + assertFalse(AnkiquestCapabilities.has(elsewhere, "wins")) + AnkiquestCapabilities.current(elsewhere) + assertEquals("/other/api/capabilities", requests.last().first) + assertTrue(AnkiquestCapabilities.has(elsewhere, "wins")) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt index 68a8d54f6f6c..564f4f2ae73e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt @@ -32,6 +32,7 @@ import java.net.InetSocketAddress import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -152,6 +153,19 @@ class AnkiquestPollTest : RobolectricTest() { assertTrue(upload < requests.indexOf("/api/leaderboard")) } + @Test + fun `polling keeps the server capabilities current after the inbox`() = + runBlocking { + assertEquals(Result.success(), worker().doWork()) + + val capabilities = requests.indexOf("/api/capabilities") + assertTrue(capabilities > requests.indexOfFirst { it.startsWith("/api/notifications/") }) + assertFalse(AnkiquestCapabilities.has(assertNotNull(AnkiquestHomeData.account()), AnkiquestCapabilities.COOKIE_SESSION)) + requests.clear() + assertEquals(Result.success(), worker().doWork()) + assertTrue(requests.none { it == "/api/capabilities" }, "fresh capabilities are not asked again") + } + @Test fun `a failed upload does not stop polling`() = runBlocking { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt index 2e78958bde90..6d777ed5bc89 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt @@ -44,6 +44,8 @@ class AnkiquestSettingsTest : RobolectricTest() { // These UI tests do not load Anki's native collection backend in the background. every { Ankiquest.onActivityResumed(any()) } returns Unit coEvery { Ankiquest.dashboardSession(any()) } returns null + mockkObject(AnkiquestCapabilities) + coEvery { AnkiquestCapabilities.current(any()) } returns emptySet() AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.PREF_URL, "https://quest.example/anki") putString(Ankiquest.PREF_USER, "cerro") @@ -53,7 +55,7 @@ class AnkiquestSettingsTest : RobolectricTest() { @After fun clearMocks() { - unmockkObject(Ankiquest) + unmockkObject(Ankiquest, AnkiquestCapabilities) } @Test @@ -109,13 +111,33 @@ class AnkiquestSettingsTest : RobolectricTest() { @Test fun `dashboard with an installed session cookie learns the member but never the token`() { coEvery { Ankiquest.dashboardSession(any()) } returns "ankiquest_session=opaque; Path=/; HttpOnly; Secure; SameSite=Lax" + coEvery { AnkiquestCapabilities.current(any()) } returns setOf(AnkiquestCapabilities.COOKIE_SESSION, "wins") + val script = dashboardScript() + assertTrue(script.contains("window.ankiquestSession = {\"user\":\"cerro\"};")) + assertFalse(script.contains("saved-test-token")) + } + + @Test + fun `dashboard keeps the token while the server does not report cookie sessions`() { + coEvery { Ankiquest.dashboardSession(any()) } returns "ankiquest_session=opaque; Path=/; HttpOnly; Secure; SameSite=Lax" + coEvery { AnkiquestCapabilities.current(any()) } returns setOf("wins") + val script = dashboardScript() + assertTrue(script.contains("saved-test-token")) + coVerify { AnkiquestCapabilities.current(match { it.server == "https://quest.example/anki" && it.user == "cerro" }) } + } + + @Test + fun `dashboard keeps the token when no session cookie was installed`() { + coEvery { AnkiquestCapabilities.current(any()) } returns setOf(AnkiquestCapabilities.COOKIE_SESSION) + assertTrue(dashboardScript().contains("saved-test-token")) + } + + private fun dashboardScript(): String { val controller = Robolectric.buildActivity(AnkiquestActivity::class.java).create().also { shadowOf(Looper.getMainLooper()).idle() } saveControllerForCleanup(controller) val webView = controller.get().findViewById(R.id.web_view) shadowOf(webView).webViewClient.onPageFinished(webView, "https://quest.example/anki/?embed=1#cerro") - val script = assertNotNull(shadowOf(webView).lastEvaluatedJavascript) - assertTrue(script.contains("window.ankiquestSession = {\"user\":\"cerro\"};")) - assertFalse(script.contains("saved-test-token")) + return assertNotNull(shadowOf(webView).lastEvaluatedJavascript) } @Test From 2af77b6b83b8d036b34554417dbb80abc7be39aa Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 15:30:25 +0100 Subject: [PATCH 3/9] Check the app's parsing against the server's recorded API answers The server's docs/api/examples are committed as test resources and fed to the code the app reads each answer with. The ankiquest API contract workflow replaces them with the server's current examples, runs the test daily as well as on pull requests, and checks the app's art with the server's exporter. --- .github/workflows/ankiquest_api_contract.yml | 97 + .../ankiquest/AnkiquestApiContractTest.kt | 260 ++ .../resources/ankiquest-api/capabilities.json | 12 + .../resources/ankiquest-api/companion.json | 3 + .../test/resources/ankiquest-api/decks.json | 28 + .../test/resources/ankiquest-api/devices.json | 18 + .../test/resources/ankiquest-api/goal.json | 6 + .../resources/ankiquest-api/leaderboard.json | 48 + .../ankiquest-api/notifications.json | 51 + .../test/resources/ankiquest-api/profile.json | 2271 ++++++++++++++++ .../test/resources/ankiquest-api/rank.json | 10 + .../test/resources/ankiquest-api/react.json | 3 + .../ankiquest-api/share_to_friends.json | 7 + .../test/resources/ankiquest-api/sharing.json | 15 + .../test/resources/ankiquest-api/sign_in.json | 5 + .../test/resources/ankiquest-api/sign_up.json | 5 + .../ankiquest-api/streak_freezes.json | 5 + .../test/resources/ankiquest-api/upload.json | 2276 +++++++++++++++++ .../test/resources/ankiquest-api/wins.json | 26 + 19 files changed, 5146 insertions(+) create mode 100644 .github/workflows/ankiquest_api_contract.yml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/capabilities.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/companion.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/decks.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/devices.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/goal.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/notifications.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/profile.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/rank.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/react.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/sharing.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/sign_in.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/sign_up.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/upload.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/wins.json diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml new file mode 100644 index 000000000000..d95276e12790 --- /dev/null +++ b/.github/workflows/ankiquest_api_contract.yml @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: ankiquest API contract + +on: + pull_request: + push: + branches: [ankiquest] + schedule: + - cron: "17 5 * * *" + workflow_dispatch: + inputs: + server_ref: + description: "float3/ankiquest branch, tag or commit whose API examples to check against" + required: true + default: master + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +env: + JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10 + +jobs: + contract: + name: API contract + if: github.event_name != 'schedule' || github.repository == 'float3/AnkiQuest-Android' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: float3/ankiquest + ref: ${{ inputs.server_ref || 'master' }} + path: server + persist-credentials: false + + - name: Use the server's current examples + shell: bash + run: | + examples=server/docs/api/examples + resources=AnkiDroid/src/test/resources/ankiquest-api + if [ -d "$examples" ]; then + find "$resources" -name '*.json' -delete + cp "$examples"/*.json "$resources"/ + git status --short -- "$resources" + else + echo "The server at this ref has no $examples; checking the committed snapshot" + fi + + - name: Configure JDK + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: "jetbrains" + java-version: "21" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + timeout-minutes: 10 + with: + cache-provider: basic + cache-read-only: ${{ github.ref != 'refs/heads/ankiquest' }} + + - name: Gradle Dependency Download + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 10 + retry_wait_seconds: 60 + max_attempts: 3 + command: ./gradlew robolectricSdkDownload --daemon + + - name: Run the contract test + run: ./gradlew :AnkiDroid:testFullDebugUnitTest --tests 'com.ichi2.anki.ankiquest.AnkiquestApiContractTest' --daemon + + - name: Report Unit Test Results + if: always() && hashFiles('**/build/test-results/**/TEST-*.xml') != '' + run: python3 .github/report_test_failures.py . --pattern "build/test-results/**/TEST-*.xml" --title "API contract failures" + + - name: Check the app's art against the server's exporter + if: ${{ !cancelled() }} + shell: bash + run: | + if [ -f server/tools/export_android_art.py ]; then + python3 -m venv "$RUNNER_TEMP/art" + "$RUNNER_TEMP/art/bin/pip" install --quiet pillow + "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . + else + echo "The server at this ref has no tools/export_android_art.py" + fi diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt new file mode 100644 index 000000000000..51bd8330b832 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.app.Notification +import android.app.NotificationManager +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager +import com.sun.net.httpserver.HttpServer +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Feeds the server's recorded answers in `src/test/resources/ankiquest-api`, which CI replaces with + * the server's current ones, to the code the app reads them with, so a server change that would + * break the app fails here before it is deployed. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [32]) +class AnkiquestApiContractTest : RobolectricTest() { + private lateinit var server: HttpServer + private lateinit var url: String + private val requests = CopyOnWriteArrayList() + private val manager get() = targetContext.getSystemService()!! + + @Before + fun serveExamples() { + mockkObject(AnkiquestWidget, AnkiquestShare) + every { AnkiquestWidget.requestUpdate(any()) } just Runs + every { AnkiquestShare.picture(any()) } returns null + AnkiDroidApp.sharedPreferencesTestingOverride = targetContext.sharedPrefs() + TimeManager.resetWith(MockTime(EXAMPLE_NOW)) + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val path = exchange.requestURI.path + requests += "${exchange.requestMethod} $path" + val body = route(exchange.requestMethod, path)?.let(::example)?.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.set("Content-Type", "application/json") + if (body == null) { + exchange.sendResponseHeaders(404, -1) + } else { + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + exchange.close() + } + server.start() + url = "http://127.0.0.1:${server.address.port}" + AnkiDroidApp.sharedPrefs().edit { + putString(Ankiquest.PREF_URL, url) + putString(Ankiquest.PREF_USER, "hill") + putString(Ankiquest.PREF_TOKEN, "hill-token") + } + } + + @After + fun stopServer() { + try { + if (::server.isInitialized) server.stop(0) + } finally { + unmockkObject(AnkiquestWidget, AnkiquestShare) + AnkiDroidApp.sharedPreferencesTestingOverride = null + } + } + + private val account: HomeAccount get() = assertNotNull(AnkiquestHomeData.account()) + + @Test + fun `every example is either read by the app or known to be unused`() { + val directory = File(assertNotNull(javaClass.classLoader?.getResource(EXAMPLES)).toURI()) + val examples = assertNotNull(directory.list()).filter { it.endsWith(".json") }.map { it.removeSuffix(".json") }.toSet() + assertEquals(CONSUMED + UNUSED, examples, "a new or removed example needs a check here") + assertTrue((CONSUMED intersect UNUSED).isEmpty()) + } + + @Test + fun `signing up and signing in keep the returned user and token`() = + runBlocking { + val created = AnkiquestAccount.submit(AnkiquestAccount.Credentials(url, "ana", "password", "Ana", create = true), "Pixel 9") + assertEquals(AnkiquestAccount.Signed(url, "ana", "example-token-1"), created) + val signed = AnkiquestAccount.submit(AnkiquestAccount.Credentials(url, "ana", "password", "", create = false), "Pixel 9") + assertEquals(AnkiquestAccount.Signed(url, "ana", "example-token-2"), signed) + assertTrue(requests.containsAll(listOf("POST /api/accounts", "POST /api/accounts/tokens"))) + } + + @Test + fun `the profile gives the goal, the streak and the connection summary`() = + runBlocking { + val profile = Ankiquest.profile() + AnkiquestNotifier.onProfile(targetContext, profile) + assertEquals( + AnkiquestDailyGoal.ServerGoal(AnkiquestDailyGoal.Choice(AnkiquestDailyGoal.Mode.BACKLOG, 14), since = 20754), + AnkiquestDailyGoal.cached(AnkiDroidApp.sharedPrefs(), account.scope), + ) + assertEquals(AnkiquestAki.Mood.STREAK, AnkiquestAki.mood(null, profile)) + assertTrue(assertNotNull(profile.optJSONArray("quests")).objects().all { it.optString("title").isNotEmpty() }) + + AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.PREF_TOKEN, "") } + assertEquals( + targetContext.getString(R.string.ankiquest_check_ok, "Hill", 2, 101L, 5L) + "\n\n" + + targetContext.getString(R.string.ankiquest_check_no_token), + Ankiquest.runFromSettings(targetContext, uploadAll = false), + ) + } + + @Test + fun `an upload answers the profile after reading which decks are shared`() = + runBlocking { + assertEquals( + targetContext.getString(R.string.ankiquest_check_ok, "Hill", 2, 101L, 5L), + Ankiquest.runFromSettings(targetContext, uploadAll = true), + ) + assertTrue("GET /api/decks/hill" in requests) + assertTrue("POST /api/reviews/hill" in requests) + assertNull(Ankiquest.feedback(JSONObject(example("upload")).optJSONObject("feedback"), showFeedback = true)) + } + + @Test + fun `deck settings list the friends to share with and the switches`() = + runBlocking { + val settings = Ankiquest.deckNotificationSettings() + val people = settings.getJSONArray("recipients").objects() + assertEquals(listOf("cerro", "friend"), people.map { it.getString("user") }) + assertTrue(people.all { it.getString("display").isNotEmpty() }) + settings.getJSONArray("decks").objects().forEach { deck -> + deck.getString("id") + deck.getBoolean("enabled") + deck.getJSONArray("recipients") + } + val switches = Ankiquest.notificationSwitches() + assertTrue(Ankiquest.celebrations(switches)) + assertFalse(switches.getBoolean(Ankiquest.NUDGES)) + } + + @Test + fun `the leaderboard orders the widget rows`() = + runBlocking { + val board = Ankiquest.leaderboard() + val week = AnkiquestWidget.forPeriod(board, "week") + assertEquals(listOf("hill", "cerro"), week.objects().map { it.getString("user") }) + val hour = AnkiquestWidget.forPeriod(board, "hour").objects() + assertEquals(listOf(51L, 30L), hour.map { it.getLong("xp") }) + assertEquals(2, AnkiquestWidget.collection(targetContext, week).itemCount) + } + + @Test + fun `a rank change is announced`() = + runBlocking { + AnkiquestNotifier.onLeaderboard(targetContext) + val announced = shadowOf(manager).allNotifications.single() + assertEquals("👑 You took the crown", announced.title()) + assertTrue("You passed Cerro." in announced.text()) + } + + @Test + fun `the inbox shows a celebration, a friend's win with its card and a cheer`() = + runBlocking { + val inbox = assertNotNull(Ankiquest.completionNotifications()) + AnkiquestNotifier.onDeckCompletions(targetContext, inbox.account, inbox.notifications, inbox.scope) + val celebration = shadowOf(manager).getNotification(5_140_001) + assertEquals("Daily goal reached!", celebration.title()) + assertEquals(listOf("Share"), celebration.actions.map { it.title.toString() }) + val share = shadowOf(manager).getNotification(5_140_006) + assertEquals("🎉 Cerro shared a win", share.title()) + assertEquals(listOf("Reply"), share.actions.map { it.title.toString() }, "the example was already cheered") + verify { AnkiquestShare.picture("https://anki.example.test/w/example-link-2.png") } + val reaction = shadowOf(manager).getNotification(5_140_008) + assertEquals("🎉 Cerro cheered", reaction.title()) + assertEquals(8L, AnkiDroidApp.sharedPrefs().getLong("ankiquestCompletionCursor:${inbox.account}", 0)) + } + + @Test + fun `sharing a win, cheering one and the stored settings are read`() = + runBlocking { + assertEquals(2, Ankiquest.shareWinWithFriends(1, account.scope)) + assertFalse(Ankiquest.react(6, account.notificationAccount, account.scope)) + val sharing = AnkiquestSharing.settings(account) + assertEquals(3, sharing.dailyCap) + assertEquals("ask", sharing.policies["daily_goal"]) + assertTrue(sharing.policies.values.all { it in setOf("private", "ask", "auto") }) + assertEquals( + AnkiquestDailyGoal.ServerGoal(AnkiquestDailyGoal.Choice(AnkiquestDailyGoal.Mode.BACKLOG, 14), since = 20754, chosen = true), + AnkiquestDailyGoal.ServerGoal.parse(AnkiquestSharing.goal(account)), + ) + assertEquals("aki", AnkiquestHomeData.companion(account)) + assertFalse(Ankiquest.streakProtectionEnabled()) + } + + private fun Notification.title() = extras.getCharSequence(Notification.EXTRA_TITLE).toString() + + private fun Notification.text() = extras.getCharSequence(Notification.EXTRA_TEXT).toString() + + private fun route( + method: String, + path: String, + ): String? { + val parts = path.removePrefix("/api/").split('/') + return when { + path == "/api/accounts" && method == "POST" -> "sign_up" + path == "/api/accounts/tokens" && method == "POST" -> "sign_in" + path == "/api/leaderboard" -> "leaderboard" + parts.size == 2 && path.startsWith("/api/") -> ROUTES[parts[0]] + else -> null + } + } + + private fun example(name: String): String = + assertNotNull(javaClass.classLoader?.getResource("$EXAMPLES/$name.json"), "$name.json").readText(Charsets.UTF_8) + + private companion object { + const val EXAMPLES = "ankiquest-api" + const val EXAMPLE_NOW = 1_793_183_460_000L + + val ROUTES = + mapOf( + "profile" to "profile", + "reviews" to "upload", + "rank" to "rank", + "notifications" to "notifications", + "share" to "share_to_friends", + "react" to "react", + "goal" to "goal", + "sharing" to "sharing", + "decks" to "decks", + "companion" to "companion", + "streak-freezes" to "streak_freezes", + ) + + val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard") + + val UNUSED = setOf("capabilities", "devices", "wins") + } +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json b/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json new file mode 100644 index 000000000000..0c83b2b23997 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json @@ -0,0 +1,12 @@ +{ + "features": [ + "activity", + "cookie_session", + "devices", + "goal_backlog_days", + "reactions", + "share_cards", + "wins" + ], + "version": 1 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/companion.json b/AnkiDroid/src/test/resources/ankiquest-api/companion.json new file mode 100644 index 000000000000..de791f3b2431 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/companion.json @@ -0,0 +1,3 @@ +{ + "companion": "aki" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/decks.json b/AnkiDroid/src/test/resources/ankiquest-api/decks.json new file mode 100644 index 000000000000..2f9fd6aaabf2 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/decks.json @@ -0,0 +1,28 @@ +{ + "celebrations": true, + "decks": [ + { + "enabled": false, + "id": "1", + "name": "Spanish", + "recipients": [] + }, + { + "enabled": false, + "id": "2", + "name": "Spanish::Verbs", + "recipients": [] + } + ], + "nudges": false, + "recipients": [ + { + "display": "Cerro", + "user": "cerro" + }, + { + "display": "Friend", + "user": "friend" + } + ] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/devices.json b/AnkiDroid/src/test/resources/ankiquest-api/devices.json new file mode 100644 index 000000000000..ed02c577f74b --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/devices.json @@ -0,0 +1,18 @@ +{ + "devices": [ + { + "created_at": 1793183400000, + "current": true, + "id": "example-device-1", + "kind": "app", + "name": "Pixel 9" + }, + { + "created_at": 1793183400000, + "current": false, + "id": "example-device-2", + "kind": "app", + "name": "Pixel 8" + } + ] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/goal.json b/AnkiDroid/src/test/resources/ankiquest-api/goal.json new file mode 100644 index 000000000000..6541458b2e91 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/goal.json @@ -0,0 +1,6 @@ +{ + "chosen": true, + "mode": "backlog", + "since": 20754, + "value": 14 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json b/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json new file mode 100644 index 000000000000..05930e95eeb0 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json @@ -0,0 +1,48 @@ +[ + { + "day_ends_at": 1793246400000, + "display": "Hill", + "level": 2, + "period": "week", + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "streak": 1, + "streak_minutes": 0, + "streak_state": "studied", + "today_minutes": 0, + "today_reviews": 5, + "user": "hill", + "week_xp": 101, + "xp": 101, + "xp_total": 101 + }, + { + "day_ends_at": 1793246400000, + "display": "Cerro", + "level": 1, + "period": "week", + "periods": { + "all": 31, + "day": 31, + "hour": 30, + "month": 31, + "week": 31, + "year": 31 + }, + "streak": 1, + "streak_minutes": 0, + "streak_state": "studied", + "today_minutes": 0, + "today_reviews": 3, + "user": "cerro", + "week_xp": 31, + "xp": 31, + "xp_total": 31 + } +] diff --git a/AnkiDroid/src/test/resources/ankiquest-api/notifications.json b/AnkiDroid/src/test/resources/ankiquest-api/notifications.json new file mode 100644 index 000000000000..530a8977d39c --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/notifications.json @@ -0,0 +1,51 @@ +[ + { + "action_required": false, + "body": "5 cards today in 0 minutes.", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 1, + "kind": "celebration", + "read_at": null, + "replied": false, + "route": null, + "sender": "", + "title": "Daily goal reached!", + "topic": "goal", + "win": 1 + }, + { + "action_required": false, + "body": "7 day streak!: “A week in a row.”", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 6, + "image": "https://anki.example.test/w/example-link-2.png", + "kind": "share", + "reacted": true, + "read_at": null, + "replied": false, + "route": null, + "sender": "cerro", + "title": "🎉 Cerro shared a win", + "topic": "streak", + "win": 2 + }, + { + "action_required": false, + "body": "“Daily goal reached!”", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 8, + "kind": "reaction", + "read_at": null, + "replied": false, + "route": null, + "sender": "cerro", + "title": "🎉 Cerro cheered", + "win": 1 + } +] diff --git a/AnkiDroid/src/test/resources/ankiquest-api/profile.json b/AnkiDroid/src/test/resources/ankiquest-api/profile.json new file mode 100644 index 000000000000..66ed94b0df5b --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/profile.json @@ -0,0 +1,2271 @@ +{ + "achievements": [ + { + "description": "Review 100 cards in total", + "id": "reviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "First Hundred", + "unlocked": null + }, + { + "description": "Review 1000 cards in total", + "id": "reviews-1000", + "progress": 5, + "reward": 200, + "target": 1000, + "title": "Thousand Flips", + "unlocked": null + }, + { + "description": "Review 5000 cards in total", + "id": "reviews-5000", + "progress": 5, + "reward": 300, + "target": 5000, + "title": "Card Shark", + "unlocked": null + }, + { + "description": "Review 10000 cards in total", + "id": "reviews-10000", + "progress": 5, + "reward": 400, + "target": 10000, + "title": "Ten Thousand Hours, Sort Of", + "unlocked": null + }, + { + "description": "Review 25000 cards in total", + "id": "reviews-25000", + "progress": 5, + "reward": 500, + "target": 25000, + "title": "Memory Palace", + "unlocked": null + }, + { + "description": "Review 50000 cards in total", + "id": "reviews-50000", + "progress": 5, + "reward": 600, + "target": 50000, + "title": "Living Library", + "unlocked": null + }, + { + "description": "Review 100000 cards in total", + "id": "reviews-100000", + "progress": 5, + "reward": 700, + "target": 100000, + "title": "Mnemosyne", + "unlocked": null + }, + { + "description": "Review 250000 cards in total", + "id": "reviews-250000", + "progress": 5, + "reward": 800, + "target": 250000, + "title": "Quarter Million", + "unlocked": null + }, + { + "description": "Review 500000 cards in total", + "id": "reviews-500000", + "progress": 5, + "reward": 900, + "target": 500000, + "title": "Half a Million", + "unlocked": null + }, + { + "description": "Review 1000000 cards in total", + "id": "reviews-1000000", + "progress": 5, + "reward": 1000, + "target": 1000000, + "title": "The Millionaire", + "unlocked": null + }, + { + "description": "Reach a 3 day streak", + "id": "streak-3", + "progress": 1, + "reward": 100, + "target": 3, + "title": "Warming Up", + "unlocked": null + }, + { + "description": "Reach a 7 day streak", + "id": "streak-7", + "progress": 1, + "reward": 200, + "target": 7, + "title": "Full Week", + "unlocked": null + }, + { + "description": "Reach a 14 day streak", + "id": "streak-14", + "progress": 1, + "reward": 300, + "target": 14, + "title": "Fortnight", + "unlocked": null + }, + { + "description": "Reach a 30 day streak", + "id": "streak-30", + "progress": 1, + "reward": 400, + "target": 30, + "title": "Month of Mondays", + "unlocked": null + }, + { + "description": "Reach a 60 day streak", + "id": "streak-60", + "progress": 1, + "reward": 500, + "target": 60, + "title": "Habit Formed", + "unlocked": null + }, + { + "description": "Reach a 100 day streak", + "id": "streak-100", + "progress": 1, + "reward": 600, + "target": 100, + "title": "Centurion", + "unlocked": null + }, + { + "description": "Reach a 200 day streak", + "id": "streak-200", + "progress": 1, + "reward": 700, + "target": 200, + "title": "Unbreakable", + "unlocked": null + }, + { + "description": "Reach a 365 day streak", + "id": "streak-365", + "progress": 1, + "reward": 800, + "target": 365, + "title": "Orbit Complete", + "unlocked": null + }, + { + "description": "Reach a 500 day streak", + "id": "streak-500", + "progress": 1, + "reward": 900, + "target": 500, + "title": "Five Hundred Sunrises", + "unlocked": null + }, + { + "description": "Reach a 730 day streak", + "id": "streak-730", + "progress": 1, + "reward": 1000, + "target": 730, + "title": "Two Orbits", + "unlocked": null + }, + { + "description": "Reach a 1000 day streak", + "id": "streak-1000", + "progress": 1, + "reward": 1100, + "target": 1000, + "title": "Thousand Days", + "unlocked": null + }, + { + "description": "Reach a 1500 day streak", + "id": "streak-1500", + "progress": 1, + "reward": 1200, + "target": 1500, + "title": "Unstoppable", + "unlocked": null + }, + { + "description": "Reach a 2000 day streak", + "id": "streak-2000", + "progress": 1, + "reward": 1300, + "target": 2000, + "title": "Perennial", + "unlocked": null + }, + { + "description": "Review 100 cards in one day", + "id": "dayreviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "Busy Day", + "unlocked": null + }, + { + "description": "Review 250 cards in one day", + "id": "dayreviews-250", + "progress": 5, + "reward": 200, + "target": 250, + "title": "Marathon", + "unlocked": null + }, + { + "description": "Review 500 cards in one day", + "id": "dayreviews-500", + "progress": 5, + "reward": 300, + "target": 500, + "title": "Ultramarathon", + "unlocked": null + }, + { + "description": "Review 750 cards in one day", + "id": "dayreviews-750", + "progress": 5, + "reward": 400, + "target": 750, + "title": "Iron Lung", + "unlocked": null + }, + { + "description": "Review 1000 cards in one day", + "id": "dayreviews-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Thousand in a Day", + "unlocked": null + }, + { + "description": "Review 1500 cards in one day", + "id": "dayreviews-1500", + "progress": 5, + "reward": 600, + "target": 1500, + "title": "Grinder", + "unlocked": null + }, + { + "description": "Review 2000 cards in one day", + "id": "dayreviews-2000", + "progress": 5, + "reward": 700, + "target": 2000, + "title": "Madness", + "unlocked": null + }, + { + "description": "Reach a 50 card combo", + "id": "combo-50", + "progress": 5, + "reward": 100, + "target": 50, + "title": "In the Zone", + "unlocked": null + }, + { + "description": "Reach a 100 card combo", + "id": "combo-100", + "progress": 5, + "reward": 200, + "target": 100, + "title": "Flow State", + "unlocked": null + }, + { + "description": "Reach a 250 card combo", + "id": "combo-250", + "progress": 5, + "reward": 300, + "target": 250, + "title": "Trance", + "unlocked": null + }, + { + "description": "Reach a 500 card combo", + "id": "combo-500", + "progress": 5, + "reward": 400, + "target": 500, + "title": "Zen", + "unlocked": null + }, + { + "description": "Reach a 1000 card combo", + "id": "combo-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Nirvana", + "unlocked": null + }, + { + "description": "Study for 10 hours in total", + "id": "hours-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Ten Hours In", + "unlocked": null + }, + { + "description": "Study for 50 hours in total", + "id": "hours-50", + "progress": 0, + "reward": 200, + "target": 50, + "title": "Fifty Hours In", + "unlocked": null + }, + { + "description": "Study for 100 hours in total", + "id": "hours-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Hundred Hours In", + "unlocked": null + }, + { + "description": "Study for 500 hours in total", + "id": "hours-500", + "progress": 0, + "reward": 400, + "target": 500, + "title": "Scholar", + "unlocked": null + }, + { + "description": "Study for 1000 hours in total", + "id": "hours-1000", + "progress": 0, + "reward": 500, + "target": 1000, + "title": "Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 2000 hours in total", + "id": "hours-2000", + "progress": 0, + "reward": 600, + "target": 2000, + "title": "Two Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 5000 hours in total", + "id": "hours-5000", + "progress": 0, + "reward": 700, + "target": 5000, + "title": "Lifer", + "unlocked": null + }, + { + "description": "Review 100 mature cards", + "id": "mature-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Long Term", + "unlocked": null + }, + { + "description": "Review 1000 mature cards", + "id": "mature-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Deep Roots", + "unlocked": null + }, + { + "description": "Review 10000 mature cards", + "id": "mature-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Old Growth", + "unlocked": null + }, + { + "description": "Review 25000 mature cards", + "id": "mature-25000", + "progress": 0, + "reward": 400, + "target": 25000, + "title": "Ancient Forest", + "unlocked": null + }, + { + "description": "Review 50000 mature cards", + "id": "mature-50000", + "progress": 0, + "reward": 500, + "target": 50000, + "title": "Redwood", + "unlocked": null + }, + { + "description": "Review 100000 mature cards", + "id": "mature-100000", + "progress": 0, + "reward": 600, + "target": 100000, + "title": "Bedrock", + "unlocked": null + }, + { + "description": "Learn 100 new cards", + "id": "newcards-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Collector", + "unlocked": null + }, + { + "description": "Learn 1000 new cards", + "id": "newcards-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Curator", + "unlocked": null + }, + { + "description": "Learn 5000 new cards", + "id": "newcards-5000", + "progress": 0, + "reward": 300, + "target": 5000, + "title": "Archivist", + "unlocked": null + }, + { + "description": "Learn 10000 new cards", + "id": "newcards-10000", + "progress": 0, + "reward": 400, + "target": 10000, + "title": "Encyclopedist", + "unlocked": null + }, + { + "description": "Learn 25000 new cards", + "id": "newcards-25000", + "progress": 0, + "reward": 500, + "target": 25000, + "title": "Lexicographer", + "unlocked": null + }, + { + "description": "Learn 50000 new cards", + "id": "newcards-50000", + "progress": 0, + "reward": 600, + "target": 50000, + "title": "Polymath", + "unlocked": null + }, + { + "description": "Complete 10 quests", + "id": "quests-10", + "progress": 1, + "reward": 100, + "target": 10, + "title": "Adventurer", + "unlocked": null + }, + { + "description": "Complete 50 quests", + "id": "quests-50", + "progress": 1, + "reward": 200, + "target": 50, + "title": "Quest Hound", + "unlocked": null + }, + { + "description": "Complete 200 quests", + "id": "quests-200", + "progress": 1, + "reward": 300, + "target": 200, + "title": "Completionist", + "unlocked": null + }, + { + "description": "Complete 500 quests", + "id": "quests-500", + "progress": 1, + "reward": 400, + "target": 500, + "title": "Quest Master", + "unlocked": null + }, + { + "description": "Complete 1000 quests", + "id": "quests-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Legend", + "unlocked": null + }, + { + "description": "Complete 2500 quests", + "id": "quests-2500", + "progress": 1, + "reward": 600, + "target": 2500, + "title": "Mythic", + "unlocked": null + }, + { + "description": "Complete every quest on 7 days", + "id": "perfectdays-7", + "progress": 0, + "reward": 100, + "target": 7, + "title": "Perfect Week", + "unlocked": null + }, + { + "description": "Complete every quest on 30 days", + "id": "perfectdays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Perfect Month", + "unlocked": null + }, + { + "description": "Complete every quest on 100 days", + "id": "perfectdays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Perfectionist", + "unlocked": null + }, + { + "description": "Complete every quest on 365 days", + "id": "perfectdays-365", + "progress": 0, + "reward": 400, + "target": 365, + "title": "Flawless Year", + "unlocked": null + }, + { + "description": "Study before 7am on 5 days", + "id": "earlydays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Early Bird", + "unlocked": null + }, + { + "description": "Study before 7am on 30 days", + "id": "earlydays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Dawn Patrol", + "unlocked": null + }, + { + "description": "Study before 7am on 100 days", + "id": "earlydays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Sunrise Scholar", + "unlocked": null + }, + { + "description": "Study after 11pm on 5 days", + "id": "latedays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Night Owl", + "unlocked": null + }, + { + "description": "Study after 11pm on 30 days", + "id": "latedays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Nocturnal", + "unlocked": null + }, + { + "description": "Study after 11pm on 100 days", + "id": "latedays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Creature of the Night", + "unlocked": null + }, + { + "description": "Study on 30 different days", + "id": "daysactive-30", + "progress": 1, + "reward": 100, + "target": 30, + "title": "Regular", + "unlocked": null + }, + { + "description": "Study on 100 different days", + "id": "daysactive-100", + "progress": 1, + "reward": 200, + "target": 100, + "title": "Hundred Days", + "unlocked": null + }, + { + "description": "Study on 365 different days", + "id": "daysactive-365", + "progress": 1, + "reward": 300, + "target": 365, + "title": "A Year of Days", + "unlocked": null + }, + { + "description": "Study on 730 different days", + "id": "daysactive-730", + "progress": 1, + "reward": 400, + "target": 730, + "title": "Two Years of Days", + "unlocked": null + }, + { + "description": "Study on 1000 different days", + "id": "daysactive-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Thousand Days Studied", + "unlocked": null + }, + { + "description": "Study on 2000 different days", + "id": "daysactive-2000", + "progress": 1, + "reward": 600, + "target": 2000, + "title": "Two Thousand Days Studied", + "unlocked": null + }, + { + "description": "Relearn 100 forgotten cards", + "id": "relearns-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Second Chance", + "unlocked": null + }, + { + "description": "Relearn 1000 forgotten cards", + "id": "relearns-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Persistence", + "unlocked": null + }, + { + "description": "Relearn 10000 forgotten cards", + "id": "relearns-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Never Give Up", + "unlocked": null + }, + { + "description": "Relearn 50000 forgotten cards", + "id": "relearns-50000", + "progress": 0, + "reward": 400, + "target": 50000, + "title": "Sisyphus", + "unlocked": null + }, + { + "description": "Recall a card last seen 180 days before", + "id": "maxinterval-180", + "progress": 1, + "reward": 100, + "target": 180, + "title": "Half-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 365 days before", + "id": "maxinterval-365", + "progress": 1, + "reward": 200, + "target": 365, + "title": "Year-Old Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 1825 days before", + "id": "maxinterval-1825", + "progress": 1, + "reward": 300, + "target": 1825, + "title": "Five-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 3650 days before", + "id": "maxinterval-3650", + "progress": 1, + "reward": 400, + "target": 3650, + "title": "Decade Memory", + "unlocked": null + }, + { + "description": "Review 1000 different cards", + "id": "distinctcards-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Wide Net", + "unlocked": null + }, + { + "description": "Review 5000 different cards", + "id": "distinctcards-5000", + "progress": 5, + "reward": 200, + "target": 5000, + "title": "Big Deck", + "unlocked": null + }, + { + "description": "Review 20000 different cards", + "id": "distinctcards-20000", + "progress": 5, + "reward": 300, + "target": 20000, + "title": "Vast Library", + "unlocked": null + }, + { + "description": "Review 50000 different cards", + "id": "distinctcards-50000", + "progress": 5, + "reward": 400, + "target": 50000, + "title": "Everything Everywhere", + "unlocked": null + }, + { + "description": "Study 60 minutes in one sitting", + "id": "longestsession-60", + "progress": 0, + "reward": 100, + "target": 60, + "title": "Deep Work", + "unlocked": null + }, + { + "description": "Study 120 minutes in one sitting", + "id": "longestsession-120", + "progress": 0, + "reward": 200, + "target": 120, + "title": "Two-Hour Sitting", + "unlocked": null + }, + { + "description": "Study 240 minutes in one sitting", + "id": "longestsession-240", + "progress": 0, + "reward": 300, + "target": 240, + "title": "Iron Chair", + "unlocked": null + }, + { + "description": "Study in 5 separate sessions in one day", + "id": "maxsessions-5", + "progress": 1, + "reward": 100, + "target": 5, + "title": "Snacker", + "unlocked": null + }, + { + "description": "Study in 10 separate sessions in one day", + "id": "maxsessions-10", + "progress": 1, + "reward": 200, + "target": 10, + "title": "Grazer", + "unlocked": null + }, + { + "description": "Study in 20 separate sessions in one day", + "id": "maxsessions-20", + "progress": 1, + "reward": 300, + "target": 20, + "title": "Can't Stop", + "unlocked": null + }, + { + "description": "Review 1000 cards in one calendar month", + "id": "bestmonth-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Busy Month", + "unlocked": null + }, + { + "description": "Review 3000 cards in one calendar month", + "id": "bestmonth-3000", + "progress": 5, + "reward": 200, + "target": 3000, + "title": "Big Month", + "unlocked": null + }, + { + "description": "Review 10000 cards in one calendar month", + "id": "bestmonth-10000", + "progress": 5, + "reward": 300, + "target": 10000, + "title": "Monster Month", + "unlocked": null + }, + { + "description": "Review 20000 cards in one calendar month", + "id": "bestmonth-20000", + "progress": 5, + "reward": 400, + "target": 20000, + "title": "Month of Madness", + "unlocked": null + }, + { + "description": "Study on 10 Saturdays or Sundays", + "id": "weekenddays-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Weekend Warrior", + "unlocked": null + }, + { + "description": "Study on 52 Saturdays or Sundays", + "id": "weekenddays-52", + "progress": 0, + "reward": 200, + "target": 52, + "title": "Weekend Regular", + "unlocked": null + }, + { + "description": "Study on 200 Saturdays or Sundays", + "id": "weekenddays-200", + "progress": 0, + "reward": 300, + "target": 200, + "title": "No Days Off", + "unlocked": null + }, + { + "description": "Come back after a month away", + "id": "comebacks-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Comeback", + "unlocked": null + }, + { + "description": "Come back after a month away 3 times", + "id": "comebacks-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Phoenix", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 1 times", + "id": "freezesused-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Saved by the Ice", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 10 times", + "id": "freezesused-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Glacier", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 50 times", + "id": "freezesused-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Ice Age", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 1 times", + "id": "fastdays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Speed Demon", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 10 times", + "id": "fastdays-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Lightning", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 50 times", + "id": "fastdays-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Quicksilver", + "unlocked": null + }, + { + "description": "Study on New Year's Day 1 times", + "id": "newyeardays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "New Year, Same Me", + "unlocked": null + }, + { + "description": "Study on New Year's Day 3 times", + "id": "newyeardays-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Tradition", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 1 times", + "id": "anniversaries-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Anniversary", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 5 times", + "id": "anniversaries-5", + "progress": 0, + "reward": 200, + "target": 5, + "title": "Old Friends", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 10 times", + "id": "anniversaries-10", + "progress": 0, + "reward": 300, + "target": 10, + "title": "Lifelong", + "unlocked": null + }, + { + "description": "Keep going for 1 years since your first review", + "id": "years-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "One Year In", + "unlocked": null + }, + { + "description": "Keep going for 3 years since your first review", + "id": "years-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Three Years In", + "unlocked": null + }, + { + "description": "Keep going for 5 years since your first review", + "id": "years-5", + "progress": 0, + "reward": 300, + "target": 5, + "title": "Veteran", + "unlocked": null + }, + { + "description": "Keep going for 10 years since your first review", + "id": "years-10", + "progress": 0, + "reward": 400, + "target": 10, + "title": "Decade of Anki", + "unlocked": null + } + ], + "at_risk": false, + "conquests": [], + "day_ends_at": 1793246400000, + "display": "Hill", + "freeze_earned_today": false, + "freezes": 0, + "freezes_enabled": false, + "goal": { + "day": 20754, + "mode": "backlog", + "progress": null, + "reached": false, + "since": 20754, + "value": 14 + }, + "heatmap": [ + { + "date": "2026-04-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-28", + "frozen": false, + "reviews": 5, + "xp": 101 + } + ], + "last_review_id": 1793182204000, + "level": 2, + "lifetime": { + "best_combo": 5, + "best_day": 5, + "best_streak": 1, + "best_streak_at": 1793160000000, + "conquered": 0, + "days_active": 1, + "first_day_at": 1793160000000, + "hours": 0, + "quests": 1, + "reviews": 5 + }, + "local_hour": 10, + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "quests": [ + { + "done": false, + "progress": 5, + "reward": 50, + "target": 15, + "title": "Review 15 cards" + }, + { + "done": true, + "progress": 5, + "reward": 50, + "target": 5, + "title": "Review 5 cards before noon" + }, + { + "done": false, + "progress": 1, + "reward": 50, + "target": 2, + "title": "Study in 2 separate sessions" + } + ], + "records": { + "day": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "hour": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 51 + }, + "month": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "week": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "year": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + } + }, + "stored_freezes": 0, + "streak": 1, + "streak_state": "studied", + "streak_warning": null, + "today": { + "current_combo": 5, + "max_combo": 5, + "minutes": 0, + "new_cards": 0, + "reviews": 5, + "streak_minutes": 0, + "xp": 101 + }, + "user": "hill", + "week_xp": 101, + "xp_for_next": 280, + "xp_into_level": 1, + "xp_total": 101 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/rank.json b/AnkiDroid/src/test/resources/ankiquest-api/rank.json new file mode 100644 index 000000000000..8265898807a6 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/rank.json @@ -0,0 +1,10 @@ +{ + "change": { + "body": "You passed Cerro.", + "title": "👑 You took the crown" + }, + "order": [ + "hill", + "cerro" + ] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/react.json b/AnkiDroid/src/test/resources/ankiquest-api/react.json new file mode 100644 index 000000000000..6059448251d4 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/react.json @@ -0,0 +1,3 @@ +{ + "already": false +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json b/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json new file mode 100644 index 000000000000..7bc68059e2a3 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json @@ -0,0 +1,7 @@ +{ + "link": { + "image": "https://anki.example.test/w/example-link-1.png", + "url": "https://anki.example.test/w/example-link-1" + }, + "recipients": 2 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sharing.json b/AnkiDroid/src/test/resources/ankiquest-api/sharing.json new file mode 100644 index 000000000000..653f810ec71c --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sharing.json @@ -0,0 +1,15 @@ +{ + "daily_cap": 3, + "policies": { + "achievement": "ask", + "challenge": "ask", + "conquered": "ask", + "daily_goal": "ask", + "level": "ask", + "place": "ask", + "quest": "private", + "record": "ask", + "streak": "ask", + "streak_secured": "private" + } +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json b/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json new file mode 100644 index 000000000000..691c0d69a3fc --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json @@ -0,0 +1,5 @@ +{ + "display": "Ana", + "token": "example-token-2", + "user": "ana" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json b/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json new file mode 100644 index 000000000000..a29376d7b477 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json @@ -0,0 +1,5 @@ +{ + "display": "Ana", + "token": "example-token-1", + "user": "ana" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json b/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json new file mode 100644 index 000000000000..3c92298e1ec3 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json @@ -0,0 +1,5 @@ +{ + "capacity": 3, + "enabled": false, + "freezes": 0 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/upload.json b/AnkiDroid/src/test/resources/ankiquest-api/upload.json new file mode 100644 index 000000000000..3fb32c880d57 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/upload.json @@ -0,0 +1,2276 @@ +{ + "achievements": [ + { + "description": "Review 100 cards in total", + "id": "reviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "First Hundred", + "unlocked": null + }, + { + "description": "Review 1000 cards in total", + "id": "reviews-1000", + "progress": 5, + "reward": 200, + "target": 1000, + "title": "Thousand Flips", + "unlocked": null + }, + { + "description": "Review 5000 cards in total", + "id": "reviews-5000", + "progress": 5, + "reward": 300, + "target": 5000, + "title": "Card Shark", + "unlocked": null + }, + { + "description": "Review 10000 cards in total", + "id": "reviews-10000", + "progress": 5, + "reward": 400, + "target": 10000, + "title": "Ten Thousand Hours, Sort Of", + "unlocked": null + }, + { + "description": "Review 25000 cards in total", + "id": "reviews-25000", + "progress": 5, + "reward": 500, + "target": 25000, + "title": "Memory Palace", + "unlocked": null + }, + { + "description": "Review 50000 cards in total", + "id": "reviews-50000", + "progress": 5, + "reward": 600, + "target": 50000, + "title": "Living Library", + "unlocked": null + }, + { + "description": "Review 100000 cards in total", + "id": "reviews-100000", + "progress": 5, + "reward": 700, + "target": 100000, + "title": "Mnemosyne", + "unlocked": null + }, + { + "description": "Review 250000 cards in total", + "id": "reviews-250000", + "progress": 5, + "reward": 800, + "target": 250000, + "title": "Quarter Million", + "unlocked": null + }, + { + "description": "Review 500000 cards in total", + "id": "reviews-500000", + "progress": 5, + "reward": 900, + "target": 500000, + "title": "Half a Million", + "unlocked": null + }, + { + "description": "Review 1000000 cards in total", + "id": "reviews-1000000", + "progress": 5, + "reward": 1000, + "target": 1000000, + "title": "The Millionaire", + "unlocked": null + }, + { + "description": "Reach a 3 day streak", + "id": "streak-3", + "progress": 1, + "reward": 100, + "target": 3, + "title": "Warming Up", + "unlocked": null + }, + { + "description": "Reach a 7 day streak", + "id": "streak-7", + "progress": 1, + "reward": 200, + "target": 7, + "title": "Full Week", + "unlocked": null + }, + { + "description": "Reach a 14 day streak", + "id": "streak-14", + "progress": 1, + "reward": 300, + "target": 14, + "title": "Fortnight", + "unlocked": null + }, + { + "description": "Reach a 30 day streak", + "id": "streak-30", + "progress": 1, + "reward": 400, + "target": 30, + "title": "Month of Mondays", + "unlocked": null + }, + { + "description": "Reach a 60 day streak", + "id": "streak-60", + "progress": 1, + "reward": 500, + "target": 60, + "title": "Habit Formed", + "unlocked": null + }, + { + "description": "Reach a 100 day streak", + "id": "streak-100", + "progress": 1, + "reward": 600, + "target": 100, + "title": "Centurion", + "unlocked": null + }, + { + "description": "Reach a 200 day streak", + "id": "streak-200", + "progress": 1, + "reward": 700, + "target": 200, + "title": "Unbreakable", + "unlocked": null + }, + { + "description": "Reach a 365 day streak", + "id": "streak-365", + "progress": 1, + "reward": 800, + "target": 365, + "title": "Orbit Complete", + "unlocked": null + }, + { + "description": "Reach a 500 day streak", + "id": "streak-500", + "progress": 1, + "reward": 900, + "target": 500, + "title": "Five Hundred Sunrises", + "unlocked": null + }, + { + "description": "Reach a 730 day streak", + "id": "streak-730", + "progress": 1, + "reward": 1000, + "target": 730, + "title": "Two Orbits", + "unlocked": null + }, + { + "description": "Reach a 1000 day streak", + "id": "streak-1000", + "progress": 1, + "reward": 1100, + "target": 1000, + "title": "Thousand Days", + "unlocked": null + }, + { + "description": "Reach a 1500 day streak", + "id": "streak-1500", + "progress": 1, + "reward": 1200, + "target": 1500, + "title": "Unstoppable", + "unlocked": null + }, + { + "description": "Reach a 2000 day streak", + "id": "streak-2000", + "progress": 1, + "reward": 1300, + "target": 2000, + "title": "Perennial", + "unlocked": null + }, + { + "description": "Review 100 cards in one day", + "id": "dayreviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "Busy Day", + "unlocked": null + }, + { + "description": "Review 250 cards in one day", + "id": "dayreviews-250", + "progress": 5, + "reward": 200, + "target": 250, + "title": "Marathon", + "unlocked": null + }, + { + "description": "Review 500 cards in one day", + "id": "dayreviews-500", + "progress": 5, + "reward": 300, + "target": 500, + "title": "Ultramarathon", + "unlocked": null + }, + { + "description": "Review 750 cards in one day", + "id": "dayreviews-750", + "progress": 5, + "reward": 400, + "target": 750, + "title": "Iron Lung", + "unlocked": null + }, + { + "description": "Review 1000 cards in one day", + "id": "dayreviews-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Thousand in a Day", + "unlocked": null + }, + { + "description": "Review 1500 cards in one day", + "id": "dayreviews-1500", + "progress": 5, + "reward": 600, + "target": 1500, + "title": "Grinder", + "unlocked": null + }, + { + "description": "Review 2000 cards in one day", + "id": "dayreviews-2000", + "progress": 5, + "reward": 700, + "target": 2000, + "title": "Madness", + "unlocked": null + }, + { + "description": "Reach a 50 card combo", + "id": "combo-50", + "progress": 5, + "reward": 100, + "target": 50, + "title": "In the Zone", + "unlocked": null + }, + { + "description": "Reach a 100 card combo", + "id": "combo-100", + "progress": 5, + "reward": 200, + "target": 100, + "title": "Flow State", + "unlocked": null + }, + { + "description": "Reach a 250 card combo", + "id": "combo-250", + "progress": 5, + "reward": 300, + "target": 250, + "title": "Trance", + "unlocked": null + }, + { + "description": "Reach a 500 card combo", + "id": "combo-500", + "progress": 5, + "reward": 400, + "target": 500, + "title": "Zen", + "unlocked": null + }, + { + "description": "Reach a 1000 card combo", + "id": "combo-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Nirvana", + "unlocked": null + }, + { + "description": "Study for 10 hours in total", + "id": "hours-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Ten Hours In", + "unlocked": null + }, + { + "description": "Study for 50 hours in total", + "id": "hours-50", + "progress": 0, + "reward": 200, + "target": 50, + "title": "Fifty Hours In", + "unlocked": null + }, + { + "description": "Study for 100 hours in total", + "id": "hours-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Hundred Hours In", + "unlocked": null + }, + { + "description": "Study for 500 hours in total", + "id": "hours-500", + "progress": 0, + "reward": 400, + "target": 500, + "title": "Scholar", + "unlocked": null + }, + { + "description": "Study for 1000 hours in total", + "id": "hours-1000", + "progress": 0, + "reward": 500, + "target": 1000, + "title": "Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 2000 hours in total", + "id": "hours-2000", + "progress": 0, + "reward": 600, + "target": 2000, + "title": "Two Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 5000 hours in total", + "id": "hours-5000", + "progress": 0, + "reward": 700, + "target": 5000, + "title": "Lifer", + "unlocked": null + }, + { + "description": "Review 100 mature cards", + "id": "mature-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Long Term", + "unlocked": null + }, + { + "description": "Review 1000 mature cards", + "id": "mature-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Deep Roots", + "unlocked": null + }, + { + "description": "Review 10000 mature cards", + "id": "mature-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Old Growth", + "unlocked": null + }, + { + "description": "Review 25000 mature cards", + "id": "mature-25000", + "progress": 0, + "reward": 400, + "target": 25000, + "title": "Ancient Forest", + "unlocked": null + }, + { + "description": "Review 50000 mature cards", + "id": "mature-50000", + "progress": 0, + "reward": 500, + "target": 50000, + "title": "Redwood", + "unlocked": null + }, + { + "description": "Review 100000 mature cards", + "id": "mature-100000", + "progress": 0, + "reward": 600, + "target": 100000, + "title": "Bedrock", + "unlocked": null + }, + { + "description": "Learn 100 new cards", + "id": "newcards-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Collector", + "unlocked": null + }, + { + "description": "Learn 1000 new cards", + "id": "newcards-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Curator", + "unlocked": null + }, + { + "description": "Learn 5000 new cards", + "id": "newcards-5000", + "progress": 0, + "reward": 300, + "target": 5000, + "title": "Archivist", + "unlocked": null + }, + { + "description": "Learn 10000 new cards", + "id": "newcards-10000", + "progress": 0, + "reward": 400, + "target": 10000, + "title": "Encyclopedist", + "unlocked": null + }, + { + "description": "Learn 25000 new cards", + "id": "newcards-25000", + "progress": 0, + "reward": 500, + "target": 25000, + "title": "Lexicographer", + "unlocked": null + }, + { + "description": "Learn 50000 new cards", + "id": "newcards-50000", + "progress": 0, + "reward": 600, + "target": 50000, + "title": "Polymath", + "unlocked": null + }, + { + "description": "Complete 10 quests", + "id": "quests-10", + "progress": 1, + "reward": 100, + "target": 10, + "title": "Adventurer", + "unlocked": null + }, + { + "description": "Complete 50 quests", + "id": "quests-50", + "progress": 1, + "reward": 200, + "target": 50, + "title": "Quest Hound", + "unlocked": null + }, + { + "description": "Complete 200 quests", + "id": "quests-200", + "progress": 1, + "reward": 300, + "target": 200, + "title": "Completionist", + "unlocked": null + }, + { + "description": "Complete 500 quests", + "id": "quests-500", + "progress": 1, + "reward": 400, + "target": 500, + "title": "Quest Master", + "unlocked": null + }, + { + "description": "Complete 1000 quests", + "id": "quests-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Legend", + "unlocked": null + }, + { + "description": "Complete 2500 quests", + "id": "quests-2500", + "progress": 1, + "reward": 600, + "target": 2500, + "title": "Mythic", + "unlocked": null + }, + { + "description": "Complete every quest on 7 days", + "id": "perfectdays-7", + "progress": 0, + "reward": 100, + "target": 7, + "title": "Perfect Week", + "unlocked": null + }, + { + "description": "Complete every quest on 30 days", + "id": "perfectdays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Perfect Month", + "unlocked": null + }, + { + "description": "Complete every quest on 100 days", + "id": "perfectdays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Perfectionist", + "unlocked": null + }, + { + "description": "Complete every quest on 365 days", + "id": "perfectdays-365", + "progress": 0, + "reward": 400, + "target": 365, + "title": "Flawless Year", + "unlocked": null + }, + { + "description": "Study before 7am on 5 days", + "id": "earlydays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Early Bird", + "unlocked": null + }, + { + "description": "Study before 7am on 30 days", + "id": "earlydays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Dawn Patrol", + "unlocked": null + }, + { + "description": "Study before 7am on 100 days", + "id": "earlydays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Sunrise Scholar", + "unlocked": null + }, + { + "description": "Study after 11pm on 5 days", + "id": "latedays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Night Owl", + "unlocked": null + }, + { + "description": "Study after 11pm on 30 days", + "id": "latedays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Nocturnal", + "unlocked": null + }, + { + "description": "Study after 11pm on 100 days", + "id": "latedays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Creature of the Night", + "unlocked": null + }, + { + "description": "Study on 30 different days", + "id": "daysactive-30", + "progress": 1, + "reward": 100, + "target": 30, + "title": "Regular", + "unlocked": null + }, + { + "description": "Study on 100 different days", + "id": "daysactive-100", + "progress": 1, + "reward": 200, + "target": 100, + "title": "Hundred Days", + "unlocked": null + }, + { + "description": "Study on 365 different days", + "id": "daysactive-365", + "progress": 1, + "reward": 300, + "target": 365, + "title": "A Year of Days", + "unlocked": null + }, + { + "description": "Study on 730 different days", + "id": "daysactive-730", + "progress": 1, + "reward": 400, + "target": 730, + "title": "Two Years of Days", + "unlocked": null + }, + { + "description": "Study on 1000 different days", + "id": "daysactive-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Thousand Days Studied", + "unlocked": null + }, + { + "description": "Study on 2000 different days", + "id": "daysactive-2000", + "progress": 1, + "reward": 600, + "target": 2000, + "title": "Two Thousand Days Studied", + "unlocked": null + }, + { + "description": "Relearn 100 forgotten cards", + "id": "relearns-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Second Chance", + "unlocked": null + }, + { + "description": "Relearn 1000 forgotten cards", + "id": "relearns-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Persistence", + "unlocked": null + }, + { + "description": "Relearn 10000 forgotten cards", + "id": "relearns-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Never Give Up", + "unlocked": null + }, + { + "description": "Relearn 50000 forgotten cards", + "id": "relearns-50000", + "progress": 0, + "reward": 400, + "target": 50000, + "title": "Sisyphus", + "unlocked": null + }, + { + "description": "Recall a card last seen 180 days before", + "id": "maxinterval-180", + "progress": 1, + "reward": 100, + "target": 180, + "title": "Half-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 365 days before", + "id": "maxinterval-365", + "progress": 1, + "reward": 200, + "target": 365, + "title": "Year-Old Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 1825 days before", + "id": "maxinterval-1825", + "progress": 1, + "reward": 300, + "target": 1825, + "title": "Five-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 3650 days before", + "id": "maxinterval-3650", + "progress": 1, + "reward": 400, + "target": 3650, + "title": "Decade Memory", + "unlocked": null + }, + { + "description": "Review 1000 different cards", + "id": "distinctcards-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Wide Net", + "unlocked": null + }, + { + "description": "Review 5000 different cards", + "id": "distinctcards-5000", + "progress": 5, + "reward": 200, + "target": 5000, + "title": "Big Deck", + "unlocked": null + }, + { + "description": "Review 20000 different cards", + "id": "distinctcards-20000", + "progress": 5, + "reward": 300, + "target": 20000, + "title": "Vast Library", + "unlocked": null + }, + { + "description": "Review 50000 different cards", + "id": "distinctcards-50000", + "progress": 5, + "reward": 400, + "target": 50000, + "title": "Everything Everywhere", + "unlocked": null + }, + { + "description": "Study 60 minutes in one sitting", + "id": "longestsession-60", + "progress": 0, + "reward": 100, + "target": 60, + "title": "Deep Work", + "unlocked": null + }, + { + "description": "Study 120 minutes in one sitting", + "id": "longestsession-120", + "progress": 0, + "reward": 200, + "target": 120, + "title": "Two-Hour Sitting", + "unlocked": null + }, + { + "description": "Study 240 minutes in one sitting", + "id": "longestsession-240", + "progress": 0, + "reward": 300, + "target": 240, + "title": "Iron Chair", + "unlocked": null + }, + { + "description": "Study in 5 separate sessions in one day", + "id": "maxsessions-5", + "progress": 1, + "reward": 100, + "target": 5, + "title": "Snacker", + "unlocked": null + }, + { + "description": "Study in 10 separate sessions in one day", + "id": "maxsessions-10", + "progress": 1, + "reward": 200, + "target": 10, + "title": "Grazer", + "unlocked": null + }, + { + "description": "Study in 20 separate sessions in one day", + "id": "maxsessions-20", + "progress": 1, + "reward": 300, + "target": 20, + "title": "Can't Stop", + "unlocked": null + }, + { + "description": "Review 1000 cards in one calendar month", + "id": "bestmonth-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Busy Month", + "unlocked": null + }, + { + "description": "Review 3000 cards in one calendar month", + "id": "bestmonth-3000", + "progress": 5, + "reward": 200, + "target": 3000, + "title": "Big Month", + "unlocked": null + }, + { + "description": "Review 10000 cards in one calendar month", + "id": "bestmonth-10000", + "progress": 5, + "reward": 300, + "target": 10000, + "title": "Monster Month", + "unlocked": null + }, + { + "description": "Review 20000 cards in one calendar month", + "id": "bestmonth-20000", + "progress": 5, + "reward": 400, + "target": 20000, + "title": "Month of Madness", + "unlocked": null + }, + { + "description": "Study on 10 Saturdays or Sundays", + "id": "weekenddays-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Weekend Warrior", + "unlocked": null + }, + { + "description": "Study on 52 Saturdays or Sundays", + "id": "weekenddays-52", + "progress": 0, + "reward": 200, + "target": 52, + "title": "Weekend Regular", + "unlocked": null + }, + { + "description": "Study on 200 Saturdays or Sundays", + "id": "weekenddays-200", + "progress": 0, + "reward": 300, + "target": 200, + "title": "No Days Off", + "unlocked": null + }, + { + "description": "Come back after a month away", + "id": "comebacks-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Comeback", + "unlocked": null + }, + { + "description": "Come back after a month away 3 times", + "id": "comebacks-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Phoenix", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 1 times", + "id": "freezesused-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Saved by the Ice", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 10 times", + "id": "freezesused-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Glacier", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 50 times", + "id": "freezesused-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Ice Age", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 1 times", + "id": "fastdays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Speed Demon", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 10 times", + "id": "fastdays-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Lightning", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 50 times", + "id": "fastdays-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Quicksilver", + "unlocked": null + }, + { + "description": "Study on New Year's Day 1 times", + "id": "newyeardays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "New Year, Same Me", + "unlocked": null + }, + { + "description": "Study on New Year's Day 3 times", + "id": "newyeardays-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Tradition", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 1 times", + "id": "anniversaries-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Anniversary", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 5 times", + "id": "anniversaries-5", + "progress": 0, + "reward": 200, + "target": 5, + "title": "Old Friends", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 10 times", + "id": "anniversaries-10", + "progress": 0, + "reward": 300, + "target": 10, + "title": "Lifelong", + "unlocked": null + }, + { + "description": "Keep going for 1 years since your first review", + "id": "years-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "One Year In", + "unlocked": null + }, + { + "description": "Keep going for 3 years since your first review", + "id": "years-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Three Years In", + "unlocked": null + }, + { + "description": "Keep going for 5 years since your first review", + "id": "years-5", + "progress": 0, + "reward": 300, + "target": 5, + "title": "Veteran", + "unlocked": null + }, + { + "description": "Keep going for 10 years since your first review", + "id": "years-10", + "progress": 0, + "reward": 400, + "target": 10, + "title": "Decade of Anki", + "unlocked": null + } + ], + "announced": [], + "at_risk": false, + "conquests": [], + "day_ends_at": 1793246400000, + "display": "Hill", + "feedback": { + "headlines": [], + "status": null + }, + "freeze_earned_today": false, + "freezes": 0, + "freezes_enabled": false, + "goal": { + "day": 20754, + "mode": "backlog", + "progress": null, + "reached": false, + "since": 20754, + "value": 30 + }, + "heatmap": [ + { + "date": "2026-04-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-28", + "frozen": false, + "reviews": 5, + "xp": 101 + } + ], + "last_review_id": 1793182204000, + "level": 2, + "lifetime": { + "best_combo": 5, + "best_day": 5, + "best_streak": 1, + "best_streak_at": 1793160000000, + "conquered": 0, + "days_active": 1, + "first_day_at": 1793160000000, + "hours": 0, + "quests": 1, + "reviews": 5 + }, + "local_hour": 10, + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "quests": [ + { + "done": false, + "progress": 5, + "reward": 50, + "target": 15, + "title": "Review 15 cards" + }, + { + "done": true, + "progress": 5, + "reward": 50, + "target": 5, + "title": "Review 5 cards before noon" + }, + { + "done": false, + "progress": 1, + "reward": 50, + "target": 2, + "title": "Study in 2 separate sessions" + } + ], + "records": { + "day": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "hour": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 51 + }, + "month": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "week": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "year": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + } + }, + "stored_freezes": 0, + "streak": 1, + "streak_state": "studied", + "streak_warning": null, + "today": { + "current_combo": 5, + "max_combo": 5, + "minutes": 0, + "new_cards": 0, + "reviews": 5, + "streak_minutes": 0, + "xp": 101 + }, + "user": "hill", + "week_xp": 101, + "xp_for_next": 280, + "xp_into_level": 1, + "xp_total": 101 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/wins.json b/AnkiDroid/src/test/resources/ankiquest-api/wins.json new file mode 100644 index 000000000000..4e74ad45b4ee --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/wins.json @@ -0,0 +1,26 @@ +{ + "wins": [ + { + "art": "celebrate", + "body": "5 cards today in 0 minutes.", + "created_at": 1793183400, + "day": 20754, + "id": 1, + "key": "goal:20754", + "kind": "daily_goal", + "link": { + "image": "https://anki.example.test/w/example-link-1.png", + "url": "https://anki.example.test/w/example-link-1" + }, + "policy": "ask", + "shared_with_friends": true, + "stats": { + "level": 2, + "minutes_today": 1, + "reviews_today": 5, + "streak": 1 + }, + "title": "Daily goal reached!" + } + ] +} From 475138c2189d5340d8f99874d2830a9308f80f26 Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 1 Nov 2026 15:33:22 +0100 Subject: [PATCH 4/9] Check the share-as-link answer against the server's example --- .../ichi2/anki/ankiquest/AnkiquestApiContractTest.kt | 11 +++++++++-- .../test/resources/ankiquest-api/share_to_link.json | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt index 51bd8330b832..0c35660e0099 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt @@ -61,7 +61,8 @@ class AnkiquestApiContractTest : RobolectricTest() { server.createContext("/") { exchange -> val path = exchange.requestURI.path requests += "${exchange.requestMethod} $path" - val body = route(exchange.requestMethod, path)?.let(::example)?.toByteArray(Charsets.UTF_8) + val sent = exchange.requestBody.bufferedReader().use { it.readText() } + val body = route(exchange.requestMethod, path, sent)?.let(::example)?.toByteArray(Charsets.UTF_8) exchange.responseHeaders.set("Content-Type", "application/json") if (body == null) { exchange.sendResponseHeaders(404, -1) @@ -200,6 +201,10 @@ class AnkiquestApiContractTest : RobolectricTest() { fun `sharing a win, cheering one and the stored settings are read`() = runBlocking { assertEquals(2, Ankiquest.shareWinWithFriends(1, account.scope)) + val link = Ankiquest.shareWinLink(1, account.scope) + assertTrue(link.url.contains("/w/"), link.url) + assertTrue(link.image.endsWith(".png"), link.image) + assertTrue(link.text.contains(link.url), link.text) assertFalse(Ankiquest.react(6, account.notificationAccount, account.scope)) val sharing = AnkiquestSharing.settings(account) assertEquals(3, sharing.dailyCap) @@ -220,9 +225,11 @@ class AnkiquestApiContractTest : RobolectricTest() { private fun route( method: String, path: String, + sent: String, ): String? { val parts = path.removePrefix("/api/").split('/') return when { + parts.firstOrNull() == "share" && sent.isNotBlank() && JSONObject(sent).optString("to") == "link" -> "share_to_link" path == "/api/accounts" && method == "POST" -> "sign_up" path == "/api/accounts/tokens" && method == "POST" -> "sign_in" path == "/api/leaderboard" -> "leaderboard" @@ -253,7 +260,7 @@ class AnkiquestApiContractTest : RobolectricTest() { "streak-freezes" to "streak_freezes", ) - val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard") + val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard", "share_to_link") val UNUSED = setOf("capabilities", "devices", "wins") } diff --git a/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json b/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json new file mode 100644 index 000000000000..65125c36e134 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json @@ -0,0 +1,5 @@ +{ + "image": "https://anki.example.test/w/example-link-1.png", + "text": "Daily goal reached!: 5 cards today in 0 minutes. Join me on AnkiQuest: https://anki.example.test/w/example-link-1", + "url": "https://anki.example.test/w/example-link-1" +} From 6f83be71fc77dd73208c2fe68b24cefa726926f5 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 08:25:10 +0100 Subject: [PATCH 5/9] Contract workflow: run on main and never write the Gradle cache --- .github/workflows/ankiquest_api_contract.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml index d95276e12790..76d7b588b712 100644 --- a/.github/workflows/ankiquest_api_contract.yml +++ b/.github/workflows/ankiquest_api_contract.yml @@ -4,7 +4,7 @@ name: ankiquest API contract on: pull_request: push: - branches: [ankiquest] + branches: [main] schedule: - cron: "17 5 * * *" workflow_dispatch: @@ -67,7 +67,8 @@ jobs: timeout-minutes: 10 with: cache-provider: basic - cache-read-only: ${{ github.ref != 'refs/heads/ankiquest' }} + # Never save: this job runs code from the server checkout, which must not reach the shared cache. + cache-read-only: true - name: Gradle Dependency Download uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 From 17d166824cd12145af9c7d8a21afbd25bd7ab527 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 09:44:51 +0100 Subject: [PATCH 6/9] Contract workflow: run the server checkout in its own job, without a cache --- .github/workflows/ankiquest_api_contract.yml | 65 ++++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml index 76d7b588b712..f7c8eb9bde3b 100644 --- a/.github/workflows/ankiquest_api_contract.yml +++ b/.github/workflows/ankiquest_api_contract.yml @@ -26,13 +26,17 @@ env: JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10 jobs: - contract: - name: API contract + # Everything from the server checkout runs here, in a job with no cache, so it + # can never reach the shared Gradle cache. The contract job gets the examples as data. + server: + name: Server examples and art if: github.event_name != 'schedule' || github.repository == 'float3/AnkiQuest-Android' runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -41,17 +45,51 @@ jobs: path: server persist-credentials: false + - name: Keep the server's current examples + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-api-examples + path: server/docs/api/examples/*.json + if-no-files-found: warn + retention-days: 1 + + - name: Check the app's art against the server's exporter + shell: bash + run: | + if [ -f server/tools/export_android_art.py ]; then + python3 -m venv "$RUNNER_TEMP/art" + "$RUNNER_TEMP/art/bin/pip" install --quiet pillow + "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . + else + echo "The server at this ref has no tools/export_android_art.py" + fi + + contract: + name: API contract + needs: server + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download the server's current examples + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + continue-on-error: true + with: + name: server-api-examples + path: ${{ runner.temp }}/server-api-examples + - name: Use the server's current examples shell: bash run: | - examples=server/docs/api/examples + examples="$RUNNER_TEMP/server-api-examples" resources=AnkiDroid/src/test/resources/ankiquest-api - if [ -d "$examples" ]; then + if compgen -G "$examples/*.json" > /dev/null; then find "$resources" -name '*.json' -delete cp "$examples"/*.json "$resources"/ git status --short -- "$resources" else - echo "The server at this ref has no $examples; checking the committed snapshot" + echo "The server at this ref has no docs/api/examples; checking the committed snapshot" fi - name: Configure JDK @@ -67,8 +105,7 @@ jobs: timeout-minutes: 10 with: cache-provider: basic - # Never save: this job runs code from the server checkout, which must not reach the shared cache. - cache-read-only: true + cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Gradle Dependency Download uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -84,15 +121,3 @@ jobs: - name: Report Unit Test Results if: always() && hashFiles('**/build/test-results/**/TEST-*.xml') != '' run: python3 .github/report_test_failures.py . --pattern "build/test-results/**/TEST-*.xml" --title "API contract failures" - - - name: Check the app's art against the server's exporter - if: ${{ !cancelled() }} - shell: bash - run: | - if [ -f server/tools/export_android_art.py ]; then - python3 -m venv "$RUNNER_TEMP/art" - "$RUNNER_TEMP/art/bin/pip" install --quiet pillow - "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . - else - echo "The server at this ref has no tools/export_android_art.py" - fi From 4c908ac9ee7a70a5020176e0f490c3a05884dc7d Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 14:49:32 +0100 Subject: [PATCH 7/9] Contract test: read the finished-deck share reply, and list mute as unused --- .../anki/ankiquest/AnkiquestApiContractTest.kt | 12 ++++++++++-- .../test/resources/ankiquest-api/deck_share.json | 3 +++ .../src/test/resources/ankiquest-api/mute.json | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/deck_share.json create mode 100644 AnkiDroid/src/test/resources/ankiquest-api/mute.json diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt index 0c35660e0099..f500fe651309 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt @@ -160,6 +160,13 @@ class AnkiquestApiContractTest : RobolectricTest() { assertFalse(switches.getBoolean(Ankiquest.NUDGES)) } + @Test + fun `telling friends about a finished deck reads how many were told`() = + runBlocking { + assertEquals(1, AnkiquestSharing.shareFinishedDeck(account, 1L, "Spanish", listOf("cerro"))) + assertTrue(requests.contains("POST /api/decks/hill/share")) + } + @Test fun `the leaderboard orders the widget rows`() = runBlocking { @@ -233,6 +240,7 @@ class AnkiquestApiContractTest : RobolectricTest() { path == "/api/accounts" && method == "POST" -> "sign_up" path == "/api/accounts/tokens" && method == "POST" -> "sign_in" path == "/api/leaderboard" -> "leaderboard" + parts.size == 3 && parts[0] == "decks" && parts[2] == "share" && method == "POST" -> "deck_share" parts.size == 2 && path.startsWith("/api/") -> ROUTES[parts[0]] else -> null } @@ -260,8 +268,8 @@ class AnkiquestApiContractTest : RobolectricTest() { "streak-freezes" to "streak_freezes", ) - val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard", "share_to_link") + val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard", "share_to_link", "deck_share") - val UNUSED = setOf("capabilities", "devices", "wins") + val UNUSED = setOf("capabilities", "devices", "mute", "wins") } } diff --git a/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json b/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json new file mode 100644 index 000000000000..33e6a1feaa37 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json @@ -0,0 +1,3 @@ +{ + "recipients": 1 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/mute.json b/AnkiDroid/src/test/resources/ankiquest-api/mute.json new file mode 100644 index 000000000000..e9306f82d4b9 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/mute.json @@ -0,0 +1,13 @@ +{ + "friends": [], + "groups": [], + "incoming": [], + "muted": [ + { + "display": "Cerro", + "user": "cerro" + } + ], + "outgoing": [], + "public": true +} From dd7301787047e63f32736793ac8127fcca0e7334 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 14:50:06 +0100 Subject: [PATCH 8/9] Contract workflow: skip the server's art script on the nightly run --- .github/workflows/ankiquest_api_contract.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml index f7c8eb9bde3b..d9eb187af3e3 100644 --- a/.github/workflows/ankiquest_api_contract.yml +++ b/.github/workflows/ankiquest_api_contract.yml @@ -54,6 +54,9 @@ jobs: retention-days: 1 - name: Check the app's art against the server's exporter + # This runs the server's code, so not on the nightly run, which has the default branch's cache access. + # Pull requests and pushes still check the art. + if: github.event_name != 'schedule' shell: bash run: | if [ -f server/tools/export_android_art.py ]; then From c11a477e563b8bfe511f8a926e24be57a57c694d Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 14:52:56 +0100 Subject: [PATCH 9/9] Move the art check into its own pull request and push workflow --- .github/workflows/ankiquest_api_contract.yml | 16 +------ .github/workflows/ankiquest_art_check.yml | 44 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ankiquest_art_check.yml diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml index d9eb187af3e3..b9ea413a748d 100644 --- a/.github/workflows/ankiquest_api_contract.yml +++ b/.github/workflows/ankiquest_api_contract.yml @@ -29,7 +29,7 @@ jobs: # Everything from the server checkout runs here, in a job with no cache, so it # can never reach the shared Gradle cache. The contract job gets the examples as data. server: - name: Server examples and art + name: Server examples if: github.event_name != 'schedule' || github.repository == 'float3/AnkiQuest-Android' runs-on: ubuntu-latest timeout-minutes: 15 @@ -53,20 +53,6 @@ jobs: if-no-files-found: warn retention-days: 1 - - name: Check the app's art against the server's exporter - # This runs the server's code, so not on the nightly run, which has the default branch's cache access. - # Pull requests and pushes still check the art. - if: github.event_name != 'schedule' - shell: bash - run: | - if [ -f server/tools/export_android_art.py ]; then - python3 -m venv "$RUNNER_TEMP/art" - "$RUNNER_TEMP/art/bin/pip" install --quiet pillow - "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . - else - echo "The server at this ref has no tools/export_android_art.py" - fi - contract: name: API contract needs: server diff --git a/.github/workflows/ankiquest_art_check.yml b/.github/workflows/ankiquest_art_check.yml new file mode 100644 index 000000000000..304c000ddb6d --- /dev/null +++ b/.github/workflows/ankiquest_art_check.yml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: ankiquest art check + +# Runs the server's art exporter against the app's drawables. It runs the server's code, so it +# only runs for pull requests and pushes, never on a schedule or by hand. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + art: + name: App art matches the server's exporter + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: float3/ankiquest + ref: master + path: server + persist-credentials: false + + - name: Check the app's art against the server's exporter + shell: bash + run: | + if [ -f server/tools/export_android_art.py ]; then + python3 -m venv "$RUNNER_TEMP/art" + "$RUNNER_TEMP/art/bin/pip" install --quiet pillow + "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . + else + echo "The server has no tools/export_android_art.py" + fi