JavaScript Evaluation
Jetpack library JavaScriptEngine provides a way for an application to evaluate JavaScript code without creating a WebView instance.
For applications requiring non-interactive JavaScript evaluation, using the JavaScript engine library has the following advantages:
Lower resource consumption, since there is no need to allocate a WebView instance.
Can be done in a Service (WorkManager task).
Multiple isolated environments with low overhead, enabling the application to run several JavaScript snippets simultaneously.
Ability to pass large amounts of data by using an API call.
Basic Usage
First of all, allocate a JavaScriptSandbox
instance. It represents
connection to the JavaScript Engine.
ListenableFuture<JavaScriptSandbox> jsSandboxFuture =
JavaScriptSandbox.createConnectedInstanceAsync(context);
It’s recommended to align the lifecycle of the sandbox with the lifecycle of the component which needs JavaScript evaluation.
For example, a component hosting the sandbox may be an Activity
or a
Service
. A single Service
might be used to encapsulate JavaScript evaluation
for all application components.
Maintain the JavaScriptSandbox
instance because its allocation is fairly
expensive. Only one JavaScriptSandbox
instance per application is allowed. An
exception is thrown when an application tries to allocate a second
JavaScriptSandbox instance. However, if multiple execution environments are
required, several JavaScriptIsolate
instances might be allocated.
When it is no longer used, close the sandbox instance to free up resources. The
JavaScriptSandbox
instance implements an AutoCloseable
interface, which
allows the recommended try-with-resources usage for simple blocking use cases.
Alternatively, make sure JavaScriptSandbox
instance lifecycle is managed by
the hosting component, closing it in onStop()
callback for an Activity or
during onDestroy()
for a Service:
jsSandbox.close();
A JavaScriptIsolate
instance represents a context for executing
JavaScript code. They can be allocated when necessary, providing weak security
boundaries for scripts of different origin or enabling concurrent JavaScript
execution since JavaScript is single-threaded by its nature. Subsequent calls to
the same instance share the state, hence it is possible to create some data
first and then process it later in the same instance of JavaScriptIsolate
.
JavaScriptIsolate jsIsolate = jsSandbox.createIsolate();
Release JavaScriptIsolate
explicitly by calling its close()
method.
Using the try-with-resources construct is recommended since it implements
AutoCloseable
interface. Closing an isolate instance running JavaScript code
(having an incomplete Future
) results in an IsolateTerminatedException
if
implementation supports JS_FEATURE_ISOLATE_TERMINATION
(see “Sandbox
features” below). Otherwise, the cleanup is postponed until all pending
evaluations are completed.
An application can create a JavaScriptIsolate
instance from any thread.
Due to JavaScript’s single-threading model, the JavaScriptIsolate
usage is not
thread-safe. So a JavaScriptIsolate
instance can be accessed from a single
thread or explicitly serialize access from different threads.
Now, the application is ready to execute some JavaScript code:
final String code = "function sum(a, b) { let r = a + b; return r.toString(); }; sum(3, 4)";
ListenableFuture<String> resultFuture = jsIsolate.evaluateJavaScriptAsync(code);
String result = resultFuture.get(5, TimeUnit.SECONDS);
The same JavaScript snippet formatted nicely:
function sum(a, b) {
let r = a + b;
return r.toString(); // make sure we return String instance
};
sum(3, 4) // calculate and return the result
The code snippet is passed as a String
and the result delivered as a String
.
Note that the JavaScript code must return an object of JavaScript String type
otherwise the library API returns an empty value. If the sandbox supports
certain features, additional return types (For example, Promise resolving to a
String) might be possible, see evaluateJavaScriptAsync(...)
javadoc for
more details.
Since the context persists, you can upload code and execute it several times
during the lifetime of the JavaScriptIsolate
:
String jsFunction = "function sum(a, b) { let r = a + b; return r.toString(); }";
ListenableFuture<String> func = js.evaluateJavaScriptAsync(jsFunction);
String twoPlusThreeCode = "let five = sum(2, 3); five";
ListenableFuture<String> r1 = Futures.transformAsync(func,
input -> js.evaluateJavaScriptAsync(twoPlusThreeCode)
, executor);
String twoPlusThree = r1.get(5, TimeUnit.SECONDS);
String fourPlusFiveCode = "sum(4, parseInt(five))";
ListenableFuture<String> r2 = Futures.transformAsync(func,
input -> js.evaluateJavaScriptAsync(fourPlusFiveCode)
, executor);
String fourPlusFive = r2.get(5, TimeUnit.SECONDS);
Of course, variables are persistent as well, so you can continue the previous snippet with:
String defineResult = "let result = sum(11, 22);";
ListenableFuture<String> r1 = Futures.transformAsync(func,
input -> js.evaluateJavaScriptAsync(defineResult)
, executor);
String unused = r1.get(5, TimeUnit.SECONDS);
String obtainValue = "result";
ListenableFuture<String> r2 = Futures.transformAsync(func,
input -> js.evaluateJavaScriptAsync(obtainValue)
, executor);
String value = r2.get(5, TimeUnit.SECONDS);
For example, the complete snippet for allocating all necessary objects and executing a JavaScript code might look like the below.
final ListenableFuture<JavaScriptSandbox> sandbox
= JavaScriptSandbox.createConnectedInstanceAsync(this);
final ListenableFuture<JavaScriptIsolate> isolate
= Futures.transform(sandbox,
input -> (jsSandBox = input).createIsolate(),
executor);
final ListenableFuture<String> js
= Futures.transformAsync(isolate,
isolate -> (jsIsolate = isolate).evaluateJavaScriptAsync("'PASS OK'"),
executor);
Futures.addCallback(js,
new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
text.append(result);
}
@Override
public void onFailure(Throwable t) {
text.append(t.getMessage());
}
},
mainThreadExecutor);
It’s recommended that you use try-with-resources to make sure all allocated
resources are released and are no longer used. Closing the sandbox instance
automatically closes all allocated JavaScriptIsolate
instances and
pending evaluations throws IsolateTerminatedException
.
Optional Sandbox Features
Depending on the underlying WebView version, a sandbox implementation might have
different sets of features available. So, it’s necessary to query each required
feature using JavaScriptSandbox.isFeatureSupported(...)
. It is important
to check feature status before calling methods relying on these features.
JavaScriptIsolate
methods that might not be available everywhere are
annotated with RequiresFeature
annotation, making it easier to spot these
calls in the code.
Passing Parameters
Because all communications to the JavaScript engine happen through a Binder transaction, the general transaction size limit is applicable to every call passing or returning data. In practice, it means the application must limit the size of the passed Strings to hundreds of kilobytes.
The response is always returned as a String and is subject to the Binder
transaction maximum size limit. Non-string values must be explicitly converted
to a JavaScript String otherwise an empty string is returned. If
JS_FEATURE_PROMISE_RETURN
feature is supported, JavaScript code might
return a Promise resolving to a String.
For passing large amounts of data to the JavaScriptIsolate
instance, you
can use the provideNamedData(...)
API. The provideNamedData(...)
API
allows sending arbitrary amounts of data not limited by the Binder transaction
size. Each data chunk must be passed using a unique identifier which cannot be
re-used. The sending code and the JavasScript code must use the same
algorithm for defining the chunk ID.
if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER)) {
js.provideNamedData("data-1", "Hello Android!".getBytes(StandardCharsets.UTF_8));
final String jsCode = "android.consumeNamedDataAsArrayBuffer('data-1').then((value) => { return String.fromCharCode.apply(null, new Uint8Array(value)); });";
ListenableFuture<String> msg = js.evaluateJavaScriptAsync(jsCode);
String response = msg.get(5, TimeUnit.SECONDS);
}
Running Wasm Code
WebAssembly (Wasm) code can be passed using the provideNamedData(...)
API, then compiled and executed in the usual manner, as demonstrated below.
final byte[] hello_world_wasm = {
0x00 ,0x61 ,0x73 ,0x6d ,0x01 ,0x00 ,0x00 ,0x00 ,0x01 ,0x0a ,0x02 ,0x60 ,0x02 ,0x7f ,0x7f ,0x01,
0x7f ,0x60 ,0x00 ,0x00 ,0x03 ,0x03 ,0x02 ,0x00 ,0x01 ,0x04 ,0x04 ,0x01 ,0x70 ,0x00 ,0x01 ,0x05,
0x03 ,0x01 ,0x00 ,0x00 ,0x06 ,0x06 ,0x01 ,0x7f ,0x00 ,0x41 ,0x08 ,0x0b ,0x07 ,0x18 ,0x03 ,0x06,
0x6d ,0x65 ,0x6d ,0x6f ,0x72 ,0x79 ,0x02 ,0x00 ,0x05 ,0x74 ,0x61 ,0x62 ,0x6c ,0x65 ,0x01 ,0x00,
0x03 ,0x61 ,0x64 ,0x64 ,0x00 ,0x00 ,0x09 ,0x07 ,0x01 ,0x00 ,0x41 ,0x00 ,0x0b ,0x01 ,0x01 ,0x0a,
0x0c ,0x02 ,0x07 ,0x00 ,0x20 ,0x00 ,0x20 ,0x01 ,0x6a ,0x0b ,0x02 ,0x00 ,0x0b,
};
final String jsCode = "android.consumeNamedDataAsArrayBuffer('wasm-1').then(" +
"(value) => { return WebAssembly.compile(value).then(" +
"(module) => { return new WebAssembly.Instance(module).exports.add(20, 22).toString(); }" +
")})";
boolean success = js.provideNamedData("wasm-1", hello_world_wasm);
if (success) {
FluentFuture.from(js.evaluateJavaScriptAsync(jsCode))
.transform(this::println, mainThreadExecutor)
.catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
} else {
// the data chunk name has been used before, use a different name
}
JavaScriptIsolate Separation
All JavaScriptIsolate
instances are independent of each other and do not
share anything. The following snippet results in
Hi from AAA!5
and
Uncaught Reference Error: a is not defined
because the ”jsTwo
” instance has no visibility of the objects created in
“jsOne
”.
JavaScriptIsolate jsOne = engine.obtainJavaScriptIsolate();
String jsCodeOne = "let x = 5; function a() { return 'Hi from AAA!'; } a() + x";
JavaScriptIsolate jsTwo = engine.obtainJavaScriptIsolate();
String jsCodeTwo = "a() + x";
FluentFuture.from(jsOne.evaluateJavaScriptAsync(jsCodeOne))
.transform(this::println, mainThreadExecutor)
.catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
FluentFuture.from(jsTwo.evaluateJavaScriptAsync(jsCodeTwo))
.transform(this::println, mainThreadExecutor)
.catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
Errors
When attempting to use an explicitly closed instance (by using
close()
), or if the framework cancels the sandbox process, aSandboxDeadException
is thrown.When a JavaScript evaluation is terminated because the
JavaScriptIsolate
is explicitly closed (by usingclose()
), anIsolateTerminatedException
indicates the condition.When the JavaScript engine encounters an error in the JavaScript code, an
EvaluationFailedException
is thrown.If WebView implementation does not support heap size adjustments,
JavaScriptSandbox.createIsolate(IsolateStartupParameters)
might throw aRuntimeException
. To avoid the error, before reconfiguring the heap size, useJavaScriptSandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_ISOLATE_MAX_HEAP_SIZE)
.Do not attempt to create more than one
JavaScriptSandbox
. It results in anIllegalStateException
. When multiple connections are attempted it produces a “Binding to already bound service when calling createConnectedInstanceAsync() more than once” message. Applications must not attempt parsing the message.Older versions of WebView do not support the JavaScript engine. If an application is installed on a device that has an older version of WebView, attempting to allocate a
JavaScriptSandbox
instance throws aSandboxUnsupportedException
.
Configuration Parameters
When requesting an isolated environment instance, you can tweak its
configuration. To tweak the configuration, pass the
IsolateStartupParameters instance to
JavaScriptSandbox.createIsolate(...)
.
Currently parameters allow specifying the maximum heap size. If the WebView
implementation does not support heap size adjustments,
JavaScriptSandbox.createIsolate(IsolateStartupParameters)
might throw a
RuntimeException
.