diff --git a/.prettierignore b/.prettierignore
index 5f1f97059b26..faa6e69a1001 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,2 @@
-AnkiDroid/src/main/assets/mathjax
-AnkiDroid/src/main/assets/jquery.min.js
+AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js
AnkiDroid/build/
\ No newline at end of file
diff --git a/AnkiDroid/proguard-rules.pro b/AnkiDroid/proguard-rules.pro
index d5012e76b2f8..ae54cdc726dd 100644
--- a/AnkiDroid/proguard-rules.pro
+++ b/AnkiDroid/proguard-rules.pro
@@ -29,6 +29,7 @@
-keep class androidx.core.app.ActivityCompat$* { *; }
-keep class androidx.concurrent.futures.** { *; }
-keep class androidx.appcompat.view.menu.MenuItemImpl { *; } # .utils.ext.MenuItemImpl
+-keep class com.ichi2.anki.jsapi.Endpoint { *; } # allEndpoints
# Ignore unused packages
-dontwarn javax.naming.**
diff --git a/AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js b/AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js
new file mode 100644
index 000000000000..3637757acbe9
--- /dev/null
+++ b/AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js
@@ -0,0 +1 @@
+var AnkiDroidJs=function(i){"use strict";var y=Object.defineProperty;var v=(i,o,n)=>o in i?y(i,o,{enumerable:!0,configurable:!0,writable:!0,value:n}):i[o]=n;var s=(i,o,n)=>v(i,typeof o!="symbol"?o+"":o,n);class o{constructor(r){this.contract=r}async request(r,e){const t=`/jsapi/${r}`,a=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:this.contract.version,developer:this.contract.developer,data:e})});if(!a.ok)throw new Error(`Request failed with status ${a.status}`);const m=await a.text();return JSON.parse(m)}}class n{constructor(r){this.handler=r}async request(r,e){return this.handler.request(`${this.base}/${r}`,e)}}class c extends n{constructor(){super(...arguments);s(this,"base","android")}showSnackbar(e,t){return this.request("show-snackbar",{text:e,duration:t})}isSystemInDarkMode(){return this.request("is-system-in-dark-mode")}isNetworkMetered(){return this.request("is-network-metered")}}class h extends n{constructor(e,t=null){super(e);s(this,"base","card");s(this,"id");if(t!==null&&(!Number.isInteger(t)||t<0))throw new Error("Card ID must be a positive integer.");this.id=t}getId(){return this.id!==null?Promise.resolve({success:!0,value:this.id}):this.request("get-id")}getFlag(){return this.request("get-flag")}getReps(){return this.request("get-reps")}getInterval(){return this.request("get-interval")}getFactor(){return this.request("get-factor")}getMod(){return this.request("get-mod")}getNid(){return this.request("get-nid")}getType(){return this.request("get-type")}getDid(){return this.request("get-did")}getLeft(){return this.request("get-left")}getODid(){return this.request("get-o-did")}getODue(){return this.request("get-o-due")}getQueue(){return this.request("get-queue")}getLapses(){return this.request("get-lapses")}getQuestion(){return this.request("get-question")}getAnswer(){return this.request("get-answer")}getDue(){return this.request("get-due")}isMarked(){return this.request("is-marked")}bury(){return this.request("bury")}suspend(){return this.request("suspend")}unbury(){return this.request("unbury")}unsuspend(){return this.request("unsuspend")}resetProgress(){return this.request("reset-progress")}toggleFlag(e){return e<0||e>7?Promise.resolve({success:!1,error:"Flag must be an integer between 0 and 7."}):this.request("toggle-flag",{flag:e})}getReviewLogs(){return this.request("get-review-logs")}async request(e,t){return super.request(e,{id:this.id,...t||{}})}}class q extends n{constructor(){super(...arguments);s(this,"base","collection")}undo(){return this.request("undo")}redo(){return this.request("redo")}isUndoAvailable(){return this.request("is-undo-available")}isRedoAvailable(){return this.request("is-redo-available")}findCards(e){return this.request("find-cards",{search:e})}findNotes(e){return this.request("find-notes",{search:e})}}class d extends n{constructor(e,t=null){super(e);s(this,"base","deck");s(this,"id");if(t!==null&&(!Number.isInteger(t)||t<0))throw new Error("Deck ID must be a positive integer.");this.id=t}getId(){return this.id!==null?Promise.resolve({success:!0,value:this.id}):this.request("get-id")}getName(){return this.request("get-name")}isFiltered(){return this.request("is-filtered")}async request(e,t){return super.request(e,{id:this.id,...t||{}})}}class l extends n{constructor(e,t=null){super(e);s(this,"base","note");s(this,"id");if(t!==null&&(!Number.isInteger(t)||t<0))throw new Error("Note ID must be a positive integer.");this.id=t}getId(){return this.id!==null?Promise.resolve({success:!0,value:this.id}):this.request("get-id")}getNoteTypeId(){return this.request("get-note-type-id")}getCardIds(){return this.request("get-card-ids")}bury(){return this.request("bury")}suspend(){return this.request("suspend")}getTags(){return this.request("get-tags")}setTags(e){return this.request("set-tags",{tags:e})}toggleMark(){return this.request("toggle-mark")}async request(e,t){return super.request(e,{id:this.id,...t||{}})}}class g extends n{constructor(e,t=null){super(e);s(this,"base","note-type");s(this,"id");if(t!==null&&(!Number.isInteger(t)||t<0))throw new Error("Note type ID must be a positive integer.");this.id=t}getId(){return this.id!==null?Promise.resolve({success:!0,value:this.id}):this.request("get-id")}getName(){return this.request("get-name")}isImageOcclusion(){return this.request("is-image-occlusion")}isCloze(){return this.request("is-cloze")}getFieldNames(){return this.request("get-field-names")}async request(e,t){return super.request(e,{id:this.id,...t||{}})}}class p extends n{constructor(){super(...arguments);s(this,"base","tts")}speak(e,t){return t!==0&&t!==1?Promise.resolve({success:!1,error:"Invalid queue mode."}):this.request("speak",{text:e,queueMode:t})}setLanguage(e){return this.request("set-language",{locale:e})}setPitch(e){return this.request("set-pitch",{pitch:e})}setSpeechRate(e){return this.request("set-speech-rate",{speechRate:e})}isSpeaking(){return this.request("is-speaking")}stop(){return this.request("stop")}}class w extends n{constructor(){super(...arguments);s(this,"base","study-screen")}getNewCount(){return this.request("get-new-count")}getLearningCount(){return this.request("get-learning-count")}getToReviewCount(){return this.request("get-to-review-count")}showAnswer(){return this.request("show-answer")}isShowingAnswer(){return this.request("is-showing-answer")}getNextTime(e){return this.request("get-next-time",{rating:e})}openCardInfo(e){return this.request("open-card-info",{cardId:e})}openNoteEditor(e){return this.request("open-note-editor",{cardId:e})}setBackgroundColor(e){return this.request("set-background-color",{colorHex:e})}answer(e){return this.request("answer",{rating:e})}deleteNote(){return this.request("delete-note")}}class b{constructor(r){s(this,"handler");s(this,"android");s(this,"card");s(this,"collection");s(this,"deck");s(this,"note");s(this,"noteType");s(this,"studyScreen");s(this,"tts");this.handler=new o(r),this.android=new c(this.handler),this.card=new h(this.handler),this.collection=new q(this.handler),this.deck=new d(this.handler),this.note=new l(this.handler),this.noteType=new g(this.handler),this.studyScreen=new w(this.handler),this.tts=new p(this.handler)}getCard(r){return new h(this.handler,r)}getNote(r){return new l(this.handler,r)}getDeck(r){return new d(this.handler,r)}getNoteType(r){return new g(this.handler,r)}}return i.Api=b,Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),i}({});
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
index 592a022c5d99..1e176131ebcb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
@@ -116,6 +116,7 @@ import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
import com.ichi2.anki.dialogs.tags.TagsDialog
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
import com.ichi2.anki.dialogs.tags.TagsDialogListener
+import com.ichi2.anki.jsapi.JsApi
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.Collection
@@ -2720,9 +2721,9 @@ abstract class AbstractFlashcardViewer :
uri: String,
bytes: ByteArray,
): ByteArray =
- if (uri.startsWith(AnkiServer.ANKIDROID_JS_PREFIX)) {
+ if (uri.startsWith(JsApi.REQUEST_PREFIX)) {
jsApi.handleJsApiRequest(
- uri.substring(AnkiServer.ANKIDROID_JS_PREFIX.length),
+ uri.substring(JsApi.REQUEST_PREFIX.length),
bytes,
returnDefaultValues = true,
)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
index a23adcde7375..0242bd737791 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
@@ -70,6 +70,7 @@ import com.ichi2.anki.cardviewer.Gesture
import com.ichi2.anki.cardviewer.ViewerCommand
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.time.TimeManager
+import com.ichi2.anki.jsapi.JsApi
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.Collection
@@ -89,7 +90,6 @@ import com.ichi2.anki.multimedia.audio.AudioRecordingController.Companion.tempAu
import com.ichi2.anki.multimedia.audio.AudioRecordingController.RecordingState
import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.observability.undoableOp
-import com.ichi2.anki.pages.AnkiServer.Companion.ANKIDROID_JS_PREFIX
import com.ichi2.anki.pages.AnkiServer.Companion.ANKI_PREFIX
import com.ichi2.anki.pages.CardInfoDestination
import com.ichi2.anki.preferences.sharedPrefs
@@ -1622,9 +1622,9 @@ open class Reviewer :
"i18nResources" -> withCol { i18nResourcesRaw(bytes) }
else -> throw IllegalArgumentException("unhandled request: $methodName")
}
- } else if (uri.startsWith(ANKIDROID_JS_PREFIX)) {
+ } else if (uri.startsWith(JsApi.REQUEST_PREFIX)) {
jsApi.handleJsApiRequest(
- uri.substring(ANKIDROID_JS_PREFIX.length),
+ uri.substring(JsApi.REQUEST_PREFIX.length),
bytes,
returnDefaultValues = false,
)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
index a85138d15503..5c6804a21ec2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
@@ -33,6 +33,7 @@ class MediaErrorHandler {
private var hasExecuted = false
private var automaticTtsFailureCount = 0
+ private var hasShownInvalidContractMessage = false
fun processFailure(
request: WebResourceRequest,
@@ -103,4 +104,10 @@ class MediaErrorHandler {
errorHandler.invoke(error)
}
+
+ fun shouldShowJsApiExceptionMessage(): Boolean {
+ if (hasShownInvalidContractMessage) return false
+ hasShownInvalidContractMessage = true
+ return true
+ }
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/Endpoint.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/Endpoint.kt
new file mode 100644
index 000000000000..b1e33078a690
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/Endpoint.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+/**
+ * Represents a JavaScript API Endpoint.
+ *
+ * It should be structured as `service-base/endpoint`
+ */
+sealed interface Endpoint {
+ /** Base path of the service */
+ val base: String
+ val value: String
+
+ enum class Android(
+ override val value: String,
+ ) : Endpoint {
+ SHOW_SNACKBAR("show-snackbar"),
+ IS_SYSTEM_IN_DARK_MODE("is-system-in-dark-mode"),
+ IS_NETWORK_METERED("is-network-metered"),
+ ;
+
+ override val base: String = "android"
+ }
+
+ enum class Card(
+ override val value: String,
+ ) : Endpoint {
+ GET_ID("get-id"),
+ GET_FLAG("get-flag"),
+ GET_REPS("get-reps"),
+ GET_INTERVAL("get-interval"),
+ GET_FACTOR("get-factor"),
+ GET_MOD("get-mod"),
+ GET_NID("get-nid"),
+ GET_TYPE("get-type"),
+ GET_DID("get-did"),
+ GET_LEFT("get-left"),
+ GET_O_DID("get-o-did"),
+ GET_O_DUE("get-o-due"),
+ GET_QUEUE("get-queue"),
+ GET_LAPSES("get-lapses"),
+ GET_DUE("get-due"),
+ GET_QUESTION("get-question"),
+ GET_ANSWER("get-answer"),
+ IS_MARKED("is-marked"),
+ BURY("bury"),
+ SUSPEND("suspend"),
+ UNBURY("unbury"),
+ UNSUSPEND("unsuspend"),
+ RESET_PROGRESS("reset-progress"),
+ TOGGLE_FLAG("toggle-flag"),
+ GET_REVIEW_LOGS("get-review-logs"),
+ ;
+
+ override val base = "card"
+ }
+
+ enum class Collection(
+ override val value: String,
+ ) : Endpoint {
+ UNDO("undo"),
+ REDO("redo"),
+ IS_UNDO_AVAILABLE("is-undo-available"),
+ IS_REDO_AVAILABLE("is-redo-available"),
+ FIND_CARDS("find-cards"),
+ FIND_NOTES("find-notes"),
+ ;
+
+ override val base = "collection"
+ }
+
+ enum class Deck(
+ override val value: String,
+ ) : Endpoint {
+ GET_ID("get-id"),
+ GET_NAME("get-name"),
+ IS_FILTERED("is-filtered"),
+ ;
+
+ override val base = "deck"
+ }
+
+ enum class Note(
+ override val value: String,
+ ) : Endpoint {
+ GET_ID("get-id"),
+ GET_NOTE_TYPE_ID("get-note-type-id"),
+ GET_CARD_IDS("get-card-ids"),
+ BURY("bury"),
+ SUSPEND("suspend"),
+ GET_TAGS("get-tags"),
+ SET_TAGS("set-tags"),
+ TOGGLE_MARK("toggle-mark"),
+ ;
+
+ override val base = "note"
+ }
+
+ enum class NoteType(
+ override val value: String,
+ ) : Endpoint {
+ GET_ID("get-id"),
+ GET_NAME("get-name"),
+ IS_IMAGE_OCCLUSION("is-image-occlusion"),
+ IS_CLOZE("is-cloze"),
+ GET_FIELD_NAMES("get-field-names"),
+ ;
+
+ override val base = "note-type"
+ }
+
+ enum class StudyScreen(
+ override val value: String,
+ ) : Endpoint {
+ GET_NEW_COUNT("get-new-count"),
+ GET_LEARNING_COUNT("get-learning-count"),
+ GET_TO_REVIEW_COUNT("get-to-review-count"),
+ SHOW_ANSWER("show-answer"),
+ ANSWER("answer"),
+ IS_SHOWING_ANSWER("is-showing-answer"),
+ GET_NEXT_TIME("get-next-time"),
+ OPEN_CARD_INFO("open-card-info"),
+ OPEN_NOTE_EDITOR("open-note-editor"),
+ SET_BACKGROUND_COLOR("set-background-color"),
+ DELETE_NOTE("delete-note"),
+ ;
+
+ override val base = "study-screen"
+ }
+
+ enum class Tts(
+ override val value: String,
+ ) : Endpoint {
+ SPEAK("speak"),
+ SET_LANGUAGE("set-language"),
+ SET_PITCH("set-pitch"),
+ SET_SPEECH_RATE("set-speech-rate"),
+ IS_SPEAKING("is-speaking"),
+ STOP("stop"),
+ ;
+
+ override val base = "tts"
+ }
+
+ companion object {
+ /**
+ * A map of all possible endpoints, indexed by a pair of their base and value strings.
+ */
+ private val allEndpoints by lazy {
+ Endpoint::class
+ .sealedSubclasses
+ .flatMap { it.java.enumConstants?.asList() ?: emptyList() }
+ .associateBy { it.base to it.value }
+ }
+
+ /**
+ * Retrieves a specific Endpoint enum constant based on its base and value.
+ *
+ * @param base The base string of the endpoint (e.g., "card").
+ * @param value The value string of the endpoint (e.g., "get-id").
+ * @return The matching [Endpoint], or `null` if no match is found.
+ */
+ fun from(
+ base: String,
+ value: String,
+ ): Endpoint? = allEndpoints[base to value]
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/InvalidContractException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/InvalidContractException.kt
new file mode 100644
index 000000000000..5ef663f2053a
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/InvalidContractException.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+import android.content.res.Resources
+import com.ichi2.anki.R
+
+sealed class InvalidContractException : Exception() {
+ abstract fun localizedErrorMessage(resources: Resources): String
+
+ class ContactError : InvalidContractException() {
+ override fun localizedErrorMessage(resources: Resources): String {
+ val errorMessage = resources.getString(R.string.js_api_error_code, INVALID_CONTACT_ERROR_CODE)
+ return resources.getString(R.string.invalid_contact_message, errorMessage)
+ }
+ }
+
+ class VersionError(
+ private val requestVersion: String,
+ private val contact: String,
+ ) : InvalidContractException() {
+ override fun localizedErrorMessage(resources: Resources): String {
+ val errorMessage = resources.getString(R.string.js_api_error_code, INVALID_VERSION_ERROR_CODE)
+ return resources.getString(R.string.invalid_js_api_version_message, requestVersion, contact, errorMessage)
+ }
+ }
+
+ class OutdatedVersion(
+ private val currentVersion: String,
+ private val requestVersion: String,
+ private val contact: String,
+ ) : InvalidContractException() {
+ override fun localizedErrorMessage(resources: Resources): String {
+ val errorMessage = resources.getString(R.string.js_api_error_code, OUTDATED_VERSION_ERROR_CODE)
+ return resources.getString(R.string.outdated_js_api_message, currentVersion, requestVersion, contact, errorMessage)
+ }
+ }
+
+ companion object {
+ const val INVALID_CONTACT_ERROR_CODE = "INVALID_CONTACT"
+ const val INVALID_VERSION_ERROR_CODE = "INVALID_VERSION"
+ const val OUTDATED_VERSION_ERROR_CODE = "OUTDATED_VERSION"
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/JsApi.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/JsApi.kt
new file mode 100644
index 000000000000..2cb3a4af4172
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/JsApi.kt
@@ -0,0 +1,400 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+import android.speech.tts.TextToSpeech
+import androidx.annotation.VisibleForTesting
+import com.github.zafarkhaja.semver.ParseException
+import com.github.zafarkhaja.semver.Version
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.Flag
+import com.ichi2.anki.JavaScriptTTS
+import com.ichi2.anki.common.utils.ext.getDoubleOrNull
+import com.ichi2.anki.common.utils.ext.getIntOrNull
+import com.ichi2.anki.common.utils.ext.getLongOrNull
+import com.ichi2.anki.common.utils.ext.getStringOrNull
+import com.ichi2.anki.libanki.Card
+import com.ichi2.anki.libanki.Note
+import com.ichi2.anki.libanki.redoAvailable
+import com.ichi2.anki.libanki.undoAvailable
+import com.ichi2.anki.observability.undoableOp
+import com.ichi2.anki.servicelayer.MARKED_TAG
+import com.ichi2.anki.servicelayer.NoteService
+import com.ichi2.anki.utils.ext.flag
+import com.ichi2.anki.utils.ext.setUserFlagForCards
+import org.json.JSONArray
+import org.json.JSONObject
+import timber.log.Timber
+
+object JsApi {
+ @VisibleForTesting
+ const val CURRENT_VERSION = "1.0.0"
+ private const val SUCCESS_KEY = "success"
+ private const val VALUE_KEY = "value"
+ private const val ERROR_KEY = "error"
+ const val REQUEST_PREFIX = "/jsapi/"
+
+ private val tts by lazy { JavaScriptTTS() }
+
+ fun parseRequest(byteArray: ByteArray): JSONObject? {
+ val requestBody = JSONObject(byteArray.decodeToString())
+ validateContract(requestBody)
+ return requestBody.optJSONObject("data")
+ }
+
+ /**
+ * @throws InvalidContractException if
+ * * developer contact is empty
+ * * request version is invalid
+ * * request version is higher than the API version
+ * * request major version is lower than the API version
+ */
+ private fun validateContract(json: JSONObject) {
+ // Developer contact
+ val developer = json.getStringOrNull("developer")
+ if (developer.isNullOrBlank()) {
+ throw InvalidContractException.ContactError()
+ }
+ // Version
+ val versionString = json.getStringOrNull("version") ?: throw InvalidContractException.VersionError("", developer)
+
+ val currentVersion = Version.parse(CURRENT_VERSION)
+ val requestVersion =
+ try {
+ Version.parse(versionString)
+ } catch (_: ParseException) {
+ throw InvalidContractException.VersionError(versionString, developer)
+ }
+
+ when {
+ requestVersion.isHigherThan(currentVersion) -> throw InvalidContractException.VersionError(versionString, developer)
+ requestVersion.isSameMajorVersionAs(currentVersion) -> return
+ requestVersion.isLowerThan(
+ currentVersion,
+ ) -> throw InvalidContractException.OutdatedVersion(CURRENT_VERSION, versionString, developer)
+ else -> throw InvalidContractException.VersionError(versionString, developer)
+ }
+ }
+
+ fun getEndpoint(uri: String): Endpoint? {
+ val path = uri.removePrefix(REQUEST_PREFIX)
+ val parts = path.split('/', limit = 2).takeIf { it.size == 2 }
+ return parts?.let { (base, value) ->
+ Endpoint.from(base, value)
+ }
+ }
+
+ suspend fun handleEndpointRequest(
+ endpoint: Endpoint,
+ data: JSONObject?,
+ topCard: Card,
+ ): ByteArray =
+ when (endpoint) {
+ is Endpoint.Card -> handleCardMethods(endpoint, data, topCard)
+ is Endpoint.Collection -> handleCollectionMethods(endpoint, data)
+ is Endpoint.Deck -> handleDeckMethods(endpoint, data, topCard)
+ is Endpoint.Note -> handleNoteMethods(endpoint, data, topCard)
+ is Endpoint.NoteType -> handleNoteTypeMethods(endpoint, data, topCard)
+ is Endpoint.Tts -> handleTtsEndpoints(endpoint, data)
+ is Endpoint.Android, is Endpoint.StudyScreen -> fail("Method not supported")
+ }
+
+ private suspend fun handleCardMethods(
+ endpoint: Endpoint.Card,
+ data: JSONObject?,
+ topCard: Card,
+ ): ByteArray {
+ val cardId = data?.getLongOrNull("id")
+ val card =
+ if (cardId != null) {
+ withCol { Card(this, cardId) }
+ } else {
+ topCard
+ }
+ return when (endpoint) {
+ Endpoint.Card.GET_ID -> success(card.id)
+ Endpoint.Card.GET_NID -> success(card.nid)
+ Endpoint.Card.GET_FLAG -> success(card.flag.code)
+ Endpoint.Card.GET_REPS -> success(card.reps)
+ Endpoint.Card.GET_INTERVAL -> success(card.ivl)
+ Endpoint.Card.GET_FACTOR -> success(card.factor)
+ Endpoint.Card.GET_MOD -> success(card.mod)
+ Endpoint.Card.GET_TYPE -> success(card.type.code)
+ Endpoint.Card.GET_DID -> success(card.did)
+ Endpoint.Card.GET_LEFT -> success(card.left)
+ Endpoint.Card.GET_O_DID -> success(card.oDid)
+ Endpoint.Card.GET_O_DUE -> success(card.oDue)
+ Endpoint.Card.GET_QUEUE -> success(card.queue.code)
+ Endpoint.Card.GET_LAPSES -> success(card.lapses)
+ Endpoint.Card.GET_DUE -> success(card.due)
+ Endpoint.Card.GET_QUESTION -> {
+ val question = withCol { card.question(this) }
+ success(question)
+ }
+ Endpoint.Card.GET_ANSWER -> {
+ val answer = withCol { card.answer(this) }
+ success(answer)
+ }
+ Endpoint.Card.BURY -> {
+ val count = undoableOp { sched.buryCards(cids = listOf(card.id)) }.count
+ success(count)
+ }
+ Endpoint.Card.IS_MARKED -> {
+ val isMarked = withCol { card.note(this).hasTag(this, MARKED_TAG) }
+ success(isMarked)
+ }
+ Endpoint.Card.SUSPEND -> {
+ val count = undoableOp { sched.suspendCards(ids = listOf(card.id)) }.count
+ success(count)
+ }
+ Endpoint.Card.UNBURY -> {
+ undoableOp { sched.unburyCards(listOf(card.id)) }
+ success()
+ }
+ Endpoint.Card.UNSUSPEND -> {
+ undoableOp { sched.unsuspendCards(listOf(card.id)) }
+ success()
+ }
+ Endpoint.Card.RESET_PROGRESS -> {
+ undoableOp {
+ sched.forgetCards(listOf(card.id), restorePosition = false, resetCounts = false)
+ }
+ success()
+ }
+ Endpoint.Card.TOGGLE_FLAG -> {
+ val requestFlag = data?.getIntOrNull("flag") ?: return fail("Missing flag")
+ if (requestFlag < 0 || requestFlag > 7) return fail("Invalid flag code")
+
+ val newFlag = if (requestFlag == card.userFlag()) Flag.NONE else Flag.fromCode(requestFlag)
+ undoableOp { setUserFlagForCards(listOf(card.id), newFlag) }
+ success()
+ }
+ Endpoint.Card.GET_REVIEW_LOGS -> {
+ val reviewLogs =
+ withCol { getReviewLogs(card.id) }.map { log ->
+ JSONObject().apply {
+ put("time", log.time)
+ put("reviewKind", log.reviewKindValue)
+ put("buttonChosen", log.buttonChosen)
+ put("interval", log.interval)
+ put("ease", log.ease)
+ put("takenSecs", log.takenSecs)
+ val memoryState =
+ if (log.hasMemoryState()) {
+ JSONObject().apply {
+ put("stability", log.memoryState.stability)
+ put("difficulty", log.memoryState.difficulty)
+ }
+ } else {
+ null
+ }
+ put("memoryState", memoryState)
+ }
+ }
+ success(reviewLogs)
+ }
+ }
+ }
+
+ private suspend fun handleCollectionMethods(
+ endpoint: Endpoint.Collection,
+ data: JSONObject?,
+ ): ByteArray {
+ return when (endpoint) {
+ Endpoint.Collection.UNDO -> {
+ val isUndoAvailable = withCol { undoAvailable() }
+ if (!isUndoAvailable) return fail("Undo is not available")
+ val changes = undoableOp { undo() }
+ success(changes.operation)
+ }
+ Endpoint.Collection.REDO -> {
+ val isRedoAvailable = withCol { redoAvailable() }
+ if (!isRedoAvailable) return fail("Redo is not available")
+ val changes = undoableOp { redo() }
+ success(changes.operation)
+ }
+ Endpoint.Collection.IS_UNDO_AVAILABLE -> {
+ val isUndoAvailable = withCol { undoAvailable() }
+ success(isUndoAvailable)
+ }
+ Endpoint.Collection.IS_REDO_AVAILABLE -> {
+ val isRedoAvailable = withCol { redoAvailable() }
+ success(isRedoAvailable)
+ }
+ Endpoint.Collection.FIND_CARDS -> {
+ val search = data?.getStringOrNull("search") ?: return fail("No search query found")
+ val ids = withCol { findCards(search) }
+ success(ids)
+ }
+ Endpoint.Collection.FIND_NOTES -> {
+ val search = data?.getStringOrNull("search") ?: return fail("No search query found")
+ val ids = withCol { findNotes(search) }
+ success(ids)
+ }
+ }
+ }
+
+ private suspend fun handleNoteMethods(
+ endpoint: Endpoint.Note,
+ data: JSONObject?,
+ topCard: Card,
+ ): ByteArray {
+ val noteId = data?.getLongOrNull("id")
+ val note =
+ if (noteId != null) {
+ withCol { Note(this, noteId) }
+ } else {
+ withCol { topCard.note(this) }
+ }
+ return when (endpoint) {
+ Endpoint.Note.GET_ID -> success(note.id)
+ Endpoint.Note.GET_NOTE_TYPE_ID -> success(note.noteTypeId)
+ Endpoint.Note.GET_CARD_IDS -> success(withCol { note.cardIds(this) })
+ Endpoint.Note.BURY -> {
+ val count = undoableOp { sched.buryNotes(listOf(note.id)) }.count
+ success(count)
+ }
+ Endpoint.Note.SUSPEND -> {
+ val count = undoableOp { sched.suspendNotes(listOf(note.id)) }.count
+ success(count)
+ }
+ Endpoint.Note.GET_TAGS -> {
+ val tags = withCol { note.stringTags(this) }
+ success(tags)
+ }
+ Endpoint.Note.SET_TAGS -> {
+ val tags = data?.optString("tags") ?: return fail("Missing tags")
+ undoableOp {
+ note.setTagsFromStr(this, tags)
+ updateNote(note)
+ }
+ success()
+ }
+ Endpoint.Note.TOGGLE_MARK -> {
+ NoteService.toggleMark(note)
+ success()
+ }
+ }
+ }
+
+ private suspend fun handleNoteTypeMethods(
+ endpoint: Endpoint.NoteType,
+ data: JSONObject?,
+ topCard: Card,
+ ): ByteArray {
+ val noteTypeId = data?.getLongOrNull("id")
+ val noteType =
+ if (noteTypeId != null) {
+ withCol { notetypes }.get(noteTypeId) ?: return fail("Found no note type with the id '$noteTypeId'")
+ } else {
+ withCol { topCard.noteType(this) }
+ }
+ return when (endpoint) {
+ Endpoint.NoteType.GET_ID -> success(noteType.id)
+ Endpoint.NoteType.GET_NAME -> success(noteType.name)
+ Endpoint.NoteType.IS_IMAGE_OCCLUSION -> success(noteType.isImageOcclusion)
+ Endpoint.NoteType.IS_CLOZE -> success(noteType.isCloze)
+ Endpoint.NoteType.GET_FIELD_NAMES -> success(noteType.fieldsNames)
+ }
+ }
+
+ private suspend fun handleDeckMethods(
+ endpoint: Endpoint.Deck,
+ data: JSONObject?,
+ topCard: Card,
+ ): ByteArray {
+ val deckId = data?.getLongOrNull("id") ?: topCard.did
+ val deck = withCol { decks.get(deckId) } ?: return fail("Found no deck with the id '$deckId'")
+ return when (endpoint) {
+ Endpoint.Deck.GET_ID -> success(deck.id)
+ Endpoint.Deck.GET_NAME -> success(deck.name)
+ Endpoint.Deck.IS_FILTERED -> success(deck.isFiltered)
+ }
+ }
+
+ private fun handleTtsEndpoints(
+ endpoint: Endpoint.Tts,
+ data: JSONObject?,
+ ): ByteArray {
+ /** Helps with TTS methods that return SUCCESS or ERROR */
+ fun ttsErrorOrSuccess(
+ @JavaScriptTTS.ErrorOrSuccess result: Int,
+ ) = when (result) {
+ TextToSpeech.SUCCESS -> success()
+ TextToSpeech.ERROR -> fail("TTS engine error")
+ else -> fail("Unknown TTS error")
+ }
+ return when (endpoint) {
+ Endpoint.Tts.SPEAK -> {
+ val text = data?.optString("text") ?: return fail("Missing text")
+ val queueMode = data.getIntOrNull("queueMode") ?: return fail("Missing queueMode")
+ if (queueMode != TextToSpeech.QUEUE_FLUSH && queueMode != TextToSpeech.QUEUE_ADD) return fail("Invalid queueMode")
+ ttsErrorOrSuccess(tts.speak(text, queueMode))
+ }
+ Endpoint.Tts.SET_LANGUAGE -> {
+ val locale = data?.optString("locale") ?: return fail("Missing locale")
+ success(tts.setLanguage(locale))
+ }
+ Endpoint.Tts.SET_PITCH -> {
+ val pitch = data?.getDoubleOrNull("pitch") ?: return fail("Missing pitch")
+ ttsErrorOrSuccess(tts.setPitch(pitch.toFloat()))
+ }
+ Endpoint.Tts.SET_SPEECH_RATE -> {
+ val speechRate = data?.getDoubleOrNull("speechRate") ?: return fail("Missing speechRate")
+ ttsErrorOrSuccess(tts.setSpeechRate(speechRate.toFloat()))
+ }
+ Endpoint.Tts.IS_SPEAKING -> {
+ success(tts.isSpeaking)
+ }
+ Endpoint.Tts.STOP -> {
+ ttsErrorOrSuccess(tts.stop())
+ }
+ }
+ }
+
+ fun success() = successResult(null)
+
+ fun success(string: String) = successResult(string)
+
+ fun success(boolean: Boolean) = successResult(boolean)
+
+ fun success(number: Int) = successResult(number)
+
+ fun success(number: Long) = successResult(number)
+
+ fun success(collection: Collection<*>) = successResult(JSONArray(collection))
+
+ private fun successResult(value: Any?): ByteArray {
+ val jsonObject =
+ JSONObject()
+ .apply {
+ put(SUCCESS_KEY, true)
+ put(VALUE_KEY, value)
+ }
+ return jsonObject.toString().toByteArray()
+ }
+
+ fun fail(error: String): ByteArray {
+ Timber.i("JsApi fail: %s", error)
+ return JSONObject()
+ .apply {
+ put(SUCCESS_KEY, false)
+ put(ERROR_KEY, error)
+ }.toString()
+ .toByteArray()
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/UiRequest.kt b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/UiRequest.kt
new file mode 100644
index 000000000000..94b86b2341a3
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/jsapi/UiRequest.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+import kotlinx.coroutines.CompletableDeferred
+import org.json.JSONObject
+
+data class UiRequest(
+ val endpoint: Endpoint,
+ val data: JSONObject?,
+ val result: CompletableDeferred,
+)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
index 69010b20a21e..f13b5ab5fcb2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt
@@ -73,7 +73,6 @@ open class AnkiServer(
/** Common prefix used on Anki requests */
const val ANKI_PREFIX = "/_anki/"
- const val ANKIDROID_JS_PREFIX = "/jsapi/"
fun getSessionBytes(session: IHTTPSession): ByteArray {
val contentLength = session.headers["content-length"]!!.toInt()
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
index e869b6568ca8..61d6ea36e111 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
@@ -38,7 +38,11 @@ import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.ichi2.anki.R
import com.ichi2.anki.ViewerResourceHandler
+import com.ichi2.anki.common.utils.ext.getIntOrNull
import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
+import com.ichi2.anki.jsapi.Endpoint
+import com.ichi2.anki.jsapi.JsApi
+import com.ichi2.anki.jsapi.UiRequest
import com.ichi2.anki.localizedErrorMessage
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ext.collectIn
@@ -46,9 +50,11 @@ import com.ichi2.anki.utils.ext.packageManager
import com.ichi2.anki.utils.openUrl
import com.ichi2.compat.CompatHelper.Companion.resolveActivityCompat
import com.ichi2.themes.Themes
+import com.ichi2.utils.NetworkUtils
import com.ichi2.utils.show
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
import timber.log.Timber
abstract class CardViewerFragment(
@@ -64,6 +70,7 @@ abstract class CardViewerFragment(
) {
setupWebView(savedInstanceState)
setupErrorListeners()
+ setupJsApi()
}
override fun onStart() {
@@ -137,6 +144,46 @@ abstract class CardViewerFragment(
viewModel.onTtsError
.onEach { showSnackbar(it.localizedErrorMessage(requireContext())) }
.launchIn(lifecycleScope)
+
+ viewModel.onJsApiError.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { error ->
+ val errorMessage = error.localizedErrorMessage(resources)
+ AlertDialog
+ .Builder(requireContext())
+ .setTitle(R.string.vague_error)
+ .setMessage(errorMessage)
+ .show()
+ }
+ }
+
+ private fun setupJsApi() {
+ viewModel.apiRequestFlow.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { request ->
+ val result = handleJsUiRequest(request)
+ request.result.complete(result)
+ }
+ }
+
+ protected open fun handleJsUiRequest(request: UiRequest): ByteArray =
+ if (request.endpoint is Endpoint.Android) {
+ handleAndroidEndpoint(request.endpoint, request.data)
+ } else {
+ JsApi.fail("Unhandled endpoint")
+ }
+
+ private fun handleAndroidEndpoint(
+ endpoint: Endpoint.Android,
+ data: JSONObject?,
+ ): ByteArray {
+ return when (endpoint) {
+ Endpoint.Android.SHOW_SNACKBAR -> {
+ val data = data ?: return JsApi.fail("Missing request data")
+ val text = data.optString("text") ?: return JsApi.fail("Missing text")
+ val duration = data.getIntOrNull("duration") ?: return JsApi.fail("Missing duration")
+ showSnackbar(text, duration)
+ JsApi.success()
+ }
+ Endpoint.Android.IS_SYSTEM_IN_DARK_MODE -> JsApi.success(Themes.systemIsInNightMode(requireContext()))
+ Endpoint.Android.IS_NETWORK_METERED -> JsApi.success(NetworkUtils.isActiveNetworkMetered())
+ }
}
protected open fun onCreateWebViewClient(savedInstanceState: Bundle?): WebViewClient = CardViewerWebViewClient(savedInstanceState)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt
index e190d4dabe40..4373a9dce7c3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt
@@ -27,6 +27,11 @@ import com.ichi2.anki.cardviewer.CardMediaPlayer
import com.ichi2.anki.cardviewer.MediaErrorBehavior
import com.ichi2.anki.cardviewer.MediaErrorHandler
import com.ichi2.anki.cardviewer.MediaErrorListener
+import com.ichi2.anki.jsapi.Endpoint
+import com.ichi2.anki.jsapi.InvalidContractException
+import com.ichi2.anki.jsapi.JsApi
+import com.ichi2.anki.jsapi.JsApi.getEndpoint
+import com.ichi2.anki.jsapi.UiRequest
import com.ichi2.anki.launchCatchingIO
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.TtsPlayer
@@ -34,11 +39,14 @@ import com.ichi2.anki.multimedia.getAvTag
import com.ichi2.anki.multimedia.replaceAvRefsWithPlayButtons
import com.ichi2.anki.pages.AnkiServer
import com.ichi2.anki.pages.PostRequestHandler
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
+import org.json.JSONObject
import timber.log.Timber
abstract class CardViewerViewModel :
@@ -48,9 +56,11 @@ abstract class CardViewerViewModel :
override val onError = MutableSharedFlow()
val onMediaError = MutableSharedFlow()
val onTtsError = MutableSharedFlow()
+ val onJsApiError = MutableSharedFlow()
val mediaErrorHandler = MediaErrorHandler()
val eval = MutableSharedFlow()
+ val apiRequestFlow = MutableSharedFlow()
open val showingAnswer = MutableStateFlow(false)
@@ -200,7 +210,44 @@ abstract class CardViewerViewModel :
"i18nResources" -> withCol { i18nResourcesRaw(bytes) }
else -> throw IllegalArgumentException("Unhandled Anki request: $uri")
}
+ } else if (uri.startsWith(JsApi.REQUEST_PREFIX)) {
+ handleJsRequest(uri, bytes)
} else {
throw IllegalArgumentException("Unhandled POST request: $uri")
}
+
+ private suspend fun handleJsRequest(
+ uri: String,
+ bytes: ByteArray,
+ ): ByteArray {
+ val requestData =
+ try {
+ JsApi.parseRequest(bytes)
+ } catch (exception: InvalidContractException) {
+ if (mediaErrorHandler.shouldShowJsApiExceptionMessage()) {
+ onJsApiError.emit(exception)
+ }
+ return JsApi.fail("Invalid contract")
+ }
+
+ val endpoint = getEndpoint(uri) ?: return JsApi.fail("Invalid endpoint")
+ return handleJsEndpoint(endpoint, requestData)
+ }
+
+ protected open suspend fun handleJsEndpoint(
+ endpoint: Endpoint,
+ data: JSONObject?,
+ ): ByteArray =
+ if (endpoint is Endpoint.Android) {
+ val result = CompletableDeferred()
+ val request = UiRequest(endpoint, data, result)
+ apiRequestFlow.emit(request)
+ // there may be no listeners for the flow, so fail the result after some time
+ // e.g. the fragment uses flowWithLifecycle and is at a different lifecycleState
+ withTimeoutOrNull(2000L) {
+ result.await()
+ } ?: JsApi.fail("Method was not handled")
+ } else {
+ JsApi.handleEndpointRequest(endpoint, data, currentCard.await())
+ }
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
index d18796f9a4ff..77221022757d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
@@ -54,6 +54,7 @@ fun stdHtml(
"backend/js/mathjax.js",
"backend/js/vendor/mathjax/tex-chtml-full.js",
"backend/js/reviewer.js",
+ "scripts/ankidroid-js-api.js",
) + extraJsAssets
val jsTxt =
jsAssets.joinToString("\n") {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
index d6c68cec2989..b1c442aa6ae0 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
@@ -42,6 +42,7 @@ import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
+import androidx.core.graphics.toColorInt
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -71,6 +72,9 @@ import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.dialogs.tags.TagsDialog
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
import com.ichi2.anki.dialogs.tags.TagsDialogListener
+import com.ichi2.anki.jsapi.Endpoint
+import com.ichi2.anki.jsapi.JsApi
+import com.ichi2.anki.jsapi.UiRequest
import com.ichi2.anki.libanki.sched.Counts
import com.ichi2.anki.model.CardStateFilter
import com.ichi2.anki.preferences.reviewer.ReviewerMenuView
@@ -673,6 +677,25 @@ class ReviewerFragment :
stateFilter: CardStateFilter,
) = viewModel.onEditedTags(selectedTags)
+ override fun handleJsUiRequest(request: UiRequest): ByteArray {
+ val result: ByteArray? =
+ when (request.endpoint) {
+ Endpoint.StudyScreen.SET_BACKGROUND_COLOR -> {
+ val colorHex = request.data?.optString("colorHex") ?: return JsApi.fail("Missing hex code")
+ val color =
+ try {
+ colorHex.toColorInt()
+ } catch (_: IllegalArgumentException) {
+ return JsApi.fail("Invalid hex code")
+ }
+ view?.setBackgroundColor(color)
+ JsApi.success()
+ }
+ else -> null
+ }
+ return result ?: super.handleJsUiRequest(request)
+ }
+
override fun onCreateWebViewClient(savedInstanceState: Bundle?): WebViewClient = ReviewerWebViewClient(savedInstanceState)
private inner class ReviewerWebViewClient(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
index fda173153df2..c5d393ec100c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
@@ -30,6 +30,7 @@ import com.ichi2.anki.asyncIO
import com.ichi2.anki.browser.BrowserDestination
import com.ichi2.anki.cardviewer.SingleCardSide
import com.ichi2.anki.common.time.TimeManager
+import com.ichi2.anki.jsapi.Endpoint
import com.ichi2.anki.launchCatchingIO
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
@@ -74,6 +75,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.intellij.lang.annotations.Language
+import org.json.JSONObject
import timber.log.Timber
class ReviewerViewModel(
@@ -81,10 +83,11 @@ class ReviewerViewModel(
) : CardViewerViewModel(),
ChangeManager.Subscriber,
BindingProcessor {
- private var queueState: Deferred =
+ var queueState: Deferred =
asyncIO {
withCol { sched.currentQueueState() }
}
+ private set
override var currentCard =
asyncIO {
queueState.await()?.topCard
@@ -235,10 +238,10 @@ class ReviewerViewModel(
statesMutated = true
}
- private suspend fun emitEditNoteDestination() {
- val cardId = currentCard.await().id
- val destination = NoteEditorLauncher.EditNoteFromPreviewer(cardId)
- Timber.i("Opening 'edit note' for card %d", cardId)
+ suspend fun emitEditNoteDestination(cardId: CardId? = null) {
+ val id = cardId ?: currentCard.await().id
+ val destination = NoteEditorLauncher.EditNoteFromPreviewer(id)
+ Timber.i("Opening 'edit note' for card %d", id)
destinationFlow.emit(destination)
}
@@ -247,10 +250,10 @@ class ReviewerViewModel(
destinationFlow.emit(NoteEditorLauncher.AddNoteFromReviewer())
}
- private suspend fun emitCardInfoDestination() {
- val cardId = currentCard.await().id
- val destination = CardInfoDestination(cardId, TR.cardStatsCurrentCard(TR.decksStudy()))
- Timber.i("Launching 'card info' for card %d", cardId)
+ suspend fun emitCardInfoDestination(cardId: CardId? = null) {
+ val id = cardId ?: currentCard.await().id
+ val destination = CardInfoDestination(id, TR.cardStatsCurrentCard(TR.decksStudy()))
+ Timber.i("Launching 'card info' for card %d", id)
destinationFlow.emit(destination)
}
@@ -281,7 +284,7 @@ class ReviewerViewModel(
destinationFlow.emit(destination)
}
- private suspend fun deleteNote() {
+ suspend fun deleteNote() {
val cardId = currentCard.await().id
val noteCount =
undoableOp {
@@ -329,7 +332,7 @@ class ReviewerViewModel(
updateCurrentCard()
}
- private suspend fun undo() {
+ suspend fun undo() {
Timber.v("ReviewerViewModel::undo")
val changes =
undoableOp {
@@ -412,6 +415,16 @@ class ReviewerViewModel(
super.handlePostRequest(uri, bytes)
}
+ override suspend fun handleJsEndpoint(
+ endpoint: Endpoint,
+ data: JSONObject?,
+ ): ByteArray =
+ if (endpoint is Endpoint.StudyScreen) {
+ handleStudyScreenEndpoint(endpoint, data)
+ } else {
+ super.handleJsEndpoint(endpoint, data)
+ }
+
override suspend fun showQuestion() {
Timber.v("ReviewerViewModel::showQuestion")
super.showQuestion()
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenApiMethods.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenApiMethods.kt
new file mode 100644
index 000000000000..81314a88b86f
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/StudyScreenApiMethods.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.ui.windows.reviewer
+
+import anki.scheduler.CardAnswer
+import com.ichi2.anki.common.utils.ext.getIntOrNull
+import com.ichi2.anki.jsapi.Endpoint
+import com.ichi2.anki.jsapi.JsApi
+import com.ichi2.anki.jsapi.UiRequest
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONObject
+
+suspend fun ReviewerViewModel.handleStudyScreenEndpoint(
+ endpoint: Endpoint.StudyScreen,
+ data: JSONObject?,
+): ByteArray {
+ return when (endpoint) {
+ Endpoint.StudyScreen.GET_NEW_COUNT -> JsApi.success(countsFlow.value.first.new)
+ Endpoint.StudyScreen.GET_LEARNING_COUNT -> JsApi.success(countsFlow.value.first.lrn)
+ Endpoint.StudyScreen.GET_TO_REVIEW_COUNT -> JsApi.success(countsFlow.value.first.rev)
+ Endpoint.StudyScreen.SHOW_ANSWER -> {
+ if (showingAnswer.value) return JsApi.success()
+ onShowAnswer()
+ JsApi.success()
+ }
+ Endpoint.StudyScreen.ANSWER -> {
+ val ratingNumber = data?.getIntOrNull("rating") ?: return JsApi.fail("Missing rating")
+ if (ratingNumber !in 1..4) {
+ return JsApi.fail("Invalid rating")
+ }
+ val rating = CardAnswer.Rating.forNumber(ratingNumber - 1)
+ answerCard(rating)
+ JsApi.success()
+ }
+ Endpoint.StudyScreen.IS_SHOWING_ANSWER -> JsApi.success(showingAnswer.value)
+ Endpoint.StudyScreen.GET_NEXT_TIME -> {
+ val ratingNumber = data?.getIntOrNull("rating") ?: return JsApi.fail("Missing rating")
+ if (ratingNumber !in 1..4) {
+ return JsApi.fail("Invalid rating")
+ }
+ val rating = CardAnswer.Rating.forNumber(ratingNumber - 1)
+
+ val queueState = queueState.await() ?: return JsApi.fail("There is no card at top of the queue")
+
+ val nextTimes = AnswerButtonsNextTime.from(queueState)
+ val nextTime =
+ when (rating) {
+ CardAnswer.Rating.AGAIN -> nextTimes.again
+ CardAnswer.Rating.HARD -> nextTimes.hard
+ CardAnswer.Rating.GOOD -> nextTimes.good
+ CardAnswer.Rating.EASY -> nextTimes.easy
+ CardAnswer.Rating.UNRECOGNIZED -> return JsApi.fail("Invalid rating")
+ }
+ JsApi.success(nextTime)
+ }
+ Endpoint.StudyScreen.OPEN_CARD_INFO -> {
+ val cardId = data?.getLong("cardId")
+ emitCardInfoDestination(cardId)
+ JsApi.success()
+ }
+ Endpoint.StudyScreen.OPEN_NOTE_EDITOR -> {
+ val cardId = data?.getLong("cardId")
+ emitEditNoteDestination(cardId)
+ JsApi.success()
+ }
+ Endpoint.StudyScreen.DELETE_NOTE -> {
+ deleteNote()
+ JsApi.success()
+ }
+ // UI requests
+ Endpoint.StudyScreen.SET_BACKGROUND_COLOR,
+ -> {
+ val result = CompletableDeferred()
+ val request = UiRequest(endpoint, data, result)
+ apiRequestFlow.emit(request)
+ // there may be no listeners for the flow, so fail the result after some time
+ // e.g. the fragment uses flowWithLifecycle and is at a different lifecycleState
+ withTimeoutOrNull(2000L) {
+ result.await()
+ } ?: JsApi.fail("Method was not handled")
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml
index 70059b4b1cee..810d4a82fcab 100644
--- a/AnkiDroid/src/main/res/values/02-strings.xml
+++ b/AnkiDroid/src/main/res/values/02-strings.xml
@@ -226,6 +226,13 @@
AnkiDroid JS API update available. Contact developer %s, or view wiki
View
(Error Code: %d)
+ (Error code: %s)
+ The developer contact used in the AnkiDroid JS API is invalid. Fix it or ask the template author. %s
+ The ā%1$sā API version used by the template is invalid. Contact developer at %2$s. %3$s
+ AnkiDroid JS API has been updated to the version %1$s. This template uses the %2$s version and may need to be updated as well to work. Contact developer at %3$s. %4$s
Copied to clipboard
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt
index 7def077e0271..dafd64012695 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ConstantUniquenessTest.kt
@@ -17,6 +17,7 @@
package com.ichi2.anki
import com.ichi2.anki.browser.BrowserColumnSelectionRecyclerItem
+import com.ichi2.anki.jsapi.InvalidContractException
import com.ichi2.anki.notifications.NotificationId
import com.ichi2.anki.preferences.reviewer.ReviewerMenuSettingsRecyclerItem
import com.ichi2.anki.worker.UniqueWorkNames
@@ -35,6 +36,7 @@ class ConstantUniquenessTest {
assertConstantUniqueness(UniqueWorkNames::class)
assertConstantUniqueness(ReviewerMenuSettingsRecyclerItem.Companion::class)
assertConstantUniqueness(BrowserColumnSelectionRecyclerItem.Companion::class)
+ assertConstantUniqueness(InvalidContractException.Companion::class)
}
companion object {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/EndpointsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/EndpointsTest.kt
new file mode 100644
index 000000000000..18c14984b659
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/EndpointsTest.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+import com.ichi2.utils.FileOperation.Companion.getFileResource
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.containsInAnyOrder
+import org.json.JSONObject
+import org.junit.Test
+import timber.log.Timber
+import java.io.File
+import kotlin.test.assertEquals
+
+class EndpointsTest {
+ private val endpointsJson =
+ run {
+ val file = File(getFileResource("js-api-endpoints.json"))
+ JSONObject(file.readText())
+ }
+
+ @Test
+ fun `CURRENT_VERSION matches API version`() {
+ val apiVersion = endpointsJson.getString("version")
+ assertEquals(apiVersion, JsApi.CURRENT_VERSION)
+ }
+
+ @Test
+ fun `endpoints JSON file matches Kotlin enums`() {
+ val endpoints = endpointsJson.getJSONObject("endpoints")
+ val topLevelKeys =
+ endpoints
+ .keys()
+ .asSequence()
+ .toList()
+ .toTypedArray()
+
+ val endpointEnums =
+ Endpoint::class.sealedSubclasses.associate { kClass ->
+ val entries = kClass.java.enumConstants!!
+ entries.first().base to entries
+ }
+ assertThat(endpointEnums.keys, containsInAnyOrder(*topLevelKeys))
+
+ endpointEnums.forEach { (base, entries) ->
+ Timber.i("Verifying endpoints for: $base")
+ val jsonEndpoints =
+ endpoints
+ .getJSONObject(base)
+ .keys()
+ .asSequence()
+ .toList()
+ .toTypedArray()
+ val enumEndpoints = entries.map { it.value }
+
+ assertThat(
+ "Enum endpoints for '$base' should match the JSON keys",
+ enumEndpoints,
+ containsInAnyOrder(*jsonEndpoints),
+ )
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/JsApiTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/JsApiTest.kt
new file mode 100644
index 000000000000..f03a159eae4a
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsapi/JsApiTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.con>
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.jsapi
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.testutils.JvmTest
+import com.ichi2.utils.FileOperation.Companion.getFileResource
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.collections.iterator
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class JsApiTest : JvmTest() {
+ @Test
+ fun `Return types match the API`() =
+ runTest {
+ val file = File(getFileResource("js-api-endpoints.json"))
+ val endpointsJson = JSONObject(file.readText()).getJSONObject("endpoints")
+ val note = addBasicNote()
+ val topCard = note.cards()[0]
+
+ fun configureData(
+ data: JSONObject,
+ params: JSONObject,
+ ): JSONObject {
+ if (params.length() < 1) return data
+ params.keys().forEach { param ->
+ val paramValue =
+ when (param) {
+ "flag" -> 1
+ "search" -> "deck:current"
+ "text" -> "foo"
+ "rating" -> 1
+ "cardId" -> topCard.id
+ "colorHex" -> "#FF00FF"
+ "queueMode" -> 0
+ "locale" -> "en"
+ "pitch" -> 1F
+ "speechRate" -> 1F
+ "tags" -> "foo bar"
+ else -> throw IllegalArgumentException("Unhandled param: $param")
+ }
+ data.put(param, paramValue)
+ }
+ return data
+ }
+
+ val endpointsToSkip =
+ setOf(
+ "get-review-logs", // TODO handle the return type
+ )
+
+ for (serviceBase in endpointsJson.keys()) {
+ val serviceObject = endpointsJson.getJSONObject(serviceBase)
+
+ for (endpointString in serviceObject.keys()) {
+ if (endpointString in endpointsToSkip) continue
+ val endpoint = Endpoint.from(serviceBase, endpointString)
+ if (endpoint is Endpoint.StudyScreen || endpoint is Endpoint.Android) continue
+
+ val methodObject = serviceObject.getJSONObject(endpointString)
+ val params = methodObject.getJSONObject("params")
+ val returnType = methodObject.getString("return")
+ val data = JSONObject()
+ configureData(data, params)
+ assertNotNull(endpoint)
+
+ val result = JsApi.handleEndpointRequest(endpoint, data, topCard)
+ val resultJson = JSONObject(String(result, Charsets.UTF_8))
+ assertTrue(resultJson.getBoolean("success"))
+
+ val resultValue = resultJson.opt("value")
+ val expectedReturnType = getJsType(resultValue)
+ assertEquals(expectedReturnType, returnType, "Unexpected return type in $serviceBase/$endpointString")
+ }
+ }
+ }
+
+ private fun getJsType(value: Any?): String {
+ if (value == null) return "void"
+ return when (value::class) {
+ String::class -> "string"
+ Int::class, Long::class, Double::class -> "number"
+ Boolean::class -> "boolean"
+ JSONArray::class -> {
+ val element = (value as JSONArray).get(0)
+ val type = getJsType(element)
+ "$type[]"
+ }
+ else -> throw IllegalArgumentException("Unhandled ${value::class}")
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/resources/js-api-endpoints.json b/AnkiDroid/src/test/resources/js-api-endpoints.json
new file mode 100644
index 000000000000..ec17f60ae171
--- /dev/null
+++ b/AnkiDroid/src/test/resources/js-api-endpoints.json
@@ -0,0 +1,319 @@
+{
+ "version": "1.0.0",
+ "endpoints": {
+ "android": {
+ "show-snackbar": {
+ "params": {
+ "text": "string",
+ "duration": "number"
+ },
+ "return": "void"
+ },
+ "is-system-in-dark-mode": {
+ "params": {},
+ "return": "boolean"
+ },
+ "is-network-metered": {
+ "params": {},
+ "return": "boolean"
+ }
+ },
+ "card": {
+ "get-id": {
+ "params": {},
+ "return": "number"
+ },
+ "get-flag": {
+ "params": {},
+ "return": "number"
+ },
+ "get-reps": {
+ "params": {},
+ "return": "number"
+ },
+ "get-interval": {
+ "params": {},
+ "return": "number"
+ },
+ "get-factor": {
+ "params": {},
+ "return": "number"
+ },
+ "get-mod": {
+ "params": {},
+ "return": "number"
+ },
+ "get-nid": {
+ "params": {},
+ "return": "number"
+ },
+ "get-type": {
+ "params": {},
+ "return": "number"
+ },
+ "get-did": {
+ "params": {},
+ "return": "number"
+ },
+ "get-left": {
+ "params": {},
+ "return": "number"
+ },
+ "get-o-did": {
+ "params": {},
+ "return": "number"
+ },
+ "get-o-due": {
+ "params": {},
+ "return": "number"
+ },
+ "get-queue": {
+ "params": {},
+ "return": "number"
+ },
+ "get-lapses": {
+ "params": {},
+ "return": "number"
+ },
+ "get-question": {
+ "params": {},
+ "return": "string"
+ },
+ "get-answer": {
+ "params": {},
+ "return": "string"
+ },
+ "get-due": {
+ "params": {},
+ "return": "number"
+ },
+ "is-marked": {
+ "params": {},
+ "return": "boolean"
+ },
+ "bury": {
+ "params": {},
+ "return": "number"
+ },
+ "suspend": {
+ "params": {},
+ "return": "number"
+ },
+ "unbury": {
+ "params": {},
+ "return": "void"
+ },
+ "unsuspend": {
+ "params": {},
+ "return": "void"
+ },
+ "reset-progress": {
+ "params": {},
+ "return": "void"
+ },
+ "toggle-flag": {
+ "params": {
+ "flag": "number"
+ },
+ "return": "void"
+ },
+ "get-review-logs": {
+ "params": {},
+ "return": "ReviewLog[]"
+ }
+ },
+ "collection": {
+ "undo": {
+ "params": {},
+ "return": "string"
+ },
+ "redo": {
+ "params": {},
+ "return": "string"
+ },
+ "is-undo-available": {
+ "params": {},
+ "return": "boolean"
+ },
+ "is-redo-available": {
+ "params": {},
+ "return": "boolean"
+ },
+ "find-cards": {
+ "params": {
+ "search": "string"
+ },
+ "return": "number[]"
+ },
+ "find-notes": {
+ "params": {
+ "search": "string"
+ },
+ "return": "number[]"
+ }
+ },
+ "deck": {
+ "get-id": {
+ "params": {},
+ "return": "number"
+ },
+ "get-name": {
+ "params": {},
+ "return": "string"
+ },
+ "is-filtered": {
+ "params": {},
+ "return": "boolean"
+ }
+ },
+ "note-type": {
+ "get-id": {
+ "params": {},
+ "return": "number"
+ },
+ "get-name": {
+ "params": {},
+ "return": "string"
+ },
+ "is-image-occlusion": {
+ "params": {},
+ "return": "boolean"
+ },
+ "is-cloze": {
+ "params": {},
+ "return": "boolean"
+ },
+ "get-field-names": {
+ "params": {},
+ "return": "string[]"
+ }
+ },
+ "note": {
+ "get-id": {
+ "params": {},
+ "return": "number"
+ },
+ "get-note-type-id": {
+ "params": {},
+ "return": "number"
+ },
+ "get-card-ids": {
+ "params": {},
+ "return": "number[]"
+ },
+ "bury": {
+ "params": {},
+ "return": "number"
+ },
+ "suspend": {
+ "params": {},
+ "return": "number"
+ },
+ "get-tags": {
+ "params": {},
+ "return": "string"
+ },
+ "set-tags": {
+ "params": {
+ "tags": "string"
+ },
+ "return": "void"
+ },
+ "toggle-mark": {
+ "params": {},
+ "return": "void"
+ }
+ },
+ "study-screen": {
+ "get-new-count": {
+ "params": {},
+ "return": "number"
+ },
+ "get-learning-count": {
+ "params": {},
+ "return": "number"
+ },
+ "get-to-review-count": {
+ "params": {},
+ "return": "number"
+ },
+ "show-answer": {
+ "params": {},
+ "return": "void"
+ },
+ "is-showing-answer": {
+ "params": {},
+ "return": "boolean"
+ },
+ "get-next-time": {
+ "params": {
+ "rating": "number"
+ },
+ "return": "string"
+ },
+ "open-card-info": {
+ "params": {
+ "cardId": "number"
+ },
+ "return": "void"
+ },
+ "open-note-editor": {
+ "params": {
+ "cardId": "number"
+ },
+ "return": "void"
+ },
+ "set-background-color": {
+ "params": {
+ "colorHex": "string"
+ },
+ "return": "void"
+ },
+ "answer": {
+ "params": {
+ "rating": "number"
+ },
+ "return": "void"
+ },
+ "delete-note": {
+ "params": {},
+ "return": "void"
+ }
+ },
+ "tts": {
+ "speak": {
+ "params": {
+ "text": "string",
+ "queueMode": "number"
+ },
+ "return": "void"
+ },
+ "set-language": {
+ "params": {
+ "locale": "string"
+ },
+ "return": "number"
+ },
+ "set-pitch": {
+ "params": {
+ "pitch": "number"
+ },
+ "return": "void"
+ },
+ "set-speech-rate": {
+ "params": {
+ "speechRate": "number"
+ },
+ "return": "void"
+ },
+ "is-speaking": {
+ "params": {},
+ "return": "boolean"
+ },
+ "stop": {
+ "params": {},
+ "return": "void"
+ }
+ }
+ }
+}
diff --git a/common/src/main/java/com/ichi2/anki/common/utils/ext/JSONObject.kt b/common/src/main/java/com/ichi2/anki/common/utils/ext/JSONObject.kt
index 87cac11b64c2..d4d0c92b3c0d 100644
--- a/common/src/main/java/com/ichi2/anki/common/utils/ext/JSONObject.kt
+++ b/common/src/main/java/com/ichi2/anki/common/utils/ext/JSONObject.kt
@@ -118,6 +118,51 @@ fun JSONObject.getStringOrNull(key: String): String? {
}
}
+/**
+ * @return `null` if:
+ * * The key does not exist
+ * * The value is [null][JSONObject.NULL]
+ */
+fun JSONObject.getLongOrNull(key: String): Long? {
+ if (!has(key)) return null
+ if (isNull(key)) return null
+ return try {
+ getLong(key)
+ } catch (_: Exception) {
+ null
+ }
+}
+
+/**
+ * @return `null` if:
+ * * The key does not exist
+ * * The value is [null][JSONObject.NULL]
+ */
+fun JSONObject.getIntOrNull(key: String): Int? {
+ if (!has(key)) return null
+ if (isNull(key)) return null
+ return try {
+ getInt(key)
+ } catch (_: Exception) {
+ null
+ }
+}
+
+/**
+ * @return `null` if:
+ * * The key does not exist
+ * * The value is [null][JSONObject.NULL]
+ */
+fun JSONObject.getDoubleOrNull(key: String): Double? {
+ if (!has(key)) return null
+ if (isNull(key)) return null
+ return try {
+ getDouble(key)
+ } catch (_: Exception) {
+ null
+ }
+}
+
/**
* Returns a [Sequence] of all values in the [JSONObject], assuming each value is a [JSONObject]
*
diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt
index cf02ee62b3d7..0a4e485eaec3 100644
--- a/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt
+++ b/libanki/src/main/java/com/ichi2/anki/libanki/Decks.kt
@@ -38,6 +38,7 @@ import anki.decks.SetDeckCollapsedRequest
import anki.decks.copy
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.utils.ext.jsonObjectIterable
+import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID
import com.ichi2.anki.libanki.backend.BackendUtils
import com.ichi2.anki.libanki.backend.BackendUtils.fromJsonBytes
import com.ichi2.anki.libanki.backend.BackendUtils.toJsonBytes
@@ -177,7 +178,7 @@ class Decks(
fun getLegacy(did: DeckId): Deck? =
try {
Deck(BackendUtils.fromJsonBytes(col.backend.getDeckLegacy(did)))
- } catch (ex: BackendNotFoundException) {
+ } catch (_: BackendNotFoundException) {
null
}
@@ -289,19 +290,27 @@ class Decks(
return col.db.queryScalar("select count() from cards where did in $strIds or odid in $strIds")
}
- @RustCleanup("implement and make public")
@LibAnkiAlias("get")
@Suppress("unused", "unused_parameter")
@CheckResult
- private fun get(
+ fun get(
did: DeckId,
default: Boolean = true,
- ): Deck? =
- try {
- Deck(BackendUtils.fromJsonBytes(col.backend.getDeckLegacy(did)))
- } catch (ex: BackendNotFoundException) {
- null
+ ): Deck? {
+ if (did == 0L) {
+ return if (default) {
+ getLegacy(DEFAULT_DECK_ID)
+ } else {
+ null
+ }
}
+ val deck = getLegacy(did)
+ return when {
+ deck != null -> deck
+ default -> getLegacy(DEFAULT_DECK_ID)
+ else -> null
+ }
+ }
/** Get deck with NAME, ignoring case. */
@LibAnkiAlias("by_name")