Skip to content

Commit

Permalink
Add a JavaScript REPL provided by Mozilla's Rhino
Browse files Browse the repository at this point in the history
This is a first draft at adding a REPL.

What works:
 - basic JS expressions
 - evaluatied statements preserve their states (can create a variable and it will persist between successive calls)
 - return types are properly expanded by chrome's inspector
 - binding of android's Context (passed as `context`)
 - binding for classes: `System.gc()` is `Packages.java.lang.System.gc()`
 - console redirection with `console.log()`
 - `importPackage()` to import all classes under a java package
 - `importClass()` to import a single class
 - binding variables
 - binding functions

For more details see:
 - https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Rhino/Scripting_Java
 - https://developer.mozilla.org/en-US/docs/Rhino_documentation
  • Loading branch information
potyl committed Jul 18, 2015
1 parent 54299fc commit c1a3048
Show file tree
Hide file tree
Showing 9 changed files with 684 additions and 0 deletions.
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
include ':stetho'
include ':stetho-urlconnection'
include ':stetho-okhttp'
include ':stetho-js-rhino'
include ':stetho-sample'
include ':stetho-timber'
1 change: 1 addition & 0 deletions stetho-js-rhino/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
224 changes: 224 additions & 0 deletions stetho-js-rhino/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Stetho's JavaScript Module

This [Stetho](https://facebook.github.io/stetho) plugin adds a JavaScript console by embedding Mozilla's [Rhino](https://github.com/mozilla/rhino).

## Set-up

### Download
Download [the latest JARs](https://github.com/facebook/stetho/releases/latest) or grab via Gradle:
```groovy
compile 'com.facebook.stetho:stetho-js-rhino:1.1.1'
```
or Maven:
```xml
<dependency>
<groupId>com.facebook.stetho</groupId>
<artifactId>stetho-js-rhino</artifactId>
<version>1.1.1</version>
</dependency>
```

Make sure that you depend on the main `stetho` dependency too.

### Putting it together
The JavaScript integration is similar to standard Stetho integration.
The main difference is with the WebKitInspector used.
There is a simple initialization step which occurs in your `Application` class:

```java
public class MyApplication extends Application {
public void onCreate() {
super.onCreate();
+ JsRuntimeBuilder jsRuntimeBuilder = new JsRuntimeBuilder(this);
Stetho.initialize(
Stetho.newInitializerBuilder(this)
.enableDumpapp(Stetho.defaultDumperPluginsProvider(this))
- .enableWebKitInspector(Stetho.defaultInspectorModulesProvider(context))
+ .enableWebKitInspector(jsRuntimeBuilder.jsInspectorModulesProvider())
.build());
}
}
```

You can still use other Stetho plugins with this approach, for instance the network helpers stetho-okhttp and stetho-urlconnection will still work if activated properly.

### How it works

At the core this plugin initializes a JavaScript runtime provided by Mozilla's [Rhino](https://github.com/mozilla/rhino).
The runtime is configured so that it will work within an Android application.
This means that code has to run in interpreted mode as the more aggressive optimizations performed
by Rhino are done through on-the-fly JVM bytecode generation and this won't work in Android which expects Dalvik bytecode.

For generic purposes the interpreted mode should have no performance impact since this is debug tool.

## Customization

By default a JavaScript interpreter starts with an empty scope (environment) and has no default variables or functions set besides the ones described by the language specification.

You might be used to the browser setting up various objects for your like `document`, `console`, etc. This is something that's not part of the javascript specification and is particular only
to the browser's runtime.

## Example

Once you have enabled the JavaScript console in your app you will be able to run live JavaScript commands on your app from the console.

Here's an example to show a toast from the console:

```javascript
importPackage(android.widget);
importPackage(android.os);
var handler = new Handler(Looper.getMainLooper());
handler.post(function() { Toast.makeText(context, "Hello from JavaScript", Toast.LENGTH_LONG).show() });
```

### Default scope

Rhino offers the possibility to use an enhanced runtime where some utilities have been added
in order to help the integration with the java runtime.
This is exactly the runtime that the plugin uses.
The default scope used by the plugin is described in more details in the article [Scripting Java](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/Rhino/Scripting_Java).

The scope can be enhanced by your application, if desired.
You can preload classes and packages, bind variables, objects and even functions.
This means that your java classes and objects can be accessed from JavaScript.

### Builtins

The JavaScript runtime use by this plugin has been enhanced.
By default your application's package has been imported.
So things like `R.string.app` should work.

The functions `importClass` and `importPackage` have been added.

A `console` object is available too. It supports only a `log()` method for now.

### Import a class

To import a java class into the JavaScript runtime do:

```java
jsRuntimeBuilder.importClass(R.class);
```

### Import a package

To import all classes in a java package into the JavaScript runtime do:

```java
jsRuntimeBuilder.importPackage("android.content");
```

### Variable binding

Here's how to pass a variable to the JavaScript runtime:

```java
jsRuntimeBuilder.addVariable("flag", new AtomicBoolean(true));
```
**Note**: Java primitive types will be autoboxed, only objects can be passed to the javascript runtime.

### Function binding

You can also add custom javascript functions that will be available to the runtime.
This requires a bit more of work on your part.
Remember that you invoke methods on objects that you have already binded.

If you really want to define a top level function this is how it can be done:

```java
// Your application context
final Context context = this;

final Handler handler = new Handler(Looper.getMainLooper());

// Add the function: void toast(String)
jsRuntimeBuilder.addFunction("toast", new BaseFunction() {
@Override
public Object call(org.mozilla.javascript.Context cx, Scriptable scope, Scriptable thisObj, Object[] args) {
// javascript passes the arguments as varags
final String message = args[0].toString();
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
}
});

// return undef in javascript
return org.mozilla.javascript.Context.getUndefinedValue();
}
});
```

**Note**: This is a complex example since a toast be invoked from the main UI thread.

## Limitations

As mentioned in the section (how it works)(#"user-content-how-it-works) the JavaScript runtime has to run in interpreted mode due to the nature of the android runtime.

### Dex method count

Rhino is not a small library and it can increase your dex method count by more than 7,000.
The standard Rhino distribution includes a "tools" package that's not required by this plugin
but it is still bundled.
That package alone adds more than 1,200 methods to the dex count.

Hopefully the Rhino devs will split the distribution in smaller artifacts (see mozilla/rhino#156).
In the meanwhile you should consider using proguard to shrink the dex method count.

Here's the dex count of the stetho-sample compiled under various scenarios:

| | Original | JavaScript |
| :--- | -------: | ---------: |
| Dex | 15,461 | 22,749 |
| Size | 1.0M | 1.6M |

With proguard:

| | Original | JavaScript | JavaScript (w/o *tools*) |
| :--- | -------: | ---------: | ---------------------------: |
| Dex | 8,013 | 15,316 | 14,043 |
| Size | 847K | 1.5M | 1.4M |

### Proguard

To proguard your project add the following rules to your proguard file:

```
# stetho
+keep class com.facebook.stetho.** { *; }
# rhino (javascript)
-dontwarn org.mozilla.javascript.**
-dontwarn org.mozilla.classfile.**
-keep class org.mozilla.javascript.** { *; }
```

If you want to remove the *tools* package for a more aggressive proguard use:

```
# stetho
+keep class com.facebook.stetho.** { *; }
# rhino (javascript)
-dontwarn org.mozilla.javascript.**
-dontwarn org.mozilla.classfile.**
-keep class org.mozilla.classfile.** { *; }
-keep class org.mozilla.javascript.* { *; }
-keep class org.mozilla.javascript.annotations.** { *; }
-keep class org.mozilla.javascript.ast.** { *; }
-keep class org.mozilla.javascript.commonjs.module.** { *; }
-keep class org.mozilla.javascript.commonjs.module.provider.** { *; }
-keep class org.mozilla.javascript.debug.** { *; }
-keep class org.mozilla.javascript.jdk13.** { *; }
-keep class org.mozilla.javascript.jdk15.** { *; }
-keep class org.mozilla.javascript.json.** { *; }
-keep class org.mozilla.javascript.optimizer.** { *; }
-keep class org.mozilla.javascript.regexp.** { *; }
-keep class org.mozilla.javascript.serialize.** { *; }
-keep class org.mozilla.javascript.typedarrays.** { *; }
-keep class org.mozilla.javascript.v8dtoa.** { *; }
-keep class org.mozilla.javascript.xml.** { *; }
-keep class org.mozilla.javascript.xmlimpl.** { *; }
```

26 changes: 26 additions & 0 deletions stetho-js-rhino/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
apply plugin: 'com.android.library'

android {
compileSdkVersion 21
buildToolsVersion "21.1.2"

defaultConfig {
minSdkVersion 9
targetSdkVersion 21
versionCode 1
versionName "1.0"
}

lintOptions {
// Rhino has references to awt and swing
disable 'InvalidPackage'
}
}

dependencies {
compile project(':stetho')
compile 'com.google.code.findbugs:jsr305:2.0.1'
compile 'org.mozilla:rhino:1.7.6'
}

apply from: rootProject.file('release.gradle')
4 changes: 4 additions & 0 deletions stetho-js-rhino/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
POM_NAME=Stetho JavaScript (Rhino) module
POM_ARTIFACT_ID=stetho-js-rhino
POM_OPTIONAL_DEPS=com.facebook.stetho:stetho
POM_PACKAGING=aar
7 changes: 7 additions & 0 deletions stetho-js-rhino/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.facebook.stetho.rhino">

<application />

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/

package com.facebook.stetho.rhino;

import android.support.annotation.Nullable;
import com.facebook.stetho.inspector.console.CLog;
import com.facebook.stetho.inspector.protocol.module.Console.MessageLevel;
import com.facebook.stetho.inspector.protocol.module.Console.MessageSource;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.ScriptableObject;
import org.mozilla.javascript.annotations.JSFunction;

class JsConsole extends ScriptableObject {

/**
* Serial version UID.
*/
private static final long serialVersionUID = 1L;

private @Nullable
JsConsole thePrototypeInstance = null;

/**
* The zero-parameter constructor.
*
* When Context.defineClass is called with this class, it will construct
* File.prototype using this constructor.
*/
public JsConsole() {
if (thePrototypeInstance == null) {
thePrototypeInstance = this;
}
}

public JsConsole(ScriptableObject scope) {
setParentScope(scope);
Object ctor = ScriptRuntime.getTopLevelProp(scope, "Console");
if (ctor != null && ctor instanceof Scriptable) {
Scriptable scriptable = (Scriptable) ctor;
setPrototype((Scriptable) scriptable.get("prototype", scriptable));
}
}

@Override
public String getClassName() {
return "Console";
}

@JSFunction
public static void log(Context cx, Scriptable thisObj, Object[] args, Function funObj) {
log(args);
}

private static void log(Object [] rawArgs) {
String format = (String) rawArgs[0];

String message;
if (rawArgs.length == 1) {
message = format;
}
else {
// Using place holders in javascript (%d) causes a problem with java's String.format().
// This happens because %d expects an int/Integer but in javascript numbers are floats.
// For now as a best effort we just try to use jsToJava() to convert everything to objects.
// This will need rework.
Object [] args = new Object[rawArgs.length - 1];
for (int i = 0; i < args.length; ++i) {
Object arg = rawArgs[i + 1];
arg = Context.jsToJava(arg, Object.class);
args[i] = arg;
}
message = String.format(format, args);
}

CLog.writeToConsole(MessageLevel.LOG, MessageSource.JAVASCRIPT, message);
}
}
Loading

0 comments on commit c1a3048

Please sign in to comment.