17

I'm stuck for a moment on this case.

I have a webview on Android 4.4.3 where I have a webapp who has float32array containing binary data. I would like to pass that array to the Java Android via a function binded with JavascriptInterface. However, it seems like in Java, I can only pass primitive types like String, int etc...

Is there a way to give to Java this arrayBuffer ?

Thank you !

mohit
  • 4,968
  • 1
  • 22
  • 39
youpi
  • 205
  • 1
  • 2
  • 6

7 Answers7

24

Ok, so following a chat with Google engineering and after reading the code I've reached the following conclusions.

Passing binary data efficiently is impossible

It is impossible to pass binary data efficiently between JavaScript and Java through a @JavascriptInterface:

On the Java side:

@JavascriptInterface
void onBytes(byte[] bytes) {
   // bytes available here
}

And on the JavaScript side:

var byteArray = new Uint8Array(buffer);
var arr = new Uint8Array(byteArray.length);
for(var i = 0; i < byteArray.length; i++) {
  arr[i] = byteArray[i];
}
javaObject.onBytes(arr);

In the code above (from my old answer) and in Alex's - the conversion performed for the array is brutal:

case JavaType::TypeArray:
  if (value->IsType(base::Value::Type::DICTIONARY)) {
    result.l = CoerceJavaScriptDictionaryToArray(
        env, value, target_type, object_refs, error);
  } else if (value->IsType(base::Value::Type::LIST)) {
    result.l = CoerceJavaScriptListToArray(
        env, value, target_type, object_refs, error);
  } else {
    result.l = NULL;
  }
  break;

Which in turn coerces every array element to a Java object:

for (jsize i = 0; i < length; ++i) {
    const base::Value* value_element = null_value.get();
    list_value->Get(i, &value_element);
    jvalue element = CoerceJavaScriptValueToJavaValue(
        env, value_element, target_inner_type, false, object_refs, error);
    SetArrayElement(env, result, target_inner_type, i, element);

So, for a 1024 * 1024 * 10 Uint8Array - ten million Java objects are created and destroyed on each pass resulting in 10 seconds of CPU time on my emulator.

Creating an HTTP server

One thing we tried was creating an HTTP server and POSTing the result to it via an XMLHttpRequest. This worked - but ended up costing about 200ms of latency and also introduced a nasty memory leak.

MessageChannels are slow

Android API 23 added support for MessageChannels, which can be used via createWebMessageChannel() as shown in this answer. This is very slow, still serializes with GIN (like the @JavascriptInterface method) and incurs additional latency. I was not able to get this to work with reasonable performance.

It is worth mentioning that Google said they believe this is the way forward and hopes to promote message channels over @JavascriptInterface at some point.

Passing a string works

After reading the conversion code - one can see (and this was confirmed by Google) that the only way to avoid many conversions is to pass a String value. This only goes through:

case JavaType::TypeString: {
  std::string string_result;
  value->GetAsString(&string_result);
  result.l = ConvertUTF8ToJavaString(env, string_result).Release();
  break;
}

Which converts the result once to UTF8 and then again to a Java string. This still means the data (10MB in this case) is copied three times - but it is possible to pass 10MB of data in "only" 60ms - which is a lot more reasonable than the 10 seconds the above array method takes.

Petka came up with the idea of using 8859 encoding which can convert a single byte to a single letter. Unfortunately it is not supported in JavaScript's TextDecoder API - so Windows-1252 which is another 1 byte encoding can be used instead.

On the JavaScript side one can do:

var a = new Uint8Array(1024 * 1024 * 10); // your buffer
var b = a.buffer
// actually windows-1252 - but called iso-8859 in TextDecoder
var e = new TextDecoder("iso-8859-1"); 
var dec = e.decode(b);
proxy.onBytes(dec); // this is in the Java side.

Then, in the Java side:

@JavascriptInterface
public void onBytes(String dec) throws UnsupportedEncodingException
    byte[] bytes = dec.getBytes("windows-1252");
    // work with bytes here
}

Which runs in about 1/8th the time of direct serialization. It's still not very fast (since the string is padded to 16 bits instead of 8, then through UTF8 and then to UTF16 again). However, it runs in reasonable speed compared to the alternative.

After speaking with the relevant parties who are maintaining this code - they told me that it's as good as it can get with the current API. I was told I'm the first person to ask for this (fast JavaScript to Java serialization).

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
  • I'd like to thank CommonWare, Petka and Chromium's Tarbo for helping me and directing me in figuring this out :) – Benjamin Gruenbaum Aug 04 '17 at 12:28
  • I can't get it working as fast with those encodings, the fastest way is using utf-8. It also takes 150-300ms for 3MB. Do you know why this is the case? – Poka Yoke Jun 02 '18 at 00:16
  • What device did you test on? UTF8 is 300ms which is much slower - are you testing on really old devices or with a text encoding polyfill – Benjamin Gruenbaum Jun 02 '18 at 12:01
  • I'm testing on an AndroidTV Nougat, so it's not really old. When I used fixed 1-byte character encodings it took almost as much as copying an array (a couple of seconds). – Poka Yoke Jun 03 '18 at 00:37
  • Thanks Benjamin. For the Java -> Javascript direction, the WebViewClient has a shouldInterceptRequest method that can just provide a stream of bytes as the response (and be read as an ArrayBuffer on the JS side from a fetch). Unfortunately for JS -> Java it doesn't seem as helpful, as the body of the request is not available in that function. I was thinking it might have allowed your POST method to work with hopefully lower latency and without the need for an HTTP server, but no luck. – tangobravo Dec 12 '18 at 09:00
  • As noted below by @张浩然, both iso-8859-1 and windows-1252 have undefined characters. These really shouldn't be used. – Nuno Cruces Mar 08 '19 at 14:03
  • They do however decode just fine between JavaScript and Java and it's the only solution with reasonable performance, it is an order of magnitude faster than base64 encode/decode – Benjamin Gruenbaum Mar 08 '19 at 14:26
  • Have you tested this for every possible byte value? A (correct) alternative which works and is just as fast is "x-user-defined". The byte value will be on the low-order 8-bits of each character. You can then use the string directly (no decoding, and avoids another copy), or if needed use the (deprecated) getBytes that simply takes the low-order 8-bits (again this should be faster than decoding). – Nuno Cruces Mar 08 '19 at 14:59
  • 1
    I did check every possible byte value (And shipped it to production where it moved hundreds of terabytes every day a year ago). I don't think that JavaScript encoding APIs actually do x-user-defined, might be interesting to check. – Benjamin Gruenbaum Mar 08 '19 at 17:08
  • 1
    Ben this fix was AMAZING. You're a stud – John Lanzivision Jan 10 '20 at 18:30
  • Is createWebMessageChannel still slow in 2020? – dazza5000 Mar 03 '20 at 17:40
  • @tangobravo, can you provide an example for the Java -> Javascript usecase? i.e. how to pass arraybuffer from Android to Javascript using the shouldInterceptRequest – Avner Moshkovitz Oct 26 '21 at 15:29
  • @tangobravo Would this work for large files (GB of data)? – Avner Moshkovitz Oct 26 '21 at 15:37
  • @AvnerMoshkovitz I don't have an example to hand. Google can find some examples, eg: https://stackoverflow.com/questions/8273991/webview-shouldinterceptrequest-example It's a stream on the Java side, so if you read it with a ReadableStream on the JS side I'd expect it to work. Reading the whole thing into an ArrayBuffer will likely hit problems. You'll need to try it to see. My use case was trying to send camera frames across from the native side (so around 1M data each time, but at 30 FPS). It didn't hit the performance I needed but can't remember if that was due to latency or throughput. – tangobravo Nov 02 '21 at 13:36
4

It is pretty simple

Init section

 JavaScriptInterface jsInterface = new JavaScriptInterface(this);
 webView.getSettings().setJavaScriptEnabled(true);
 webView.addJavascriptInterface(jsInterface, "JSInterface");

JavaScriptInterface

public class JavaScriptInterface {
        private Activity activity;

        public JavaScriptInterface(Activity activiy) {
            this.activity = activiy;
        }
        @JavascriptInterface
        public void putData(byte[] bytes){
            //do whatever
        }
    }

Js section

<script>
  function putAnyBinaryArray(arr) {
        var uint8 = Uint8Array.from(arr);
        window.JSInterface.putData(uint8);
  };
</script>

TypedArray.from polyfill if need : https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/from

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
Alex Nikulin
  • 8,194
  • 4
  • 35
  • 37
  • Actually, there is a better way - to use a MessagePort - which avoids the copy in this answer - https://developer.android.com/reference/android/webkit/WebView.html#createWebMessageChannel() - https://html.spec.whatwg.org/multipage/web-messaging.html#messagechannel – Benjamin Gruenbaum Jul 30 '17 at 12:17
  • Meaning, it can potentially be twice as fast. This was still the best answer when the bounty expired - so cheers. If you want to update your answer I would appreciate not having to post another one. – Benjamin Gruenbaum Jul 30 '17 at 12:17
  • So, turns out this doesn't _really_ work, it crashes the Chrome process if you try to pass more than 10mb and it will take 10s to pass 10mb (and 1s to pass 1mb) – Benjamin Gruenbaum Aug 02 '17 at 17:40
  • @BenjaminGruenbaum try to slice your array by 1mb. The operation of passing data to android or js it is a heavy process. You really not pass a data, you creating a copy of your data. So it takes twice the memory. – Alex Nikulin Aug 03 '17 at 05:04
  • It looks like it's doing string conversion or is otherwise slow :( If I have a 10mb array it takes over 10 seconds to pass which is ridiculous since it's more than it takes to download. – Benjamin Gruenbaum Aug 03 '17 at 07:29
  • 1
    I fixed it, I'll post a working answer later this week – Benjamin Gruenbaum Aug 03 '17 at 11:28
  • @BenjaminGruenbaum I look forward to your post:) – Alex Nikulin Aug 04 '17 at 04:27
  • @BenjaminGruenbaum I look forward to your post :) – Brandon Ros Dec 04 '22 at 22:27
  • https://stackoverflow.com/a/45506857/1348195 – Benjamin Gruenbaum Dec 05 '22 at 07:04
3

Serialise your data into a string, then unserialize in your app.

markt
  • 903
  • 7
  • 21
2

Cloning the ArrayBuffer makes it work - something about a TypedArray backed with an ArrayBuffer doesn't marshall well into Android.

If you copy your ArrayBuffer into a new TypedArray you can avoid the expensive serialization overhead.

On the reader:

@JavascriptInterface
void onBytes(byte[] bytes) {
   // bytes available here
}

And on the JS side:

var byteArray = new Uint8Array(buffer);
var arr = new Uint8Array(byteArray.length);
for(var i = 0; i < byteArray.length; i++) {
  arr[i] = byteArray[i];
}
javaObject.onBytes(arr);

Works perfectly fine :)

Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
1

If you want Sync call, just use base64 encode & decode: (Convert base64 string to ArrayBuffer)

@JavascriptInterface
void onBytes(String base64) {
    // decode here
}

If you want Async call:

You can create http server in Android appliction, and then use "xhr" or "fetch" in javascript side to send binary or string async


And don't use "iso-8859-1" or "windows-1252" mentioned above, it's dangerous !!!
"iso-8859-1" has undefined code which can't be decode between javascript and java. (https://en.wikipedia.org/wiki/ISO/IEC_8859-1)

张浩然
  • 371
  • 3
  • 3
  • Using iso-8859-1/windows-1252 is a bad idea, but x-user-defined works. Have you compared the performance of this vs base64? – Nuno Cruces Mar 08 '19 at 14:05
0

the code linked in the second answer https://source.chromium.org/chromium/chromium/src/+/master:content/browser/android/java/gin_java_script_to_java_types_coercion.cc;l=628?q=gin_java_scr&ss=chromium

doesn't actually understand TypedArrays (it looks like it does because it says TypeArray but, everything in that file is TypeXZY)

So I can definitely imagine that it's faster to copy a string. However, there is no reason that it shouldn't be able to pass a typed array without copying, or at least with just a single raw copy.

It would require a patch to chromium though.

dominic
  • 571
  • 4
  • 7
0

in my case, app tranfers blob data each other without a http server, so there's no choice but to send arrayBuffer to javainterface, as follows:

//javascript
if ((window as any)?.JsBridge?.downloadFile) {
      file.arrayBuffer().then(arr => {
        (window as any)?.JsBridge?.downloadFile(new Uint8Array(arr), filename)
      })
}
//java
@JavascriptInterface
public void downloadFile(byte[] bytes, String filename) {
    NativeApi.log("downloadFile", filename + "," + bytes.length);
}

by the way, the file limit to 10M, toast something after asyncTask processed!

Peter Yu
  • 27
  • 2