Android: Connect via Bluetooth

Demo: Native Android example (WebView + JSBridge + Nordic BLE) → native-android-example

This guide shows how to integrate @onekeyfe/hd-common-connect-sdk in a native Android host via a low-level adapter. The JavaScript bundle runs in a WebView; transport calls are forwarded to native (Nordic BLE) and bridged back to JS.

Key libraries:

  • WebView JS bridge: com.smallbuer:jsbridge:1.0.7

  • Nordic BLE (Kotlin, ≥ 1.1.0):

    • no.nordicsemi.android.kotlin.ble:scanner:1.1.0

    • no.nordicsemi.android.kotlin.ble:client:1.1.0

    • Reason: includeStoredBondedDevices in BleScannerSettings requires > 1.0.9

OneKey BLE UUIDs:

  • Service: 00000001-0000-1000-8000-00805f9b34fb

  • Write: 00000002-0000-1000-8000-00805f9b34fb

  • Notify: 00000003-0000-1000-8000-00805f9b34fb

Step 1. Gradle and Manifest

Gradle (app/build.gradle.kts):

dependencies {
  implementation("com.smallbuer:jsbridge:1.0.7")
  implementation("no.nordicsemi.android.kotlin.ble:scanner:1.1.0")
  implementation("no.nordicsemi.android.kotlin.ble:client:1.1.0")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Android 12+ permissions (AndroidManifest.xml):

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>

Runtime permissions (Kotlin):

private fun ensureBluetoothPermissions(): Boolean {
  val needs = mutableListOf<String>()
  if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) needs += android.Manifest.permission.BLUETOOTH_SCAN
  if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) needs += android.Manifest.permission.BLUETOOTH_CONNECT
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) needs += android.Manifest.permission.ACCESS_FINE_LOCATION
  if (needs.isNotEmpty()) {
    ActivityCompat.requestPermissions(this, needs.toTypedArray(), 1001)
    return false
  }
  return true
}

Step 2. Build the web bundle (already implemented in the demo)

Inside the hardware-js-sdk repo:

cd packages/connect-examples/native-android-example/web
yarn && yarn build   # emits web/web_dist/

Copy the entire output folder into your Android project:

  • Copy web/web_dist/app/src/main/assets/web_dist/

  • The entry file will be available as: app/src/main/assets/web_dist/index.html

No further JS work is required in the native app; the demo’s web bundle already initializes env: 'lowlevel' and wires the low-level adapter.

Step 3. WebView + Bridge (register before loading)

class MainActivity : AppCompatActivity() {
  lateinit var webview: BridgeWebView

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    webview = findViewById(R.id.webview)
    configureWebView()

    // Register native handlers BEFORE loading html
    registerHandlers()

    // Load built HTML bundle copied into assets/web_dist/
    webview.loadUrl("file:///android_asset/web_dist/index.html")
  }

  private fun configureWebView() {
    val s = webview.settings
    s.javaScriptEnabled = true
    s.domStorageEnabled = true
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) s.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
  }
}

Step 4. BLE scan (include stored bonded devices)

private val ONEKEY_SERVICE_UUID: UUID = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb")
private val bleScanner by lazy { BleScanner(this) }
private val aggregator = BleScanResultAggregator()

data class OneKeyDeviceInfo(val id: String, val name: String)

private fun startScan(onResult: (List<OneKeyDeviceInfo>) -> Unit) {
  if (!ensureBluetoothPermissions()) return

  val settings = BleScannerSettings(
    scanMode = BleScanMode.LOW_LATENCY,
    filter = BleScanFilter(serviceUUIDs = listOf(FilteredServiceUuid(ONEKEY_SERVICE_UUID))),
    includeStoredBondedDevices = true,               // requires Nordic BLE >= 1.1.0
  )

  aggregator.reset()
  bleScanner.scan(settings)
    .map { aggregator.aggregate(it) }
    .onEach { results ->
      val devices = results.mapNotNull { r -> r.device }
        .distinctBy { it.address }
        .map { OneKeyDeviceInfo(id = it.address, name = it.name ?: "") }
      onResult(devices)
    }
    .launchIn(lifecycleScope)
}

Bridge handler for enumerate:

private fun registerHandlers() {
  webview.registerHandler("enumerate", BridgeHandler { _, cb ->
    startScan { list -> cb.onCallBack(Gson().toJson(list)) }
  })

  // The rest of handlers are shown below
}

Step 5. Connect, characteristics, notifications

private var connection: ClientBleGatt? = null
private var writeCh: ClientBleGattCharacteristic? = null
private var notifyCh: ClientBleGattCharacteristic? = null

webview.registerHandler("connect", BridgeHandler { data, cb ->
  if (!ensureBluetoothPermissions()) return@BridgeHandler
  val json = JsonParser.parseString(data).asJsonObject
  val mac = json.get("uuid").asString

  lifecycleScope.launch(Dispatchers.IO) {
    val gatt = ClientBleGatt.getInstance(this@MainActivity, mac)
    connection = gatt

    writeCh = gatt.getCharacteristic(ONEKEY_SERVICE_UUID, UUID.fromString("00000002-0000-1000-8000-00805f9b34fb"))
    notifyCh = gatt.getCharacteristic(ONEKEY_SERVICE_UUID, UUID.fromString("00000003-0000-1000-8000-00805f9b34fb"))

    notifyCh?.getNotifications()?.onEach { packet ->
      // Forward hex to JS receiver
      val hex = DataByteArray(packet.value).toHexString()
      webview.callHandler("monitorCharacteristic", hex)
    }?.launchIn(lifecycleScope)

    withContext(Dispatchers.Main) { cb.onCallBack("{\"success\":true}") }
  }
})

Step 6. Send / disconnect

webview.registerHandler("send", BridgeHandler { data, cb ->
  val json = JsonParser.parseString(data).asJsonObject
  val hex = json.get("data").asString
  val bytes = DataByteArray.from(hex).value
  lifecycleScope.launch(Dispatchers.IO) {
    writeCh?.write(bytes)
    withContext(Dispatchers.Main) { cb.onCallBack("{\"success\":true}") }
  }
})

webview.registerHandler("disconnect", BridgeHandler { _, cb ->
  connection?.disconnect()
  cb.onCallBack("{\"success\":true}")
})

Step 7. JavaScript bundle (low-level adapter)

The demo’s web project already builds a bundle that initializes the SDK with env: 'lowlevel' and wires the low-level adapter. You typically do NOT need to write extra JS — just build and copy web/web_dist/ into app/src/main/assets/web_dist/ and load file:///android_asset/web_dist/index.html.

If you customize the adapter, the core idea remains: initialize with env: 'lowlevel' and forward enumerate/connect/disconnect/send/receive via the bridge.

Step 8. UI events (PIN / Passphrase)

Handle UI_EVENT in your JS bundle and respond with HardwareSDK.uiResponse. See Config Event for event wiring and the WebUSB guide for minimal, production-ready dialogs.

  • PIN on device: payload: '@@ONEKEY_INPUT_PIN_IN_DEVICE'

  • Passphrase on device: { passphraseOnDevice: true, value: '' }

Step 9. Checklist

  • Nordic BLE library version ≥ 1.1.0 to use includeStoredBondedDevices.

  • Register handlers before loading the HTML to avoid race conditions.

  • Request runtime permissions on Android 12+ before scanning/connecting.

  • Persist connectId (MAC) and cache device_id via getFeatures(connectId) after the first connection.

References

Last updated

Was this helpful?