How to Get Up and Running With Turbo Android Turbo Android Part 3 - How to Access Native Android Features with the JavaScript Bridge

Continuing from my previous post about accessing native screens from your hybrid web app, today, we’re going to talk about the Javascript bridge which is my favourite feature within the whole Turbo native ecosystem.


We can write the javascript we already know(and love/hate) to call native code. In previous projects, I’ve used this to delete native tokens, access permissions, and access a native SDK and in an earlier post, we’ve accessed the phone’s contacts database. In today’s post, we’ll be doing precisely that to demonstrate the power of Turbo Native.

Backend application

We will use the same backend application we’ve used for the last two articles, which you can find here. We’ve added some Javascript.

Here we’ve set up a JavaScript bridge to post messages to the Android client and send messages back.

// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"

class Bridge {

  // Sends a message to the native app, if active.
  static postMessage(name, data = {}) {
    // iOS

    // Android

  static importingContacts(name) {
    fetch('/contacts.json', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        "Accept": "application/json",
      body: JSON.stringify({contact: { name } })
    }).then((response) => response.json())
      .then((data) => {
        console.log("Success:", data);
        var btn = document.querySelector('button')
        btn.textContent = `"Imported ${name}"`
      .catch((error) => {
        console.error("Error:", error);

// Expose this on the window object so TurboNative can interact with it
window.Bridge = Bridge
export default Bridge

We’ve also created a stimulus controller that attaches to a button that posts that particular method.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  import() {
    window.Bridge.postMessage("contacts", {name: "contacts"})

Finally, in our view, we’ve connected the Stimulus controller to the button that will import the contacts.

<p style="color: green"><%= notice %></p>

<div class="mt-8 px-4 flex items-center justify-between">
  <div class="min-w-0 flex-1">
    <h1 class='h1'>Import Contacts</h1>

<div data-controller='contacts' class='overflow-hidden rounded-md bg-white shadow mt-4 p-2'>

  <div class="text-center">
    <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
      <path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
    <h3 class="mt-2 text-sm font-semibold text-gray-900">No contacts imported</h3>
    <p class="mt-1 text-sm text-gray-500">Get started by creating a new project.</p>
    <div class="mt-6">
      <button data-action='contacts#import' type="button" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
        <svg class="-ml-0.5 mr-1.5 h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
        Import Contacts

  <ul class='divide-y divide-gray-200' id="contacts">

Pretty straightforward so far. Now let’s look and see what we’ve to do on the client side.

The Native Bridge

First, we create a new class that will act as our javascript interface and allow us to post javascript messages.

const val CONTACTS = "contacts"

class NativeJavaScriptInterface {

  var onContacts: () -> Unit = {}

    fun postMessage(jsonData: String) {
      if (jsonData == null) {
        Log.d(ContentValues.TAG, "postMessage: ${jsonData}")
      } else {
        val json = JSONObject(jsonData)
          when (val command = json.optString("name")) {
            CONTACTS -> contacts()
              else -> Log.d(ContentValues.TAG, "No function: $command. Add function in ${this::class.simpleName}")

  private fun contacts() = onContacts()

The native bridge is connected to our MainSessionNavHostFragment, which we created in the first post in this series.

class MainSessionNavHostFragment : TurboSessionNavHostFragment() {


    var nativeAppJavaScriptInterface: NativeJavaScriptInterface = NativeJavaScriptInterface()

    override fun onCreateWebView(context: Context): TurboWebView {
      val turboWebView = super.onCreateWebView(context)
        bindNativeBridge(turboWebView, context = context)
        return turboWebView

  private fun bindNativeBridge(webView: TurboWebView, context: Context) {
    nativeAppJavaScriptInterface.onContacts = {
      Handler(Looper.getMainLooper()).post {

        val permissions = arrayOf(

          val requestCode = 123 // This can be any integer value

          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (context.checkSelfPermission(permissions[0]) == PackageManager.PERMISSION_GRANTED &&
                context.checkSelfPermission(permissions[1]) == PackageManager.PERMISSION_GRANTED) {
              // Permissions already granted
              // Do the contact reading operation here
            } else {
              // Permissions not granted
              requestPermissions(permissions, requestCode)
          } else {
            // Permissions not needed in older versions of Android
            // Do the contact reading operation here

        val contactsList: MutableList<String> = mutableListOf()
          val cursor = context.contentResolver.query(

          if (cursor?.count ?: 0 > 0) {
            while (cursor != null && cursor.moveToNext()) {
              val name =


          for (contact in contactsList) {
            Log.d("Contact", contact)
              val script = "Bridge.importingContacts('${contact}')"
              session.webView.evaluateJavascript(script, null)
        Log.d(ContentValues.TAG, "bindNativeBridge onImportContacts")

    webView.addJavascriptInterface(nativeAppJavaScriptInterface, "nativeApp")

    webView.loadData("", "text/html", null)

In our AndroidManifest file, we have to add the following:

<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"

Adding these lines ensures we can access the phone’s contacts.

Now everything is hooked up; when we run our app, we should see that we can import the user’s contacts. Using the JavaScript bridge is another powerful tool that Hotwire gives us, opening up a whole new world with a fraction of the code.


So far, we’ve covered getting up and running, utilising modals to feel more native, and accessing native screens, and today we covered the Javascript bridge. In the following article, we will talk about debugging, structuring your Android project, and external libraries that are useful in the Android ecosystem without going overboard.

Till next time.

Subscribe so you can learn how to build Android, iOS and Rails Apps using Hotwire

© 2023 William Kennedy, Inc. All rights reserved.