diff --git a/AnkiDroid/src/main/assets/card_template.html b/AnkiDroid/src/main/assets/card_template.html index 899e0ea77076..ea9fe4613cae 100644 --- a/AnkiDroid/src/main/assets/card_template.html +++ b/AnkiDroid/src/main/assets/card_template.html @@ -13,6 +13,7 @@ +
diff --git a/AnkiDroid/src/main/assets/scripts/card.js b/AnkiDroid/src/main/assets/scripts/card.js index 5747788a5520..86a1b0a79a2c 100644 --- a/AnkiDroid/src/main/assets/scripts/card.js +++ b/AnkiDroid/src/main/assets/scripts/card.js @@ -108,53 +108,6 @@ function reloadPage() { window.location.href = "signal:reload_card_html"; } -// Mark current card -function ankiMarkCard() { - window.location.href = "signal:mark_current_card"; -} - -/* Toggle flag on card from AnkiDroid Webview using JavaScript - Possible values: "none", "red", "orange", "green", "blue" - See AnkiDroid Manual for Usage -*/ -function ankiToggleFlag(flag) { - var flagVal = Number.isInteger(flag); - - if (flagVal) { - switch (flag) { - case 0: - window.location.href = "signal:flag_none"; - break; - case 1: - window.location.href = "signal:flag_red"; - break; - case 2: - window.location.href = "signal:flag_orange"; - break; - case 3: - window.location.href = "signal:flag_green"; - break; - case 4: - window.location.href = "signal:flag_blue"; - break; - case 5: - window.location.href = "signal:flag_pink"; - break; - case 6: - window.location.href = "signal:flag_turquoise"; - break; - case 7: - window.location.href = "signal:flag_purple"; - break; - default: - console.log("No Flag Found"); - break; - } - } else { - window.location.href = "signal:flag_" + flag; - } -} - // Show toast using js function ankiShowToast(message) { var msg = encodeURI(message); diff --git a/AnkiDroid/src/main/assets/scripts/js-api.js b/AnkiDroid/src/main/assets/scripts/js-api.js new file mode 100644 index 000000000000..780858cee55b --- /dev/null +++ b/AnkiDroid/src/main/assets/scripts/js-api.js @@ -0,0 +1,120 @@ +/* + * AnkiDroid JavaScript API + * Version: 0.0.2 + */ + +/** + * jsApiList + * + * name: method name + * value: endpoint + */ +const jsApiList = { + ankiGetNewCardCount: "newCardCount", + ankiGetLrnCardCount: "lrnCardCount", + ankiGetRevCardCount: "revCardCount", + ankiGetETA: "eta", + ankiGetCardMark: "cardMark", + ankiGetCardFlag: "cardFlag", + ankiGetNextTime1: "nextTime1", + ankiGetNextTime2: "nextTime2", + ankiGetNextTime3: "nextTime3", + ankiGetNextTime4: "nextTime4", + ankiGetCardReps: "cardReps", + ankiGetCardInterval: "cardInterval", + ankiGetCardFactor: "cardFactor", + ankiGetCardMod: "cardMod", + ankiGetCardId: "cardId", + ankiGetCardNid: "cardNid", + ankiGetCardType: "cardType", + ankiGetCardDid: "cardDid", + ankiGetCardLeft: "cardLeft", + ankiGetCardODid: "cardODid", + ankiGetCardODue: "cardODue", + ankiGetCardQueue: "cardQueue", + ankiGetCardLapses: "cardLapses", + ankiGetCardDue: "cardDue", + ankiIsInFullscreen: "isInFullscreen", + ankiIsTopbarShown: "isTopbarShown", + ankiIsInNightMode: "isInNightMode", + ankiIsDisplayingAnswer: "isDisplayingAnswer", + ankiGetDeckName: "deckName", + ankiIsActiveNetworkMetered: "isActiveNetworkMetered", + ankiTtsFieldModifierIsAvailable: "ttsFieldModifierIsAvailable", + ankiTtsIsSpeaking: "ttsIsSpeaking", + ankiTtsStop: "ttsStop", + ankiBuryCard: "buryCard", + ankiBuryNote: "buryNote", + ankiSuspendCard: "suspendCard", + ankiSuspendNote: "suspendNote", + ankiAddTagToCard: "addTagToCard", + ankiResetProgress: "resetProgress", + ankiMarkCard: "markCard", + ankiToggleFlag: "toggleFlag", + ankiSearchCard: "searchCard", + ankiSearchCardWithCallback: "searchCardWithCallback", + ankiTtsSpeak: "ttsSpeak", + ankiTtsSetLanguage: "ttsSetLanguage", + ankiTtsSetPitch: "ttsSetPitch", + ankiTtsSetSpeechRate: "ttsSetSpeechRate", + ankiEnableHorizontalScrollbar: "enableHorizontalScrollbar", + ankiEnableVerticalScrollbar: "enableVerticalScrollbar", + ankiSetCardDue: "setCardDue", +}; + +class AnkiDroidJS { + constructor({ developer, version }) { + this.developer = developer; + this.version = version; + this.handleRequest(`init`); + } + + static init({ developer, version }) { + return new AnkiDroidJS({ developer, version }); + } + + handleRequest = async (endpoint, data) => { + const url = `/jsapi/${endpoint}`; + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + developer: this.developer, + version: this.version, + data, + }), + }); + + if (!response.ok) { + throw new Error("Failed to make the request"); + } + + const responseData = await response.text(); + if (endpoint.includes("nextTime") || endpoint.includes("deckName")) { + return responseData; + } + return JSON.parse(responseData); + } catch (error) { + console.error("Request error:", error); + throw error; + } + }; +} + +Object.keys(jsApiList).forEach(method => { + if (method === "ankiTtsSpeak") { + AnkiDroidJS.prototype[method] = async function (text, queueMode = 0) { + const endpoint = jsApiList[method]; + const data = JSON.stringify({ text, queueMode }); + return await this.handleRequest(endpoint, data); + }; + return; + } + AnkiDroidJS.prototype[method] = async function (data) { + const endpoint = jsApiList[method]; + return await this.handleRequest(endpoint, data); + }; +}); diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index cf0ff0b1fc44..018df181c2da 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -1053,7 +1053,6 @@ abstract class AbstractFlashcardViewer : // Javascript interface for calling AnkiDroid functions in webview, see card.js mAnkiDroidJsAPI = javaScriptFunction() - webView.addJavascriptInterface(mAnkiDroidJsAPI!!, "AnkiDroidJS") // enable dom storage so that sessionStorage & localStorage can be used in webview webView.settings.domStorageEnabled = true @@ -1313,9 +1312,6 @@ abstract class AbstractFlashcardViewer : open fun displayCardQuestion() { displayCardQuestion(false) - - // js api initialisation / reset - mAnkiDroidJsAPI!!.init() } private fun displayCardQuestion(reload: Boolean) { @@ -2295,58 +2291,6 @@ abstract class AbstractFlashcardViewer : redrawCard() return true } - // mark card using javascript - if (url.startsWith("signal:mark_current_card")) { - if (!mAnkiDroidJsAPI!!.isInit(AnkiDroidJsAPIConstants.MARK_CARD, AnkiDroidJsAPIConstants.ankiJsErrorCodeMarkCard)) { - return true - } - executeCommand(ViewerCommand.MARK) - return true - } - // flag card (blue, green, orange, red) using javascript from AnkiDroid webview - if (url.startsWith("signal:flag_")) { - if (!mAnkiDroidJsAPI!!.isInit(AnkiDroidJsAPIConstants.TOGGLE_FLAG, AnkiDroidJsAPIConstants.ankiJsErrorCodeFlagCard)) { - return true - } - return when (url.replaceFirst("signal:flag_".toRegex(), "")) { - "none" -> { - executeCommand(ViewerCommand.UNSET_FLAG) - true - } - "red" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_RED) - true - } - "orange" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_ORANGE) - true - } - "green" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_GREEN) - true - } - "blue" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_BLUE) - true - } - "pink" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_PINK) - true - } - "turquoise" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_TURQUOISE) - true - } - "purple" -> { - executeCommand(ViewerCommand.TOGGLE_FLAG_PURPLE) - true - } - else -> { - Timber.d("No such Flag found.") - true - } - } - } // Show toast using JS if (url.startsWith("signal:anki_show_toast:")) { @@ -2555,7 +2499,7 @@ abstract class AbstractFlashcardViewer : } } - open fun javaScriptFunction(): AnkiDroidJsAPI? { + open fun javaScriptFunction(): AnkiDroidJsAPI { return AnkiDroidJsAPI(this) } @@ -2565,6 +2509,10 @@ abstract class AbstractFlashcardViewer : refreshIfRequired() } + open fun getCardDataForJsApi(): AnkiDroidJsAPI.CardDataForJsApi { + return AnkiDroidJsAPI.CardDataForJsApi() + } + companion object { /** * Result codes that are returned when this activity finishes. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt index 25f7e81ceb23..6227d7840357 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt @@ -22,75 +22,84 @@ package com.ichi2.anki import android.content.Context import android.content.Intent import android.net.Uri -import android.webkit.JavascriptInterface import com.github.zafarkhaja.semver.Version import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeBuryCard +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeBuryNote +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeError +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeFlagCard +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeMarkCard +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeSetDue +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeSuspendCard +import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeSuspendNote +import com.ichi2.anki.AnkiDroidJsAPIConstants.flagCommands +import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.model.CardsOrNotes +import com.ichi2.anki.servicelayer.rescheduleCards +import com.ichi2.anki.servicelayer.resetCards import com.ichi2.anki.snackbar.setMaxLines import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.libanki.Card -import com.ichi2.libanki.CardId -import com.ichi2.libanki.Consts.CARD_QUEUE -import com.ichi2.libanki.Consts.CARD_TYPE import com.ichi2.libanki.Decks import com.ichi2.libanki.SortOrder import com.ichi2.utils.NetworkUtils -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject import timber.log.Timber -@Suppress("unused") open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { private val currentCard: Card get() = activity.currentCard!! /** Javascript Interface class for calling Java function from AnkiDroid WebView - see card.js for available functions + see js-api.js for available functions */ private val context: Context = activity - private var cardSuppliedDeveloperContact = "" - private var cardSuppliedApiVersion = "" - - // JS api list enable/disable status - private var mJsApiListMap = AnkiDroidJsAPIConstants.initApiMap() // Text to speech private val mTalker = JavaScriptTTS() - // init or reset api list - fun init() { - cardSuppliedApiVersion = "" - cardSuppliedDeveloperContact = "" - mJsApiListMap = AnkiDroidJsAPIConstants.initApiMap() + open fun convertToByteArray(apiContract: ApiContract, boolean: Boolean): ByteArray { + return ApiResult(apiContract.isValid, boolean.toString()).toString().toByteArray() + } + + open fun convertToByteArray(apiContract: ApiContract, int: Int): ByteArray { + return ApiResult(apiContract.isValid, int.toString()).toString().toByteArray() + } + + open fun convertToByteArray(apiContract: ApiContract, long: Long): ByteArray { + return ApiResult(apiContract.isValid, long.toString()).toString().toByteArray() } - // Check if value null - private fun isAnkiApiNull(api: String): Boolean { - return mJsApiListMap[api] == null + open fun convertToByteArray(apiContract: ApiContract, string: String): ByteArray { + return ApiResult(apiContract.isValid, string).toString().toByteArray() } /** - * Before calling js api check it init or not. It requires api name its error code. - * If developer contract provided with correct js api version then it returns true - * - * - * @param apiName - * @param apiErrorCode + * The method parse json data and return api contract object + * @param byteArray + * @return apiContract or null */ - fun isInit(apiName: String, apiErrorCode: Int): Boolean { - if (isAnkiApiNull(apiName)) { - showDeveloperContact(AnkiDroidJsAPIConstants.ankiJsErrorCodeDefault) - return false - } else if (!getJsApiListMap()?.get(apiName)!!) { - // see 02-string.xml - showDeveloperContact(apiErrorCode) - return false + private fun parseJsApiContract(byteArray: ByteArray): ApiContract? { + try { + val data = JSONObject(byteArray.decodeToString()) + val cardSuppliedApiVersion = data.optString("version", "") + val cardSuppliedDeveloperContact = data.optString("developer", "") + val cardSuppliedData = data.optString("data", "") + val isValid = requireApiVersion(cardSuppliedApiVersion, cardSuppliedDeveloperContact) + return ApiContract(isValid, cardSuppliedDeveloperContact, cardSuppliedData) + } catch (j: JSONException) { + Timber.w(j) + activity.runOnUiThread { + activity.showSnackbar(context.getString(R.string.invalid_json_data, j.localizedMessage)) + } } - return true + return null } /* @@ -101,9 +110,9 @@ open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { * * show developer contact if js api used in card is deprecated */ - fun showDeveloperContact(errorCode: Int) { + private fun showDeveloperContact(errorCode: Int, apiDevContact: String) { val errorMsg: String = context.getString(R.string.anki_js_error_code, errorCode) - val snackbarMsg: String = context.getString(R.string.api_version_developer_contact, cardSuppliedDeveloperContact, errorMsg) + val snackbarMsg: String = context.getString(R.string.api_version_developer_contact, apiDevContact, errorMsg) activity.showSnackbar(snackbarMsg, Snackbar.LENGTH_INDEFINITE) { setMaxLines(3) @@ -118,7 +127,10 @@ open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { */ private fun requireApiVersion(apiVer: String, apiDevContact: String): Boolean { try { - if (apiDevContact.isEmpty()) { + if (apiDevContact.isEmpty() || apiVer.isEmpty()) { + activity.runOnUiThread { + activity.showSnackbar(context.getString(R.string.invalid_json_data, "")) + } return false } val versionCurrent = Version.valueOf(AnkiDroidJsAPIConstants.sCurrentJsApiVersion) @@ -135,13 +147,13 @@ open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { } versionSupplied.lessThan(versionCurrent) -> { activity.runOnUiThread { - activity.showSnackbar(context.getString(R.string.update_js_api_version, cardSuppliedDeveloperContact)) + activity.showSnackbar(context.getString(R.string.update_js_api_version, apiDevContact)) } versionSupplied.greaterThanOrEqualTo(Version.valueOf(AnkiDroidJsAPIConstants.sMinimumJsApiVersion)) } else -> { activity.runOnUiThread { - activity.showSnackbar(context.getString(R.string.valid_js_api_version, cardSuppliedDeveloperContact)) + activity.showSnackbar(context.getString(R.string.valid_js_api_version, apiDevContact)) } false } @@ -152,325 +164,158 @@ open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { return false } - // if supplied api version match then enable api - private fun enableJsApi() { - for (api in mJsApiListMap) { - mJsApiListMap[api.key] = true + /** + * Handle js api request, + * some of the methods are overriden in Reviewer.kt and default values are returned. + * @param methodName + * @param bytes + * @param isReviewer + * @return + */ + open suspend fun handleJsApiRequest(methodName: String, bytes: ByteArray, isReviewer: Boolean) = withContext(Dispatchers.Main) { + // the method will call to set the card supplied data and is valid version for each api request + val apiContract = parseJsApiContract(bytes)!! + // if api not init or is api not called from reviewer then return default -1 + // also other action will not be modified + if (!apiContract.isValid or !isReviewer) { + return@withContext convertToByteArray(apiContract, -1) } - } - - protected fun getJsApiListMap(): HashMap? { - return mJsApiListMap - } - @JavascriptInterface - fun init(jsonData: String): String { - val data: JSONObject - var apiStatusJson = "" - try { - data = JSONObject(jsonData) - cardSuppliedApiVersion = data.optString("version", "") - cardSuppliedDeveloperContact = data.optString("developer", "") - if (requireApiVersion(cardSuppliedApiVersion, cardSuppliedDeveloperContact)) { - enableJsApi() + val cardDataForJsAPI = activity.getCardDataForJsApi() + val apiParams = apiContract.cardSuppliedData + + return@withContext when (methodName) { + "init" -> convertToByteArray(apiContract, true) + "newCardCount" -> convertToByteArray(apiContract, cardDataForJsAPI.newCardCount) + "lrnCardCount" -> convertToByteArray(apiContract, cardDataForJsAPI.lrnCardCount) + "revCardCount" -> convertToByteArray(apiContract, cardDataForJsAPI.revCardCount) + "eta" -> convertToByteArray(apiContract, cardDataForJsAPI.eta) + "nextTime1" -> convertToByteArray(apiContract, cardDataForJsAPI.nextTime1) + "nextTime2" -> convertToByteArray(apiContract, cardDataForJsAPI.nextTime2) + "nextTime3" -> convertToByteArray(apiContract, cardDataForJsAPI.nextTime3) + "nextTime4" -> convertToByteArray(apiContract, cardDataForJsAPI.nextTime4) + "toggleFlag" -> { + if (apiParams !in flagCommands) { + showDeveloperContact(ankiJsErrorCodeFlagCard, apiContract.cardSuppliedDeveloperContact) + return@withContext convertToByteArray(apiContract, false) + } + convertToByteArray(apiContract, activity.executeCommand(flagCommands[apiParams]!!)) } - apiStatusJson = JSONObject(mJsApiListMap as Map).toString() - } catch (j: JSONException) { - Timber.w(j) - activity.runOnUiThread { - activity.showSnackbar(context.getString(R.string.invalid_json_data, j.localizedMessage)) + "markCard" -> processAction({ activity.executeCommand(ViewerCommand.MARK) }, apiContract, ankiJsErrorCodeMarkCard, ::convertToByteArray) + "buryCard" -> processAction(activity::buryCard, apiContract, ankiJsErrorCodeBuryCard, ::convertToByteArray) + "buryNote" -> processAction(activity::buryNote, apiContract, ankiJsErrorCodeBuryNote, ::convertToByteArray) + "suspendCard" -> processAction(activity::suspendCard, apiContract, ankiJsErrorCodeSuspendCard, ::convertToByteArray) + "suspendNote" -> processAction(activity::suspendNote, apiContract, ankiJsErrorCodeSuspendNote, ::convertToByteArray) + "setCardDue" -> { + try { + val days = apiParams.toInt() + if (days < 0 || days > 9999) { + showDeveloperContact(ankiJsErrorCodeSetDue, apiContract.cardSuppliedDeveloperContact) + return@withContext convertToByteArray(apiContract, false) + } + activity.launchCatchingTask { + activity.rescheduleCards(listOf(currentCard.id), days) + } + return@withContext convertToByteArray(apiContract, true) + } catch (e: NumberFormatException) { + showDeveloperContact(ankiJsErrorCodeSetDue, apiContract.cardSuppliedDeveloperContact) + return@withContext convertToByteArray(apiContract, false) + } + } + "resetProgress" -> { + val cardIds = listOf(currentCard.id) + activity.launchCatchingTask { activity.resetCards(cardIds) } + convertToByteArray(apiContract, true) + } + "cardMark" -> convertToByteArray(apiContract, currentCard.note().hasTag("marked")) + "cardFlag" -> convertToByteArray(apiContract, currentCard.userFlag()) + "cardReps" -> convertToByteArray(apiContract, currentCard.reps) + "cardInterval" -> convertToByteArray(apiContract, currentCard.ivl) + "cardFactor" -> convertToByteArray(apiContract, currentCard.factor) + "cardMod" -> convertToByteArray(apiContract, currentCard.mod) + "cardId" -> convertToByteArray(apiContract, currentCard.id) + "cardNid" -> convertToByteArray(apiContract, currentCard.nid) + "cardType" -> convertToByteArray(apiContract, currentCard.type) + "cardDid" -> convertToByteArray(apiContract, currentCard.did) + "cardLeft" -> convertToByteArray(apiContract, currentCard.left) + "cardODid" -> convertToByteArray(apiContract, currentCard.oDid) + "cardODue" -> convertToByteArray(apiContract, currentCard.oDue) + "cardQueue" -> convertToByteArray(apiContract, currentCard.queue) + "cardLapses" -> convertToByteArray(apiContract, currentCard.lapses) + "cardDue" -> convertToByteArray(apiContract, currentCard.due) + "deckName" -> convertToByteArray(apiContract, Decks.basename(activity.getColUnsafe.decks.name(currentCard.did))) + "isActiveNetworkMetered" -> convertToByteArray(apiContract, NetworkUtils.isActiveNetworkMetered()) + "ttsSetLanguage" -> convertToByteArray(apiContract, mTalker.setLanguage(apiParams)) + "ttsSpeak" -> { + val jsonObject = JSONObject(apiParams) + val text = jsonObject.getString("text") + val queueMode = jsonObject.getInt("queueMode") + convertToByteArray(apiContract, mTalker.speak(text, queueMode)) + } + "ttsIsSpeaking" -> convertToByteArray(apiContract, mTalker.isSpeaking) + "ttsSetPitch" -> convertToByteArray(apiContract, mTalker.setPitch(apiParams.toFloat())) + "ttsSetSpeechRate" -> convertToByteArray(apiContract, mTalker.setSpeechRate(apiParams.toFloat())) + "ttsFieldModifierIsAvailable" -> { + // Know if {{tts}} is supported - issue #10443 + // Return false for now + convertToByteArray(apiContract, false) + } + "ttsStop" -> convertToByteArray(apiContract, mTalker.stop()) + "searchCard" -> { + val intent = Intent(context, CardBrowser::class.java).apply { + putExtra("currentCard", currentCard.id) + putExtra("search_query", apiParams) + } + activity.startActivityWithAnimation(intent, ActivityTransitionAnimation.Direction.START) + convertToByteArray(apiContract, true) + } + "searchCardWithCallback" -> ankiSearchCardWithCallback(apiContract) + "isDisplayingAnswer" -> convertToByteArray(apiContract, activity.isDisplayingAnswer) + "addTagToCard" -> { + activity.runOnUiThread { activity.showTagsDialog() } + convertToByteArray(apiContract, true) + } + "isInFullscreen" -> convertToByteArray(apiContract, activity.isFullscreen) + "isTopbarShown" -> convertToByteArray(apiContract, activity.prefShowTopbar) + "isInNightMode" -> convertToByteArray(apiContract, activity.isInNightMode) + "enableHorizontalScrollbar" -> { + activity.webView!!.isHorizontalScrollBarEnabled = apiParams.toBoolean() + convertToByteArray(apiContract, true) + } + "enableVerticalScrollbar" -> { + activity.webView!!.isVerticalScrollBarEnabled = apiParams.toBoolean() + convertToByteArray(apiContract, true) + } + else -> { + showDeveloperContact(ankiJsErrorCodeError, apiContract.cardSuppliedDeveloperContact) + throw Exception("unhandled request: $methodName") } } - return apiStatusJson - } - - // This method and the one belows return "default" values when there is no count nor ETA. - // Javascript may expect ETA and Counts to be set, this ensure it does not bug too much by providing a value of correct type - // but with a clearly incorrect value. - // It's overridden in the Reviewer, where those values are actually defined. - @JavascriptInterface - open fun ankiGetNewCardCount(): String? { - return "-1" - } - - @JavascriptInterface - open fun ankiGetLrnCardCount(): String? { - return "-1" - } - - @JavascriptInterface - open fun ankiGetRevCardCount(): String? { - return "-1" - } - - @JavascriptInterface - open fun ankiGetETA(): Int { - return -1 - } - - @JavascriptInterface - fun ankiGetCardMark(): Boolean { - return currentCard.note().hasTag("marked") - } - - @JavascriptInterface - fun ankiGetCardFlag(): Int { - return currentCard.userFlag() - } - - // behavior change ankiGetNextTime1...4 - @JavascriptInterface - open fun ankiGetNextTime1(): String { - return activity.easeButton1!!.nextTime - } - - @JavascriptInterface - open fun ankiGetNextTime2(): String { - return activity.easeButton2!!.nextTime - } - - @JavascriptInterface - open fun ankiGetNextTime3(): String { - return activity.easeButton3!!.nextTime - } - - @JavascriptInterface - open fun ankiGetNextTime4(): String { - return activity.easeButton4!!.nextTime - } - - @JavascriptInterface - fun ankiGetCardReps(): Int { - return currentCard.reps - } - - @JavascriptInterface - fun ankiGetCardInterval(): Int { - return currentCard.ivl - } - - /** Returns the ease as an int (percentage * 10). Default: 2500 (250%). Minimum: 1300 (130%) */ - @JavascriptInterface - fun ankiGetCardFactor(): Int { - return currentCard.factor - } - - /** Returns the last modified time as a Unix timestamp in seconds. Example: 1477384099 */ - @JavascriptInterface - fun ankiGetCardMod(): Long { - return currentCard.mod - } - - /** Returns the ID of the card. Example: 1477380543053 */ - @JavascriptInterface - fun ankiGetCardId(): Long { - return currentCard.id - } - - /** Returns the ID of the note which generated the card. Example: 1590418157630 */ - @JavascriptInterface - fun ankiGetCardNid(): Long { - return currentCard.nid - } - - @JavascriptInterface - @CARD_TYPE - fun ankiGetCardType(): Int { - return currentCard.type - } - - /** Returns the ID of the deck which contains the card. Example: 1595967594978 */ - @JavascriptInterface - fun ankiGetCardDid(): Long { - return currentCard.did - } - - @JavascriptInterface - fun ankiGetCardLeft(): Int { - return currentCard.left - } - - /** Returns the ID of the home deck for the card if it is filtered, or 0 if not filtered. Example: 1595967594978 */ - @JavascriptInterface - fun ankiGetCardODid(): Long { - return currentCard.oDid - } - - @JavascriptInterface - fun ankiGetCardODue(): Long { - return currentCard.oDue - } - - @JavascriptInterface - @CARD_QUEUE - fun ankiGetCardQueue(): Int { - return currentCard.queue - } - - @JavascriptInterface - fun ankiGetCardLapses(): Int { - return currentCard.lapses - } - - @JavascriptInterface - fun ankiGetCardDue(): Long { - return currentCard.due - } - - @JavascriptInterface - fun ankiIsInFullscreen(): Boolean { - return activity.isFullscreen - } - - @JavascriptInterface - fun ankiIsTopbarShown(): Boolean { - return activity.prefShowTopbar - } - - @JavascriptInterface - fun ankiIsInNightMode(): Boolean { - return activity.isInNightMode - } - - @JavascriptInterface - fun ankiIsDisplayingAnswer(): Boolean { - return activity.isDisplayingAnswer - } - - @JavascriptInterface - fun ankiGetDeckName(): String { - return Decks.basename(activity.getColUnsafe.decks.name(currentCard.did)) - } - - @JavascriptInterface - fun ankiBuryCard(): Boolean { - if (!isInit(AnkiDroidJsAPIConstants.BURY_CARD, AnkiDroidJsAPIConstants.ankiJsErrorCodeBuryCard)) { - return false - } - - return activity.buryCard() - } - - @JavascriptInterface - fun ankiBuryNote(): Boolean { - if (!isInit(AnkiDroidJsAPIConstants.BURY_NOTE, AnkiDroidJsAPIConstants.ankiJsErrorCodeBuryNote)) { - return false - } - - return activity.buryNote() - } - - @JavascriptInterface - fun ankiSuspendCard(): Boolean { - if (!isInit(AnkiDroidJsAPIConstants.SUSPEND_CARD, AnkiDroidJsAPIConstants.ankiJsErrorCodeSuspendCard)) { - return false - } - - return activity.suspendCard() } - @JavascriptInterface - fun ankiSuspendNote(): Boolean { - if (!isInit(AnkiDroidJsAPIConstants.SUSPEND_NOTE, AnkiDroidJsAPIConstants.ankiJsErrorCodeSuspendNote)) { - return false + private fun processAction( + action: () -> Boolean, + apiContract: ApiContract, + errorCode: Int, + conversion: (ApiContract, Boolean) -> ByteArray + ): ByteArray { + val status = action() + if (!status) { + showDeveloperContact(errorCode, apiContract.cardSuppliedDeveloperContact) } - - return activity.suspendNote() + return conversion(apiContract, status) } - @JavascriptInterface - fun ankiAddTagToCard() { - activity.runOnUiThread { activity.showTagsDialog() } - } - - @JavascriptInterface - fun ankiSearchCard(query: String?) { - val intent = Intent(context, CardBrowser::class.java) - val currentCardId: CardId = currentCard.id - intent.putExtra("currentCard", currentCardId) - intent.putExtra("search_query", query) - activity.startActivityWithAnimation(intent, ActivityTransitionAnimation.Direction.START) - } - - @JavascriptInterface - fun ankiIsActiveNetworkMetered(): Boolean { - return NetworkUtils.isActiveNetworkMetered() - } - - // Know if {{tts}} is supported - issue #10443 - // Return false for now - @JavascriptInterface - fun ankiTtsFieldModifierIsAvailable(): Boolean { - return false - } - - @JavascriptInterface - fun ankiTtsSpeak(text: String?, queueMode: Int): Int { - return mTalker.speak(text, queueMode) - } - - @JavascriptInterface - fun ankiTtsSpeak(text: String?): Int { - return mTalker.speak(text) - } - - @JavascriptInterface - fun ankiTtsSetLanguage(loc: String): Int { - return mTalker.setLanguage(loc) - } - - @JavascriptInterface - fun ankiTtsSetPitch(pitch: Float): Int { - return mTalker.setPitch(pitch) - } - - @JavascriptInterface - fun ankiTtsSetPitch(pitch: Double): Int { - return mTalker.setPitch(pitch.toFloat()) - } - - @JavascriptInterface - fun ankiTtsSetSpeechRate(speechRate: Float): Int { - return mTalker.setSpeechRate(speechRate) - } - - @JavascriptInterface - fun ankiTtsSetSpeechRate(speechRate: Double): Int { - return mTalker.setSpeechRate(speechRate.toFloat()) - } - - @JavascriptInterface - fun ankiTtsIsSpeaking(): Boolean { - return mTalker.isSpeaking - } - - @JavascriptInterface - fun ankiTtsStop(): Int { - return mTalker.stop() - } - - @JavascriptInterface - fun ankiEnableHorizontalScrollbar(scroll: Boolean) { - activity.webView!!.isHorizontalScrollBarEnabled = scroll - } - - @JavascriptInterface - fun ankiEnableVerticalScrollbar(scroll: Boolean) { - activity.webView!!.isVerticalScrollBarEnabled = scroll - } - - @JavascriptInterface - fun ankiSearchCardWithCallback(query: String) { + private suspend fun ankiSearchCardWithCallback(apiContract: ApiContract): ByteArray = withContext(Dispatchers.Main) { val cards = try { - runBlocking { - searchForCards(query, SortOrder.UseCollectionOrdering(), CardsOrNotes.CARDS) - } + searchForCards(apiContract.cardSuppliedData, SortOrder.UseCollectionOrdering(), CardsOrNotes.CARDS) } catch (exc: Exception) { activity.webView!!.evaluateJavascript( "console.log('${context.getString(R.string.search_card_js_api_no_results)}')", null ) - return + showDeveloperContact(AnkiDroidJsAPIConstants.ankiJsErrorCodeSearchCard, apiContract.cardSuppliedDeveloperContact) + return@withContext convertToByteArray(apiContract, false) } val searchResult: MutableList = ArrayList() for (s in cards) { @@ -493,23 +338,32 @@ open class AnkiDroidJsAPI(private val activity: AbstractFlashcardViewer) { } // quote result to prevent JSON injection attack - val jsonEncodedString = org.json.JSONObject.quote(searchResult.toString()) + val jsonEncodedString = JSONObject.quote(searchResult.toString()) activity.runOnUiThread { activity.webView!!.evaluateJavascript("ankiSearchCard($jsonEncodedString)", null) } + convertToByteArray(apiContract, true) + } + + open class CardDataForJsApi { + var newCardCount = "" + var lrnCardCount = "" + var revCardCount = "" + var eta = -1 + var nextTime1 = "" + var nextTime2 = "" + var nextTime3 = "" + var nextTime4 = "" + } + + class ApiResult(private val status: Boolean, private val value: String) { + override fun toString(): String { + return JSONObject().apply { + put("success", status) + put("value", value) + }.toString() + } } - @JavascriptInterface - open fun ankiSetCardDue(days: Int): Boolean { - // the function is overridden in Reviewer.kt - // it may be called in previewer so just return true value here - return true - } - - @JavascriptInterface - open fun ankiResetProgress(): Boolean { - // the function is overridden in Reviewer.kt - // it may be called in previewer so just return true value here - return true - } + class ApiContract(val isValid: Boolean, val cardSuppliedDeveloperContact: String, val cardSuppliedData: String) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt index bf206890853a..1f32a17486e9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPIConstants.kt @@ -18,8 +18,11 @@ package com.ichi2.anki +import com.ichi2.anki.cardviewer.ViewerCommand + object AnkiDroidJsAPIConstants { // JS API ERROR CODE + const val ankiJsErrorCodeError: Int = -1 const val ankiJsErrorCodeDefault: Int = 0 const val ankiJsErrorCodeMarkCard: Int = 1 const val ankiJsErrorCodeFlagCard: Int = 2 @@ -29,33 +32,20 @@ object AnkiDroidJsAPIConstants { const val ankiJsErrorCodeBuryNote: Int = 5 const val ankiJsErrorCodeSuspendNote: Int = 6 const val ankiJsErrorCodeSetDue: Int = 7 + const val ankiJsErrorCodeSearchCard: Int = 8 // js api developer contact - const val sCurrentJsApiVersion = "0.0.1" - const val sMinimumJsApiVersion = "0.0.1" - - const val MARK_CARD = "markCard" - const val TOGGLE_FLAG = "toggleFlag" - - const val BURY_CARD = "buryCard" - const val BURY_NOTE = "buryNote" - const val SUSPEND_CARD = "suspendCard" - const val SUSPEND_NOTE = "suspendNote" - const val SET_CARD_DUE = "setCardDue" - const val RESET_PROGRESS = "setCardDue" - - fun initApiMap(): HashMap { - val jsApiListMap = HashMap() - jsApiListMap[MARK_CARD] = false - jsApiListMap[TOGGLE_FLAG] = false - - jsApiListMap[BURY_CARD] = false - jsApiListMap[BURY_NOTE] = false - jsApiListMap[SUSPEND_CARD] = false - jsApiListMap[SUSPEND_NOTE] = false - jsApiListMap[SET_CARD_DUE] = false - jsApiListMap[RESET_PROGRESS] = false - - return jsApiListMap - } + const val sCurrentJsApiVersion = "0.0.2" + const val sMinimumJsApiVersion = "0.0.2" + + val flagCommands = mapOf( + "none" to ViewerCommand.UNSET_FLAG, + "red" to ViewerCommand.TOGGLE_FLAG_RED, + "orange" to ViewerCommand.TOGGLE_FLAG_ORANGE, + "green" to ViewerCommand.TOGGLE_FLAG_GREEN, + "blue" to ViewerCommand.TOGGLE_FLAG_BLUE, + "pink" to ViewerCommand.TOGGLE_FLAG_PINK, + "turquoise" to ViewerCommand.TOGGLE_FLAG_TURQUOISE, + "purple" to ViewerCommand.TOGGLE_FLAG_PURPLE + ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt index 4cec789d12b3..246763190dff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt @@ -32,7 +32,6 @@ import android.os.Parcelable import android.text.SpannableString import android.text.style.UnderlineSpan import android.view.* -import android.webkit.JavascriptInterface import android.widget.* import androidx.annotation.* import androidx.appcompat.app.AlertDialog @@ -47,10 +46,6 @@ import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation import com.ichi2.anim.ActivityTransitionAnimation.getInverseTransition -import com.ichi2.anki.AnkiDroidJsAPIConstants.RESET_PROGRESS -import com.ichi2.anki.AnkiDroidJsAPIConstants.SET_CARD_DUE -import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeDefault -import com.ichi2.anki.AnkiDroidJsAPIConstants.ankiJsErrorCodeSetDue import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.Whiteboard.Companion.createInstance import com.ichi2.anki.Whiteboard.OnPaintColorChangeListener @@ -1558,83 +1553,21 @@ open class Reviewer : } override fun javaScriptFunction(): AnkiDroidJsAPI { - return ReviewerJavaScriptFunction(this) + return AnkiDroidJsAPI(this) } - inner class ReviewerJavaScriptFunction(activity: AbstractFlashcardViewer) : AnkiDroidJsAPI(activity) { - @JavascriptInterface - override fun ankiGetNewCardCount(): String { - return mNewCount.toString() - } - - @JavascriptInterface - override fun ankiGetLrnCardCount(): String { - return mLrnCount.toString() - } - - @JavascriptInterface - override fun ankiGetRevCardCount(): String { - return mRevCount.toString() - } - - @JavascriptInterface - override fun ankiGetETA(): Int { - return mEta - } - - @JavascriptInterface - override fun ankiGetNextTime1(): String { - return easeButton1!!.nextTime - } - - @JavascriptInterface - override fun ankiGetNextTime2(): String { - return easeButton2!!.nextTime - } - - @JavascriptInterface - override fun ankiGetNextTime3(): String { - return easeButton3!!.nextTime - } - - @JavascriptInterface - override fun ankiGetNextTime4(): String { - return easeButton4!!.nextTime - } - - @JavascriptInterface - override fun ankiSetCardDue(days: Int): Boolean { - val apiList = getJsApiListMap()!! - if (!apiList[SET_CARD_DUE]!!) { - showDeveloperContact(ankiJsErrorCodeDefault) - return false - } - - if (days < 0 || days > 9999) { - showDeveloperContact(ankiJsErrorCodeSetDue) - return false - } - - val cardIds = listOf(currentCard!!.id) - launchCatchingTask { - rescheduleCards(cardIds, days) - } - return true - } - - @JavascriptInterface - override fun ankiResetProgress(): Boolean { - val apiList = getJsApiListMap()!! - if (!apiList[RESET_PROGRESS]!!) { - showDeveloperContact(ankiJsErrorCodeDefault) - return false - } - val cardIds = listOf(currentCard!!.id) - launchCatchingTask { - resetCards(cardIds) - } - return true + override fun getCardDataForJsApi(): AnkiDroidJsAPI.CardDataForJsApi { + val cardDataForJsAPI = AnkiDroidJsAPI.CardDataForJsApi().apply { + newCardCount = mNewCount.toString() + lrnCardCount = mLrnCount.toString() + revCardCount = mRevCount.toString() + nextTime1 = easeButton1!!.nextTime + nextTime2 = easeButton2!!.nextTime + nextTime3 = easeButton3!!.nextTime + nextTime4 = easeButton4!!.nextTime + eta = mEta } + return cardDataForJsAPI } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ReviewerServer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ReviewerServer.kt index 9e4972bfc4dd..509a961375b6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ReviewerServer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ReviewerServer.kt @@ -26,6 +26,11 @@ import java.io.FileInputStream class ReviewerServer(activity: FragmentActivity, val mediaDir: String) : AnkiServer(activity) { var reviewerHtml: String = "" + private val jsApi = if (activity is Reviewer) { + reviewer().javaScriptFunction() + } else { + cardTemplatePreviewer().javaScriptFunction() + } override fun start() { super.start() @@ -72,6 +77,11 @@ class ReviewerServer(activity: FragmentActivity, val mediaDir: String) : AnkiSer handlePostRequest(uri.substring(ANKI_PREFIX.length), inputBytes) } } + if (uri.startsWith(ANKIDROID_JS_PREFIX)) { + return buildResponse { + jsApi.handleJsApiRequest(uri.substring(ANKIDROID_JS_PREFIX.length), inputBytes, activity is Reviewer) + } + } } Timber.w("not found: $uri") @@ -92,6 +102,10 @@ class ReviewerServer(activity: FragmentActivity, val mediaDir: String) : AnkiSer return (activity as Reviewer) } + private fun cardTemplatePreviewer(): CardTemplatePreviewer { + return (activity as CardTemplatePreviewer) + } + private fun getSchedulingStatesWithContext(): ByteArray { val state = reviewer().queueState ?: return ByteArray(0) return state.schedulingStatesWithContext().toByteArray() 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 2612f7dadac5..7893e76f385b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt @@ -120,6 +120,7 @@ open class AnkiServer( companion object { /** Common prefix used on Anki requests */ const val ANKI_PREFIX = "/_anki/" + const val ANKIDROID_JS_PREFIX = "/jsapi/" fun getMimeFromUri(uri: String): String { return when (uri.substringAfterLast(".")) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt index d4506dc4f32b..772d9faacc99 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt @@ -26,7 +26,7 @@ import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.json.JSONObject import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -34,7 +34,7 @@ import org.junit.runner.RunWith class AnkiDroidJsAPITest : RobolectricTest() { @Test - fun initTest() { + fun ankiGetNextTimeTest() = runTest { val models = col.notetypes val decks = col.decks val didA = addDeck("Test") @@ -44,45 +44,32 @@ class AnkiDroidJsAPITest : RobolectricTest() { decks.select(didA) val reviewer: Reviewer = startReviewer() - val javaScriptFunction = reviewer.javaScriptFunction() - - val data = JSONObject() - data.put("version", "0.0.1") - data.put("developer", "dev@mail.com") - - // this will be changed when new api added - // TODO - make this test to auto add api from list - val expected = "{\"setCardDue\":true,\"suspendNote\":true,\"markCard\":true,\"suspendCard\":true,\"buryCard\":true,\"toggleFlag\":true,\"buryNote\":true}" - - waitForAsyncTasksToComplete() - assertThat(javaScriptFunction.init(data.toString()), equalTo(expected)) - } - - @Test - fun ankiGetNextTimeTest() { - val models = col.notetypes - val decks = col.decks - val didA = addDeck("Test") - val basic = models.byName(AnkiDroidApp.appResources.getString(R.string.basic_model_name)) - basic!!.put("did", didA) - addNoteUsingBasicModel("foo", "bar") - decks.select(didA) - - val reviewer: Reviewer = startReviewer() - val javaScriptFunction = reviewer.javaScriptFunction() + val jsapi = reviewer.javaScriptFunction() reviewer.displayCardAnswer() waitForAsyncTasksToComplete() - assertThat(javaScriptFunction.ankiGetNextTime1().withoutUnicodeIsolation(), equalTo("<1m")) - assertThat(javaScriptFunction.ankiGetNextTime2().withoutUnicodeIsolation(), equalTo("<6m")) - assertThat(javaScriptFunction.ankiGetNextTime3().withoutUnicodeIsolation(), equalTo("<10m")) - assertThat(javaScriptFunction.ankiGetNextTime4().withoutUnicodeIsolation(), equalTo("4d")) + assertThat( + getDataFromRequest("nextTime1", jsapi).withoutUnicodeIsolation(), + equalTo(formatApiResult("<1m")) + ) + assertThat( + getDataFromRequest("nextTime2", jsapi).withoutUnicodeIsolation(), + equalTo(formatApiResult("<6m")) + ) + assertThat( + getDataFromRequest("nextTime3", jsapi).withoutUnicodeIsolation(), + equalTo(formatApiResult("<10m")) + ) + assertThat( + getDataFromRequest("nextTime4", jsapi).withoutUnicodeIsolation(), + equalTo(formatApiResult("4d")) + ) } @Test - fun ankiTestCurrentCard() { + fun ankiTestCurrentCard() = runTest { val models = col.notetypes val decks = col.decks val didA = addDeck("Test") @@ -92,7 +79,7 @@ class AnkiDroidJsAPITest : RobolectricTest() { decks.select(didA) val reviewer: Reviewer = startReviewer() - val javaScriptFunction = reviewer.javaScriptFunction() + val jsapi = reviewer.javaScriptFunction() reviewer.displayCardAnswer() waitForAsyncTasksToComplete() @@ -100,47 +87,101 @@ class AnkiDroidJsAPITest : RobolectricTest() { val currentCard = reviewer.currentCard!! // Card Did - assertThat(javaScriptFunction.ankiGetCardDid(), equalTo(currentCard.did)) + assertThat( + getDataFromRequest("cardDid", jsapi), + equalTo(formatApiResult(currentCard.did)) + ) // Card Id - assertThat(javaScriptFunction.ankiGetCardId(), equalTo(currentCard.id)) + assertThat( + getDataFromRequest("cardId", jsapi), + equalTo(formatApiResult(currentCard.id)) + ) // Card Nid - assertThat(javaScriptFunction.ankiGetCardNid(), equalTo(currentCard.nid)) + assertThat( + getDataFromRequest("cardNid", jsapi), + equalTo(formatApiResult(currentCard.nid)) + ) // Card ODid - assertThat(javaScriptFunction.ankiGetCardODid(), equalTo(currentCard.oDid)) + assertThat( + getDataFromRequest("cardODid", jsapi), + equalTo(formatApiResult(currentCard.oDid)) + ) // Card Type - assertThat(javaScriptFunction.ankiGetCardType(), equalTo(currentCard.type)) + assertThat( + getDataFromRequest("cardType", jsapi), + equalTo(formatApiResult(currentCard.type)) + ) // Card ODue - assertThat(javaScriptFunction.ankiGetCardODue(), equalTo(currentCard.oDue)) + assertThat( + getDataFromRequest("cardODue", jsapi), + equalTo(formatApiResult(currentCard.oDue)) + ) // Card Due - assertThat(javaScriptFunction.ankiGetCardDue(), equalTo(currentCard.due)) + assertThat( + getDataFromRequest("cardDue", jsapi), + equalTo(formatApiResult(currentCard.due)) + ) // Card Factor - assertThat(javaScriptFunction.ankiGetCardFactor(), equalTo(currentCard.factor)) + assertThat( + getDataFromRequest("cardFactor", jsapi), + equalTo(formatApiResult(currentCard.factor)) + ) // Card Lapses - assertThat(javaScriptFunction.ankiGetCardLapses(), equalTo(currentCard.lapses)) + assertThat( + getDataFromRequest("cardLapses", jsapi), + equalTo(formatApiResult(currentCard.lapses)) + ) // Card Ivl - assertThat(javaScriptFunction.ankiGetCardInterval(), equalTo(currentCard.ivl)) + assertThat( + getDataFromRequest("cardInterval", jsapi), + equalTo(formatApiResult(currentCard.ivl)) + ) // Card mod - assertThat(javaScriptFunction.ankiGetCardMod(), equalTo(currentCard.mod)) + assertThat( + getDataFromRequest("cardMod", jsapi), + equalTo(formatApiResult(currentCard.mod)) + ) // Card Queue - assertThat(javaScriptFunction.ankiGetCardQueue(), equalTo(currentCard.queue)) + assertThat( + getDataFromRequest("cardQueue", jsapi), + equalTo(formatApiResult(currentCard.queue)) + ) // Card Reps - assertThat(javaScriptFunction.ankiGetCardReps(), equalTo(currentCard.reps)) + assertThat( + getDataFromRequest("cardReps", jsapi), + equalTo(formatApiResult(currentCard.reps)) + ) // Card left - assertThat(javaScriptFunction.ankiGetCardLeft(), equalTo(currentCard.left)) + assertThat( + getDataFromRequest("cardLeft", jsapi), + equalTo(formatApiResult(currentCard.left)) + ) // Card Flag - assertThat(javaScriptFunction.ankiGetCardFlag(), equalTo(0)) + assertThat( + getDataFromRequest("cardFlag", jsapi), + equalTo(formatApiResult(0)) + ) reviewer.currentCard!!.setFlag(1) - assertThat(javaScriptFunction.ankiGetCardFlag(), equalTo(1)) + assertThat( + getDataFromRequest("cardFlag", jsapi), + equalTo(formatApiResult(1)) + ) // Card Mark - assertThat(javaScriptFunction.ankiGetCardMark(), equalTo(false)) + assertThat( + getDataFromRequest("cardMark", jsapi), + equalTo(formatApiResult(false)) + ) reviewer.currentCard!!.note().addTag("marked") - assertThat(javaScriptFunction.ankiGetCardMark(), equalTo(true)) + assertThat( + getDataFromRequest("cardMark", jsapi), + equalTo(formatApiResult(true)) + ) } @Test - fun ankiJsUiTest() { + fun ankiJsUiTest() = runTest { val models = col.notetypes val decks = col.decks val didA = addDeck("Test") @@ -150,25 +191,40 @@ class AnkiDroidJsAPITest : RobolectricTest() { decks.select(didA) val reviewer: Reviewer = startReviewer() - val javaScriptFunction = reviewer.javaScriptFunction() + val jsapi = reviewer.javaScriptFunction() waitForAsyncTasksToComplete() // Displaying question - assertThat(javaScriptFunction.ankiIsDisplayingAnswer(), equalTo(reviewer.isDisplayingAnswer)) + assertThat( + getDataFromRequest("isDisplayingAnswer", jsapi), + equalTo(formatApiResult(reviewer.isDisplayingAnswer)) + ) reviewer.displayCardAnswer() - assertThat(javaScriptFunction.ankiIsDisplayingAnswer(), equalTo(reviewer.isDisplayingAnswer)) + assertThat( + getDataFromRequest("isDisplayingAnswer", jsapi), + equalTo(formatApiResult(reviewer.isDisplayingAnswer)) + ) // Full Screen - assertThat(javaScriptFunction.ankiIsInFullscreen(), equalTo(reviewer.isFullscreen)) + assertThat( + getDataFromRequest("isInFullscreen", jsapi), + equalTo(formatApiResult(reviewer.isFullscreen)) + ) // Top bar - assertThat(javaScriptFunction.ankiIsTopbarShown(), equalTo(reviewer.prefShowTopbar)) + assertThat( + getDataFromRequest("isTopbarShown", jsapi), + equalTo(formatApiResult(reviewer.prefShowTopbar)) + ) // Night Mode - assertThat(javaScriptFunction.ankiIsInNightMode(), equalTo(reviewer.isInNightMode)) + assertThat( + getDataFromRequest("isInNightMode", jsapi), + equalTo(formatApiResult(reviewer.isInNightMode)) + ) } @Test - fun ankiMarkAndFlagCardTest() { + fun ankiMarkAndFlagCardTest() = runTest { // js api test for marking and flagging card val models = col.notetypes val decks = col.decks @@ -179,7 +235,7 @@ class AnkiDroidJsAPITest : RobolectricTest() { decks.select(didA) val reviewer: Reviewer = startReviewer() - val javaScriptFunction = reviewer.javaScriptFunction() + val jsapi = reviewer.javaScriptFunction() waitForAsyncTasksToComplete() @@ -187,52 +243,47 @@ class AnkiDroidJsAPITest : RobolectricTest() { // Card mark test // --------------- // Before marking card - assertThat(javaScriptFunction.ankiGetCardMark(), equalTo(false)) - - // call javascript function defined in card.js to mark card - var markCardJs = "javascript:(function () {\n" - - // add js api developer contract - markCardJs += "var jsApi = {\"version\" : \"0.0.1\", \"developer\" : \"dev@mail.com\"};\n" - - // init JS API - markCardJs += "AnkiDroidJS.init(JSON.stringify(jsApi));\n" - - // call function defined in card.js to mark card - markCardJs += "ankiMarkCard();\n" - - // get card mark status for test - markCardJs += "AnkiDroidJS.ankiGetCardMark();\n" + - "})();" - - reviewer.webView!!.evaluateJavascript(markCardJs) { s -> assertThat(s, equalTo(true)) } + assertThat( + getDataFromRequest("cardMark", jsapi), + equalTo(formatApiResult(false)) + ) + + // Mark card + assertThat( + getDataFromRequest("markCard", jsapi, "true"), + equalTo(formatApiResult(true)) + ) + + // After marking card + assertThat( + getDataFromRequest("cardMark", jsapi), + equalTo(formatApiResult(true)) + ) // --------------- // Card flag test // --------------- // before toggling flag - assertThat(javaScriptFunction.ankiGetCardFlag(), equalTo(0)) - - // call javascript function defined in card.js to toggle flag - var flagCardJs = "javascript:(function () {\n" - - // add js api developer contract - flagCardJs += "var jsApi = {\"version\" : \"0.0.1\", \"developer\" : \"test@example.com\"};\n" - - // init JS API - flagCardJs += "AnkiDroidJS.init(JSON.stringify(jsApi));\n" - - // call function defined in card.js to flag card to red - flagCardJs += "ankiToggleFlag(\"red\");\n" - - // get flag status for test - flagCardJs += "AnkiDroidJS.ankiGetCardFlag();\n" + - "})();" - - reviewer.webView!!.evaluateJavascript(flagCardJs) { s -> assertThat(s, equalTo(1)) } + assertThat( + getDataFromRequest("cardFlag", jsapi), + equalTo(formatApiResult(0)) + ) + + // call javascript function to toggle flag + assertThat( + getDataFromRequest("toggleFlag", jsapi, "red"), + equalTo(formatApiResult(true)) + ) + + // after toggling flag + assertThat( + getDataFromRequest("cardFlag", jsapi), + equalTo(formatApiResult(1)) + ) } - fun ankiBurySuspendTest() { + @Ignore("the test need to be updated") + fun ankiBurySuspendTest() = runTest { // js api test for bury and suspend notes and cards // add five notes, four will be buried and suspended // count number of notes, if buried or suspended then @@ -250,15 +301,16 @@ class AnkiDroidJsAPITest : RobolectricTest() { decks.select(didA) val reviewer: Reviewer = startReviewer() - - waitForAsyncTasksToComplete() + val jsapi = reviewer.javaScriptFunction() // ---------- // Bury Card // ---------- - var jsScript = createTestScript("AnkiDroidJS.ankiBuryCard();") // call script to bury current card - reviewer.webView!!.evaluateJavascript(jsScript) { s -> assertThat(s, equalTo(true)) } + assertThat( + getDataFromRequest("buryCard", jsapi), + equalTo(formatApiResult(true)) + ) // count number of notes val sched = reviewer.getColUnsafe @@ -267,9 +319,11 @@ class AnkiDroidJsAPITest : RobolectricTest() { // ---------- // Bury Note // ---------- - jsScript = createTestScript("AnkiDroidJS.ankiBuryNote();") // call script to bury current note - reviewer.webView!!.evaluateJavascript(jsScript) { s -> assertThat(s, equalTo(true)) } + assertThat( + getDataFromRequest("buryNote", jsapi), + equalTo(formatApiResult(true)) + ) // count number of notes assertThat(sched.cardCount(), equalTo(3)) @@ -277,9 +331,11 @@ class AnkiDroidJsAPITest : RobolectricTest() { // ------------- // Suspend Card // ------------- - jsScript = createTestScript("AnkiDroidJS.ankiSuspendCard();") // call script to suspend current card - reviewer.webView!!.evaluateJavascript(jsScript) { s -> assertThat(s, equalTo(true)) } + assertThat( + getDataFromRequest("suspendCard", jsapi), + equalTo(formatApiResult(true)) + ) // count number of notes assertThat(sched.cardCount(), equalTo(2)) @@ -287,30 +343,16 @@ class AnkiDroidJsAPITest : RobolectricTest() { // ------------- // Suspend Note // ------------- - jsScript = createTestScript("AnkiDroidJS.ankiSuspendNote();") // call script to suspend current note - reviewer.webView!!.evaluateJavascript(jsScript) { s -> assertThat(s, equalTo(true)) } + assertThat( + getDataFromRequest("suspendNote", jsapi), + equalTo(formatApiResult(true)) + ) // count number of notes assertThat(sched.cardCount(), equalTo(1)) } - private fun createTestScript(apiName: String): String { - // create js script for evaluating in webview - var script = "javascript:(function () {\n" - - // add js api developer contract - script += "var jsApi = {\"version\" : \"0.0.1\", \"developer\" : \"test@example.com\"};\n" - - // init JS API - script += "AnkiDroidJS.init(JSON.stringify(jsApi));\n" - - // call js api - script += "$apiName\n})();" - - return script - } - private fun startReviewer(): Reviewer { return ReviewerTest.startReviewer(this) } @@ -330,27 +372,20 @@ class AnkiDroidJsAPITest : RobolectricTest() { val reviewer: Reviewer = startReviewer() waitForAsyncTasksToComplete() - val javaScriptFunction = reviewer.javaScriptFunction() - // init js api - javaScriptFunction.init(initJsApiContract()) + val jsapi = reviewer.javaScriptFunction() // get card id for testing due - val cardId = javaScriptFunction.ankiGetCardId() + val cardIdRes = getDataFromRequest("cardId", jsapi) + val jsonObject = JSONObject(cardIdRes) + val cardId = jsonObject.get("value").toString().toLong() // test that card rescheduled for 15 days interval and returned true - assertTrue("Card rescheduled, so returns true", javaScriptFunction.ankiSetCardDue(15)) + assertThat(getDataFromRequest("setCardDue", jsapi, "15"), equalTo(formatApiResult(true))) waitForAsyncTasksToComplete() // verify that it did get rescheduled // -------------------------------- - val cardAfterRescheduleCards = col.getCard(cardId) - assertEquals("Card is rescheduled", 15L + col.sched.today, cardAfterRescheduleCards.due) - } - - private fun initJsApiContract(): String { - val data = JSONObject() - data.put("version", "0.0.1") - data.put("developer", "test@example.com") - return data.toString() + val cardToBeReschedule = col.getCard(cardId) + assertEquals("Card is rescheduled", 15L + col.sched.today, cardToBeReschedule.due) } @Test @@ -373,21 +408,40 @@ class AnkiDroidJsAPITest : RobolectricTest() { val reviewer: Reviewer = startReviewer() waitForAsyncTasksToComplete() - val javaScriptFunction = reviewer.javaScriptFunction() - // init js api - javaScriptFunction.init(initJsApiContract()) - // get card id for testing due - val cardId = javaScriptFunction.ankiGetCardId() + val jsapi = reviewer.javaScriptFunction() // test that card reset - assertTrue("Card progress reset", javaScriptFunction.ankiResetProgress()) + assertThat(getDataFromRequest("resetProgress", jsapi), equalTo(formatApiResult(true))) waitForAsyncTasksToComplete() // verify that card progress reset // -------------------------------- - val cardAfterReset = col.getCard(cardId) + val cardAfterReset = col.getCard(reviewer.currentCard!!.id) assertEquals("Card due after reset", 2, cardAfterReset.due) assertEquals("Card interval after reset", 0, cardAfterReset.ivl) assertEquals("Card type after reset", Consts.CARD_TYPE_NEW, cardAfterReset.type) } + + companion object { + fun jsApiContract(data: String = ""): ByteArray { + return JSONObject().apply { + put("version", "0.0.2") + put("developer", "test@example.com") + put("data", data) + }.toString().toByteArray() + } + + fun formatApiResult(res: Any): String { + return "{\"success\":true,\"value\":\"$res\"}" + } + + suspend fun getDataFromRequest( + methodName: String, + jsAPI: AnkiDroidJsAPI, + apiData: String = "" + ): String { + return jsAPI.handleJsApiRequest(methodName, jsApiContract(apiData), true) + .decodeToString() + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt index e468b678e33a..f75a98fff82a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt @@ -21,6 +21,9 @@ import androidx.core.content.edit import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.AbstractFlashcardViewer.Companion.RESULT_DEFAULT +import com.ichi2.anki.AnkiDroidJsAPITest.Companion.formatApiResult +import com.ichi2.anki.AnkiDroidJsAPITest.Companion.getDataFromRequest +import com.ichi2.anki.AnkiDroidJsAPITest.Companion.jsApiContract import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.cardviewer.ViewerCommand.FLIP_OR_ANSWER_EASE1 import com.ichi2.anki.cardviewer.ViewerCommand.MARK @@ -222,7 +225,7 @@ class ReviewerTest : RobolectricTest() { } @Test - fun jsAnkiGetDeckName() { + fun jsAnkiGetDeckName() = runTest { val models = col.notetypes val decks = col.decks @@ -238,7 +241,11 @@ class ReviewerTest : RobolectricTest() { val javaScriptFunction = reviewer.javaScriptFunction() waitForAsyncTasksToComplete() - assertThat(javaScriptFunction.ankiGetDeckName(), equalTo("B")) + assertThat( + javaScriptFunction.handleJsApiRequest("deckName", jsApiContract(), true) + .decodeToString(), + equalTo(formatApiResult("B")) + ) } @Ignore("needs update for v3") @@ -324,20 +331,18 @@ class ReviewerTest : RobolectricTest() { } @Suppress("SameParameterValue") - private fun assertCounts(r: Reviewer, newCount: Int, stepCount: Int, revCount: Int) { + private fun assertCounts(r: Reviewer, newCount: Int, stepCount: Int, revCount: Int) = runTest { val jsApi = r.javaScriptFunction() val countList = listOf( - jsApi.ankiGetNewCardCount(), - jsApi.ankiGetLrnCardCount(), - jsApi.ankiGetRevCardCount() + getDataFromRequest("newCardCount", jsApi), + getDataFromRequest("lrnCardCount", jsApi), + getDataFromRequest("revCardCount", jsApi) ) - val expected = listOf( - newCount, - stepCount, - revCount + formatApiResult(newCount), + formatApiResult(stepCount), + formatApiResult(revCount) ) - assertThat( countList.toString(), equalTo(expected.toString()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt index 660c567b6ab1..744e2fbb324e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/jsaddons/AddonModelTest.kt @@ -68,7 +68,7 @@ class AddonModelTest : RobolectricTest() { assertEquals(addon.name, "valid-ankidroid-js-addon-test") assertEquals(addon.addonTitle, "Valid AnkiDroid JS Addon") assertEquals(addon.version, "1.0.0") - assertEquals(addon.ankidroidJsApi, "0.0.1") + assertEquals(addon.ankidroidJsApi, "0.0.2") assertEquals(addon.addonType, "reviewer") assertEquals(addon.icon, "") // reviewer icon is empty diff --git a/AnkiDroid/src/test/resources/test-js-addon.json b/AnkiDroid/src/test/resources/test-js-addon.json index 271d3f56e10f..8aaa6376c777 100644 --- a/AnkiDroid/src/test/resources/test-js-addon.json +++ b/AnkiDroid/src/test/resources/test-js-addon.json @@ -5,7 +5,7 @@ "version": "1.1.1", "description": "Show progress bar in AnkiDroid, this package may not be used in node_modules. For using this addon view. https://github.com/ankidroid/Anki-Android/pull/9232", "main": "index.js", - "ankidroidJsApi": "0.0.1", + "ankidroidJsApi": "0.0.2", "addonType": "reviewer", "keywords": [ "ankidroid-js-addon" @@ -31,7 +31,7 @@ "version": "1.0.1", "description": "This addon will listed in Addon Browser. Also AddonInfo.isValidAnkiDroidAddon return true for this package. For more view. https://github.com/ankidroid/Anki-Android/pull/9232", "main": "index.js", - "ankidroidJsApi": "0.0.1", + "ankidroidJsApi": "0.0.2", "addonType": "reviewer", "keywords": [ "ankidroid-js-addon" diff --git a/AnkiDroid/src/test/resources/valid-ankidroid-js-addon-test.json b/AnkiDroid/src/test/resources/valid-ankidroid-js-addon-test.json index 91505cc75e8b..b1b315d8dceb 100644 --- a/AnkiDroid/src/test/resources/valid-ankidroid-js-addon-test.json +++ b/AnkiDroid/src/test/resources/valid-ankidroid-js-addon-test.json @@ -2,7 +2,7 @@ "name": "valid-ankidroid-js-addon-test", "addonTitle": "Valid AnkiDroid JS Addon", "version": "1.0.0", - "ankidroidJsApi": "0.0.1", + "ankidroidJsApi": "0.0.2", "addonType": "reviewer", "description": "This addon will listed in Addon Browser. Also AddonInfo.isValidAnkiDroidAddon return true for this package. For more view. https://github.com/ankidroid/Anki-Android/pull/9232", "main": "index.js",