Executing JavaScript and WebAssembly

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 JavaScriptEngine 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

To begin, create an instance of JavaScriptSandbox. This represents a connection to the out-of-process 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 IllegalStateException is thrown when an application attempts to allocate a second JavaScriptSandbox instance. However, if multiple execution environments are required, several JavaScriptIsolate instances can 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 try-with-resources usage for simple blocking use cases. Alternatively, make sure JavaScriptSandbox instance lifecycle is managed by the hosting component, closing it in the 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 nature. Subsequent calls to the same instance share the same 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. Closing an isolate instance running JavaScript code (having an incomplete Future) results in an IsolateTerminatedException. The isolate is cleaned up subsequently in the background if the implementation supports JS_FEATURE_ISOLATE_TERMINATION, as described in the handling sandbox crashes section later on this page. Otherwise, the cleanup is postponed until all pending evaluations are completed or the sandbox is closed.

An application can create and access a JavaScriptIsolate instance from any thread.

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
};

// Calculate and evaluate the expression
// NOTE: We are not in a function scope and the `return` keyword
// should not be used. The result of the evaluation is the value
// the last expression evaluates to.
sum(3, 4);

The code snippet is passed as a String and the result delivered as a String. Note that calling evaluateJavaScriptAsync() returns the evaluated result of the last expression in the JavaScript code. This must be of JavaScript String type; otherwise, the library API returns an empty value. The JavaScript code shouldn't use a return keyword. If the sandbox supports certain features, additional return types (for example, a Promise that resolves to a String) might be possible.

The library also supports evaluation of scripts that are in the form of an AssetFileDescriptor or a ParcelFileDescriptor. See evaluateJavaScriptAsync(AssetFileDescriptor) and evaluateJavaScriptAsync(ParcelFileDescriptor) for more details. These APIs are better suited for evaluating from a file on disk or in app directories.

The library also supports console logging which can be used for debugging purposes. This can be set up using setConsoleCallback().

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> r3 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(defineResult)
       , executor);
String unused = r3.get(5, TimeUnit.SECONDS);

String obtainValue = "result";
ListenableFuture<String> r4 = Futures.transformAsync(func,
       input -> js.evaluateJavaScriptAsync(obtainValue)
       , executor);
String value = r4.get(5, TimeUnit.SECONDS);

For example, the complete snippet for allocating all necessary objects and executing a JavaScript code might look like the following:

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 results in all pending evaluations in all JavaScriptIsolate instances failing with a SandboxDeadException. When the JavaScript evaluation encounters an error, a JavaScriptException is created. Refer to its subclasses for more specific exceptions.

Handling Sandbox Crashes

All JavaScript is executed in a separate sandboxed process away from your application’s main process. If the JavaScript code causes this sandboxed process to crash, for example, by exhausting a memory limit, the application’s main process will be unaffected.

A sandbox crash will cause all isolates in that sandbox to terminate. The most obvious symptom of this is that all evaluations will start failing with IsolateTerminatedException. Depending on the circumstances, more specific exceptions such as SandboxDeadException or MemoryLimitExceededException may be thrown.

Handling crashes for each individual evaluation is not always practical. Furthermore, an isolate may terminate outside of an explicitly requested evaluation due to background tasks or evaluations in other isolates. The crash handling logic can be centralized by attaching a callback using JavaScriptIsolate.addOnTerminatedCallback().

final ListenableFuture<JavaScriptSandbox> sandboxFuture =
    JavaScriptSandbox.createConnectedInstanceAsync(this);
final ListenableFuture<JavaScriptIsolate> isolateFuture =
    Futures.transform(sandboxFuture, sandbox -> {
      final IsolateStartupParameters startupParams = new IsolateStartupParameters();
      if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_ISOLATE_MAX_HEAP_SIZE)) {
        startupParams.setMaxHeapSizeBytes(100_000_000);
      }
      return sandbox.createIsolate(startupParams);
    }, executor);
Futures.transform(isolateFuture,
    isolate -> {
      // Add a crash handler
      isolate.addOnTerminatedCallback(executor, terminationInfo -> {
        Log.e(TAG, "The isolate crashed: " + terminationInfo);
      });
      // Cause a crash (eventually)
      isolate.evaluateJavaScriptAsync("Array(1_000_000_000).fill(1)");
      return null;
    }, executor);

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

If JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT is supported, the evaluation requests sent to the JavaScript engine are not bound by the binder transaction limits. If the feature is not supported, all data to the JavaScriptEngine occurs through a Binder transaction. The general transaction size limit is applicable to every call that passes in data or returns data.

The response is always returned as a String and is subject to the Binder transaction maximum size limit if JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT is not supported. 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 may alternatively return a Promise resolving to a String.

For passing large byte arrays to the JavaScriptIsolate instance, you can use the provideNamedData(...) API. Usage of this API is not bound by the Binder transaction limits. Each byte array must be passed using a unique identifier which cannot be re-used.

if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER)) {
    js.provideNamedData("data-1", "Hello Android!".getBytes(StandardCharsets.US_ASCII));
    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 = "(async ()=>{" +
       "const wasm = await android.consumeNamedDataAsArrayBuffer('wasm-1');" +
       "const module = await WebAssembly.compile(wasm);" +
       "const instance = WebAssembly.instance(module);" +
       "return instance.exports.add(20, 22).toString();" +
       "})()";
// Ensure that the name has not been used before.
js.provideNamedData("wasm-1", hello_world_wasm);
FluentFuture.from(js.evaluateJavaScriptAsync(jsCode))
           .transform(this::println, mainThreadExecutor)
           .catching(Throwable.class, e -> println(e.getMessage()), mainThreadExecutor);
}

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

Kotlin Support

To use this Jetpack library with Kotlin coroutines, add a dependency to kotlinx-coroutines-guava. This allows integration with ListenableFuture.

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.6.0"
}

The Jetpack library APIs can now be called from a coroutine scope, as demonstrated below:

// Launch a coroutine
lifecycleScope.launch {
    val jsSandbox = JavaScriptSandbox
            .createConnectedInstanceAsync(applicationContext)
            .await()
    val jsIsolate = jsSandbox.createIsolate()
    val resultFuture = jsIsolate.evaluateJavaScriptAsync("PASS")

    // Await the result
    textBox.text = resultFuture.await()
    // Or add a callback
    Futures.addCallback<String>(
        resultFuture, object : FutureCallback<String?> {
            override fun onSuccess(result: String?) {
                textBox.text = result
            }
            override fun onFailure(t: Throwable) {
                // Handle errors
            }
        },
        mainExecutor
    )
}

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 and the maximum size for evaluation return values and errors.