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")