Skip to Content

Android: Connect via Bluetooth (Native)

Note: Demo: Native Android example (WebView + JSBridge + Nordic BLE) → native-android-example  This guide builds on the Low-Level Transport Plugin — read that first for BLE message format and protocol details.

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.

Critical: BLE Connection Flow

Android BLE requires a specific connection sequence. Skipping steps will cause silent failures.

1. ensureBonded() // Bond BEFORE connecting (60s timeout) 2. connect() // GATT connection (15s timeout) 3. delay(500ms) // Wait for stability 4. discoverServices() // Service discovery 5. delay(500ms) // Wait after discovery 6. startNotifications() // With retry mechanism (3 attempts)

Why Bonding First?

OneKey devices require a secure BLE bond. Without bonding:

  • startNotifications() may fail silently
  • Device responds but SDK reports “Device not found”
  • Intermittent connection drops

Connection Code Example

suspend fun connectWithBonding(deviceAddress: String) { // Step 1: Ensure bonded val device = bluetoothAdapter.getRemoteDevice(deviceAddress) if (device.bondState != BluetoothDevice.BOND_BONDED) { device.createBond() // Wait for bonding (user must confirm pairing dialog) delay(60000) // or use BroadcastReceiver for BOND_BONDED } // Step 2: GATT connect val gatt = device.connectGatt(context, false, gattCallback) // Step 3: Wait for stability delay(500) // Step 4: Discover services (triggered in onConnectionStateChange) gatt.discoverServices() // Step 5: Wait after discovery delay(500) // Step 6: Start notifications with retry startNotificationsWithRetry(gatt, maxRetries = 3) } suspend fun startNotificationsWithRetry(gatt: BluetoothGatt, maxRetries: Int) { repeat(maxRetries) { attempt -> try { gatt.setCharacteristicNotification(notifyCharacteristic, true) // Write descriptor to enable notifications val descriptor = notifyCharacteristic.getDescriptor(CCCD_UUID) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) return // Success } catch (e: Exception) { delay((attempt + 1) * 1000L) // Incremental backoff } } throw Exception("Failed to start notifications after $maxRetries attempts") }
OperationTimeout
Bonding (user confirmation)60 seconds
GATT connect15 seconds
Post-connect delay500ms
Service discovery15 seconds
Post-discovery delay500ms
Notification setup15 seconds
Retry backoff1s, 2s, 3s

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: See Low-Level Transport Plugin - Quick Reference

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 on