Skip to content

Commit cfa8dfd

Browse files
committed
feat: JavaScript API 1.0.0
works on the new study screen and the previewers
1 parent 21db411 commit cfa8dfd

File tree

10 files changed

+273
-11
lines changed

10 files changed

+273
-11
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js
12
AnkiDroid/build/

AnkiDroid/src/main/assets/scripts/ankidroid-js-api.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class MediaErrorHandler {
3333
private var hasExecuted = false
3434

3535
private var automaticTtsFailureCount = 0
36+
private var hasShownInvalidContractMessage = false
3637

3738
fun processFailure(
3839
request: WebResourceRequest,
@@ -103,4 +104,10 @@ class MediaErrorHandler {
103104

104105
errorHandler.invoke(error)
105106
}
107+
108+
fun shouldShowJsApiExceptionMessage(): Boolean {
109+
if (hasShownInvalidContractMessage) return false
110+
hasShownInvalidContractMessage = true
111+
return true
112+
}
106113
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright (c) 2025 Brayan Oliveira <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.jsapi
17+
18+
import kotlinx.coroutines.CompletableDeferred
19+
import org.json.JSONObject
20+
21+
data class UiRequest(
22+
val endpoint: Endpoint,
23+
val data: JSONObject?,
24+
val result: CompletableDeferred<ByteArray>,
25+
)

AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,23 @@ import androidx.lifecycle.flowWithLifecycle
3838
import androidx.lifecycle.lifecycleScope
3939
import com.ichi2.anki.R
4040
import com.ichi2.anki.ViewerResourceHandler
41+
import com.ichi2.anki.common.utils.ext.getIntOrNull
4142
import com.ichi2.anki.dialogs.TtsVoicesDialogFragment
43+
import com.ichi2.anki.jsapi.Endpoint
44+
import com.ichi2.anki.jsapi.JsApi
45+
import com.ichi2.anki.jsapi.UiRequest
4246
import com.ichi2.anki.localizedErrorMessage
4347
import com.ichi2.anki.snackbar.showSnackbar
4448
import com.ichi2.anki.utils.ext.collectIn
4549
import com.ichi2.anki.utils.ext.packageManager
4650
import com.ichi2.anki.utils.openUrl
4751
import com.ichi2.compat.CompatHelper.Companion.resolveActivityCompat
4852
import com.ichi2.themes.Themes
53+
import com.ichi2.utils.NetworkUtils
4954
import com.ichi2.utils.show
5055
import kotlinx.coroutines.flow.launchIn
5156
import kotlinx.coroutines.flow.onEach
57+
import org.json.JSONObject
5258
import timber.log.Timber
5359

5460
abstract class CardViewerFragment(
@@ -64,6 +70,7 @@ abstract class CardViewerFragment(
6470
) {
6571
setupWebView(savedInstanceState)
6672
setupErrorListeners()
73+
setupJsApi()
6774
}
6875

6976
override fun onStart() {
@@ -132,6 +139,46 @@ abstract class CardViewerFragment(
132139
viewModel.onTtsError
133140
.onEach { showSnackbar(it.localizedErrorMessage(requireContext())) }
134141
.launchIn(lifecycleScope)
142+
143+
viewModel.onJsApiError.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { error ->
144+
val errorMessage = error.localizedErrorMessage(resources)
145+
AlertDialog
146+
.Builder(requireContext())
147+
.setTitle(R.string.vague_error)
148+
.setMessage(errorMessage)
149+
.show()
150+
}
151+
}
152+
153+
private fun setupJsApi() {
154+
viewModel.apiRequestFlow.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { request ->
155+
val result = handleJsUiRequest(request)
156+
request.result.complete(result)
157+
}
158+
}
159+
160+
protected open fun handleJsUiRequest(request: UiRequest): ByteArray =
161+
if (request.endpoint is Endpoint.Android) {
162+
handleAndroidEndpoint(request.endpoint, request.data)
163+
} else {
164+
JsApi.fail("Unhandled endpoint")
165+
}
166+
167+
private fun handleAndroidEndpoint(
168+
endpoint: Endpoint.Android,
169+
data: JSONObject?,
170+
): ByteArray {
171+
return when (endpoint) {
172+
Endpoint.Android.SHOW_SNACKBAR -> {
173+
val data = data ?: return JsApi.fail("Missing request data")
174+
val text = data.optString("text") ?: return JsApi.fail("Missing text")
175+
val duration = data.getIntOrNull("duration") ?: return JsApi.fail("Missing duration")
176+
showSnackbar(text, duration)
177+
JsApi.success()
178+
}
179+
Endpoint.Android.IS_SYSTEM_IN_DARK_MODE -> JsApi.success(Themes.systemIsInNightMode(requireContext()))
180+
Endpoint.Android.IS_NETWORK_METERED -> JsApi.success(NetworkUtils.isActiveNetworkMetered())
181+
}
135182
}
136183

137184
protected open fun onCreateWebViewClient(savedInstanceState: Bundle?): WebViewClient = CardViewerWebViewClient(savedInstanceState)

AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,26 @@ import com.ichi2.anki.cardviewer.CardMediaPlayer
2727
import com.ichi2.anki.cardviewer.MediaErrorBehavior
2828
import com.ichi2.anki.cardviewer.MediaErrorHandler
2929
import com.ichi2.anki.cardviewer.MediaErrorListener
30+
import com.ichi2.anki.jsapi.Endpoint
31+
import com.ichi2.anki.jsapi.InvalidContractException
32+
import com.ichi2.anki.jsapi.JsApi
33+
import com.ichi2.anki.jsapi.JsApi.getEndpoint
34+
import com.ichi2.anki.jsapi.UiRequest
3035
import com.ichi2.anki.launchCatchingIO
3136
import com.ichi2.anki.libanki.Card
3237
import com.ichi2.anki.libanki.TtsPlayer
3338
import com.ichi2.anki.multimedia.getAvTag
3439
import com.ichi2.anki.multimedia.replaceAvRefsWithPlayButtons
3540
import com.ichi2.anki.pages.AnkiServer
3641
import com.ichi2.anki.pages.PostRequestHandler
42+
import kotlinx.coroutines.CompletableDeferred
3743
import kotlinx.coroutines.Deferred
3844
import kotlinx.coroutines.flow.MutableSharedFlow
3945
import kotlinx.coroutines.flow.MutableStateFlow
4046
import kotlinx.coroutines.launch
47+
import kotlinx.coroutines.withTimeoutOrNull
4148
import kotlinx.serialization.json.Json
49+
import org.json.JSONObject
4250
import timber.log.Timber
4351

4452
abstract class CardViewerViewModel :
@@ -48,9 +56,11 @@ abstract class CardViewerViewModel :
4856
override val onError = MutableSharedFlow<String>()
4957
val onMediaError = MutableSharedFlow<String>()
5058
val onTtsError = MutableSharedFlow<TtsPlayer.TtsError>()
59+
val onJsApiError = MutableSharedFlow<InvalidContractException>()
5160
val mediaErrorHandler = MediaErrorHandler()
5261

5362
val eval = MutableSharedFlow<String>()
63+
val apiRequestFlow = MutableSharedFlow<UiRequest>()
5464

5565
open val showingAnswer = MutableStateFlow(false)
5666

@@ -200,7 +210,44 @@ abstract class CardViewerViewModel :
200210
"i18nResources" -> withCol { i18nResourcesRaw(bytes) }
201211
else -> throw IllegalArgumentException("Unhandled Anki request: $uri")
202212
}
213+
} else if (uri.startsWith(JsApi.REQUEST_PREFIX)) {
214+
handleJsRequest(uri, bytes)
203215
} else {
204216
throw IllegalArgumentException("Unhandled POST request: $uri")
205217
}
218+
219+
private suspend fun handleJsRequest(
220+
uri: String,
221+
bytes: ByteArray,
222+
): ByteArray {
223+
val requestData =
224+
try {
225+
JsApi.parseRequest(bytes)
226+
} catch (exception: InvalidContractException) {
227+
if (mediaErrorHandler.shouldShowJsApiExceptionMessage()) {
228+
onJsApiError.emit(exception)
229+
}
230+
return JsApi.fail("Invalid contract")
231+
}
232+
233+
val endpoint = getEndpoint(uri) ?: return JsApi.fail("Invalid endpoint")
234+
return handleJsEndpoint(endpoint, requestData)
235+
}
236+
237+
protected open suspend fun handleJsEndpoint(
238+
endpoint: Endpoint,
239+
data: JSONObject?,
240+
): ByteArray =
241+
if (endpoint is Endpoint.Android) {
242+
val result = CompletableDeferred<ByteArray>()
243+
val request = UiRequest(endpoint, data, result)
244+
apiRequestFlow.emit(request)
245+
// there may be no listeners for the flow, so fail the result after some time
246+
// e.g. the fragment uses flowWithLifecycle and is at a different lifecycleState
247+
withTimeoutOrNull(2000L) {
248+
result.await()
249+
} ?: JsApi.fail("Method was not handled")
250+
} else {
251+
JsApi.handleEndpointRequest(endpoint, data, currentCard.await())
252+
}
206253
}

AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ fun stdHtml(
5454
"backend/js/mathjax.js",
5555
"backend/js/vendor/mathjax/tex-chtml-full.js",
5656
"backend/js/reviewer.js",
57+
"scripts/ankidroid-js-api.js",
5758
) + extraJsAssets
5859
val jsTxt =
5960
jsAssets.joinToString("\n") {

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import androidx.constraintlayout.widget.ConstraintSet
4141
import androidx.coordinatorlayout.widget.CoordinatorLayout
4242
import androidx.core.content.ContextCompat
4343
import androidx.core.content.getSystemService
44+
import androidx.core.graphics.toColorInt
4445
import androidx.core.view.ViewCompat
4546
import androidx.core.view.WindowInsetsCompat
4647
import androidx.core.view.WindowInsetsControllerCompat
@@ -70,6 +71,9 @@ import com.ichi2.anki.common.utils.android.isRobolectric
7071
import com.ichi2.anki.dialogs.tags.TagsDialog
7172
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
7273
import com.ichi2.anki.dialogs.tags.TagsDialogListener
74+
import com.ichi2.anki.jsapi.Endpoint
75+
import com.ichi2.anki.jsapi.JsApi
76+
import com.ichi2.anki.jsapi.UiRequest
7377
import com.ichi2.anki.libanki.sched.Counts
7478
import com.ichi2.anki.model.CardStateFilter
7579
import com.ichi2.anki.preferences.reviewer.ReviewerMenuView
@@ -663,6 +667,25 @@ class ReviewerFragment :
663667
stateFilter: CardStateFilter,
664668
) = viewModel.onEditedTags(selectedTags)
665669

670+
override fun handleJsUiRequest(request: UiRequest): ByteArray {
671+
val result: ByteArray? =
672+
when (request.endpoint) {
673+
Endpoint.StudyScreen.SET_BACKGROUND_COLOR -> {
674+
val colorHex = request.data?.optString("colorHex") ?: return JsApi.fail("Missing hex code")
675+
val color =
676+
try {
677+
colorHex.toColorInt()
678+
} catch (_: IllegalArgumentException) {
679+
return JsApi.fail("Invalid hex code")
680+
}
681+
view?.setBackgroundColor(color)
682+
JsApi.success()
683+
}
684+
else -> null
685+
}
686+
return result ?: super.handleJsUiRequest(request)
687+
}
688+
666689
override fun onCreateWebViewClient(savedInstanceState: Bundle?): WebViewClient = ReviewerWebViewClient(savedInstanceState)
667690

668691
private inner class ReviewerWebViewClient(

0 commit comments

Comments
 (0)