Skip to main content

Android Native BLE

The native Android shell pattern uses WebView to host @ukeyfe/hardware-common-connect-sdk, JSBridge to move calls across the boundary, and Nordic BLE for scanning, connections, and BLE I/O. The JavaScript layer still calls SDK APIs, while Android owns the transport details and reports results back through the bridge.

If you haven't understood the underlying plug-in interface, read Underlying transport plug-in first, especially the requirements for BLE message headers, notification reassembly, and receive() returning complete messages.

BLE Flow

Android BLE is sensitive to the connection order. Stabilization time is required between pairing, GATT connection, service discovery, and notification turning on; skipping one of these steps may not throw an obvious error, but the SDK will appear to be unable to find the device or be unresponsive for a long time.

OrderCall pointHandling notes
1ensureBonded()Prepare the system bond first, allowing up to 60s.
2connect()Open the GATT session with a 15s connection window.
3delay(500ms)Leave a half-second buffer so the link can settle.
4discoverServices()Enumerate the BLE services exposed by the device.
5delay(500ms)Pause briefly after discovery before sending data.
6startNotifications()Enable notifications last, retrying up to 3 times if needed.

Bond First?

The UKey Wallet device's BLE channel relies on the system pairing process. When pairing is not completed, the following problems may occur:

  • startNotifications() The call appears to be successful, but the notification is not actually turned on.
  • The native layer can see the device, but the JS side receives "Device not found".
  • The connection is occasionally disconnected and restored after retrying, making the problem difficult to reproduce.
suspend fun connectWithBonding(deviceAddress: String) {
// Step 1: First confirm the device has already been paired
val device = bluetoothAdapter.getRemoteDevice(deviceAddress)
if (device.bondState != BluetoothDevice.BOND_BONDED) {
device.createBond()
// Wait for pairing to finish; the user must approve the system dialog
delay(60000) // You can also listen for BOND_BONDED via BroadcastReceiver
}

// Step 2: Open the GATT connection
val gatt = device.connectGatt(context, false, gattCallback)

// Step 3: Give the link a brief moment to settle
delay(500)

// Step 4: Start service discovery (often also triggered in onConnectionStateChange)
gatt.discoverServices()

// Step 5: Pause briefly after discovery completes
delay(500)

// Step 6: Enable notifications with retry support
startNotificationsWithRetry(gatt, maxRetries = 3)
}

suspend fun startNotificationsWithRetry(gatt: BluetoothGatt, maxRetries: Int) {
repeat(maxRetries) { attempt ->
try {
gatt.setCharacteristicNotification(notifyCharacteristic, true)
// Note: Write descriptor to enable notifications
val descriptor = notifyCharacteristic.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
return // Note: success
} catch (e: Exception) {
delay((attempt + 1) * 1000L) // Note: Increasing backoff
}
}
throw Exception("Failed to start notifications after $maxRetries attempts")
}

Timeout Tips

operationtimeout
Pairing (user confirmation)60 seconds
GATT connection15 seconds
delay after connection500ms
service discovery15 seconds
Delay after discovery500ms
Notification settings15 seconds
Retry backoff1s, 2s, 3s

BLE Deps & Params

The Android project needs both the WebView bridge library and the Nordic BLE Kotlin components. JSBridge forwards SDK requests from WebView into native code, while Nordic BLE takes care of scanning, connecting, pairing, discovering services, and subscribing to notifications.

ComponentDependency
WebView JSBridgecom.smallbuer:jsbridge:1.0.7
Nordic BLE Kotlinno.nordicsemi.android.kotlin.ble:scanner:1.1.0
no.nordicsemi.android.kotlin.ble:client:1.1.0

Do not drop Nordic BLE to 1.0.9 or earlier; this scan example enables BleScannerSettings.includeStoredBondedDevices, which starts at 1.1.0.

The service UUIDs, characteristic UUIDs, and 64-byte frame rules are collected in the Low-level transport reference. Native code should reassemble notification chunks into a complete response before handing it back to JS through receive().

Part 1: Gradle & 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)

The Manifest should include both the legacy Bluetooth permissions and the Android 12+ split permissions for scan, advertise, and connect. Even when your minimum version is newer, keeping the legacy entries with maxSdkVersion="30" helps avoid scan failures on Android 11 and below.

<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 permission checking (Kotlin)

Declaring permissions in the Manifest is only the first step. Before scan or connect begins, native code should verify runtime grants; if any are missing, request them from the user and resume the BLE flow only after the callback succeeds.

private const val BLE_PERMISSION_REQUEST_CODE = 1003

private fun ensureBleRuntimePermissions(): Boolean {
val missingPermissions = mutableListOf<String>()

if (
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.BLUETOOTH_SCAN,
) != PackageManager.PERMISSION_GRANTED
) {
missingPermissions += android.Manifest.permission.BLUETOOTH_SCAN
}

if (
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
missingPermissions += 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
) {
missingPermissions += android.Manifest.permission.ACCESS_FINE_LOCATION
}

if (missingPermissions.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
missingPermissions.toTypedArray(),
BLE_PERMISSION_REQUEST_CODE,
)
return false
}

return true
}

This check should pass before invoking a scan, reading a paired device, or establishing a GATT connection. Otherwise, a permission exception may be thrown directly on Android 12+, or the device may not be scanned.

Part 2: Build Web

Build the web bundle from the reference project first, then copy the generated files into the Android project's assets directory:

cd packages/connect-examples/native-android-example/web
yarn && yarn build
# Build output is located at web/web_dist/

Place the full web/web_dist/ directory under app/src/main/assets/web_dist/ in the Android project. Once the assets are in place, point the WebView entry to app/src/main/assets/web_dist/index.html.

The demo web package already initializes the SDK with env: 'lowlevel' and wires the low-level adapter on the JS side. The native layer only needs to provide bridge handlers for enumeration, connection, disconnection, sending and receiving; the business JS inside the WebView usually does not need changes.

Part 3: WebView Bridge

Attach the native handlers before the WebView starts reading the page:

class MainActivity : AppCompatActivity() {
lateinit var bridgeWebView: BridgeWebView
private var activeGattConnection: ClientBleGatt? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bridgeWebView = findViewById(R.id.webview)

configureBridgeWebView()
registerBridgeHandlers()

bridgeWebView.loadUrl("file://android_asset/web_dist/index.html")
}
}

Part 4: BLE Scan

private val primaryServiceUUIDString = listOf(
"00000001",
"0000",
"1000",
"8000",
"00805f9b34fb",
).joinToString("-")
private val primaryServiceUUID: UUID = UUID.fromString(primaryServiceUUIDString)
private val bleScanner by lazy { BleScanner(this) }

private fun startScan(onResult: (List<UKeyDeviceInfo>) -> Unit) {
val settings = BleScannerSettings(
scanMode = BleScanMode.LOW_LATENCY,
filter = BleScanFilter(serviceUUIDs = listOf(FilteredServiceUuid(primaryServiceUUID))),
includeStoredBondedDevices = true, // Requires Nordic BLE 1.1.0+
)
// 其余逻辑按需补充
}
bridgeWebView.registerHandler("connect", BridgeHandler { requestPayload, bridgeCallback ->
lifecycleScope.launch(Dispatchers.IO) {
val targetMacAddress = JsonParser.parseString(requestPayload).asJsonObject.get("mac").asString
activeGattConnection = ClientBleGatt.getInstance(this@MainActivity, targetMacAddress)
// Retrieve features and start notifications
rxCharacteristic?.getNotifications()?.onEach { packet ->
val hexPayload = DataByteArray(packet.value).toHexString()
bridgeWebView.callHandler("monitorCharacteristic", hexPayload)
}?.launchIn(lifecycleScope)

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

Part 6: Send & Close

The send processor is responsible for writing the hexadecimal string passed in from the JS side into the BLE write feature; the disconnect processor is responsible for actively releasing the current GATT connection. Both must explicitly tell the JS side whether the operation is completed through the bridge callback, otherwise the upper SDK will keep waiting for the result.

bridgeWebView.registerHandler("send", BridgeHandler { requestPayload, bridgeCallback ->
val json = JsonParser.parseString(requestPayload).asJsonObject
val hexPayload = json.get("data").asString
val outboundBytes = DataByteArray.from(hexPayload).value

lifecycleScope.launch(Dispatchers.IO) {
txCharacteristic?.write(outboundBytes)
withContext(Dispatchers.Main) {
bridgeCallback.onCallBack("{\"success\":true}")
}
}
})

bridgeWebView.registerHandler("disconnect", BridgeHandler { _, bridgeCallback ->
activeGattConnection?.disconnect()
activeGattConnection = null
bridgeCallback.onCallBack("{\"success\":true}")
})

If writing fails or the connection is disconnected, return JSON with error information and convert it into a failure result that can be recognized by the SDK on the JS side. This way the application layer can prompt the user to reconnect instead of staying in an unresponsive state.

Part 7: JS Web Adapter

The demo web project already has the low-level transport adapter wired in: the SDK starts with env: 'lowlevel', then reaches native enumerate, connect, disconnect, send and receive through the bridge. A typical integration only needs to build and copy web/web_dist/, then load file://android_asset/web_dist/index.html.

If you need to modify the adapter, the core principle remains the same: the JS side only maintains SDK calls and UI events, and the actual scanning, connection, writing, notification listening and message reassembly are all done in the Android native layer.

Part 8: UI Events

PIN, Passphrase and confirmation class interactions are still monitored by the JS side after UI_EVENT responds. The native layer can only be responsible for BLE, or it can also provide additional native pop-up windows, and then pass the user results back to JS.

  • Keep PIN on the device: payload: '@@UKEY_INPUT_PIN_IN_DEVICE'
  • Let the device handle Passphrase: { passphraseOnDevice: true, value: '' }

For the complete writing method of event monitoring and response, please refer to event configuration. If you want to create a production-level pop-up window, you can reuse the minimal interaction mode in the WebUSB guide and replace it with the Android native UI.

Part 9: Final Checks

CheckpointWhat to check
Nordic BLE versionStay on 1.1.0 or newer so includeStoredBondedDevices is available.
Bridge orderBring up the bridge handlers before loading the HTML bundle, otherwise the web side may speak first.
Android 12+ permissionsComplete runtime permission requests before any scan or connection attempt.
Device identifiersAfter the first successful connection, keep connectId around and refresh device_id with getFeatures(connectId).