Android SDK: Integration guide
This guide walks through a complete integration of the MiKey Android SDK in the order your app runs it: check the prerequisites, install the aar, configure the SDK once, get the terminal, save the key, connect, send actions, handle every callback and error, and disconnect.
The demo app in the SDK package (the mikey-sdk-android/ project, app/src/main/java/com/cartrack/cartrackble/MainActivity.kt) is the worked example: it makes the same calls in the same order, and its event list writes one line for every SDK callback. Those lines are quoted below so you can compare your app with the demo on the same vehicle.
The per-class pages under API Reference hold the full signatures; this guide links to them rather than repeating them. Behaviour that changed in SDK 3.0.4 is marked "from 3.0.4".
Before you start
You need:
- The SDK package. It is provided on request: see Request SDK access.
- The Bluetooth name the terminal in the vehicle advertises. The SDK finds the terminal by this name. If you do not know it, ask Cartrack support.
- Access to the Cartrack Fleet API, to fetch the vehicle's key.
- A physical Android phone with Bluetooth Low Energy. The Android emulator has no BLE.
Android versions
The SDK requires Android 7.0 (API 24) or higher. The runtime permissions your app must request depend on the Android version:
| Android version | Runtime permissions to request |
|---|---|
| Android 12 (API 31) and higher | BLUETOOTH_SCAN, BLUETOOTH_CONNECT and ACCESS_FINE_LOCATION |
| Android 7.0 to 11 (API 24 to 30) | ACCESS_FINE_LOCATION |
The SDK manifest declares every permission it uses (BLUETOOTH, BLUETOOTH_ADMIN, BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION), and they are merged into your app at build time. Declaring them in your own manifest is optional. Granting them at run time is your app's job: the SDK only checks them when you connect.
Runtime permissions
Request the permissions before the first connection. This is how the demo app does it:
private val requiredPermissions: List<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION,
)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result.values.all { it }) {
// All granted: connect
}
}
// Before connecting:
permissionLauncher.launch(requiredPermissions.toTypedArray())
The SDK needs the location permission for Bluetooth scanning only: it never reads the phone's location and sends no data anywhere (see Privacy and data).
When a permission is missing, connecting fails with BleError.BluetoothUnauthorized (Bluetooth permissions; from 3.0.4 also when only one of BLUETOOTH_SCAN and BLUETOOTH_CONNECT is granted) or BleError.LocationUnauthorized (location).
Location services and Bluetooth
- Location services must be on. The SDK checks that the GPS or the network location provider is enabled and reports
BleError.LocationUnauthorizedotherwise, even when the permission is granted. - Bluetooth must be on before you connect. The SDK does not check it up front: with Bluetooth off the scan cannot start, and the attempt ends in
BleError.TerminalNotFoundonce the timeout has passed. Check the adapter and ask the user to turn Bluetooth on, as the demo app does:
private val enableBluetoothLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
// Bluetooth is on: connect
}
}
val adapter = (getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
if (adapter?.isEnabled != true) {
enableBluetoothLauncher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
}
Connect from the foreground
Start the connection from a visible screen with the screen on. Android delivers no results to the SDK's scan while the screen is off, so a connection started then ends in BleError.TerminalNotFound. See Troubleshooting.
Install the aar
Copy the .aar file from the SDK package into your app module, for example under app/libs/, and add it as a file dependency in your app-level build file. No repository configuration is needed. Use the file name of the aar you received, for example blesdk-release_v3.0.5.aar:
// build.gradle
dependencies {
implementation files('libs/blesdk-release_v<version>.aar')
}
// build.gradle.kts
dependencies {
implementation(files("libs/blesdk-release_v<version>.aar"))
}
A local aar carries no dependency information, so Gradle does not add the SDK's own dependencies for you. Your app must include the Kotlin standard library and AndroidX Core (androidx.core:core or androidx.core:core-ktx), which most Android apps already do.
The SDK classes live in two packages:
import com.cartrack.blesdk.ctg.* // BleService, BleTerminal, BleListener
import com.cartrack.blesdk.enumerations.* // BleAction, BleError, CtgError, LockState, ...
Configure the SDK once
Configure the SDK once, with the application context, before any other call:
if (!BleService.isConfigured()) BleService.configure(applicationContext)
- Pass the application context, not an Activity: the SDK keeps it for its whole lifetime.
configure()creates a new service every time it is called. A second call drops the terminals created so far, and the nextgetTerminal()returns a newBleTerminalwhile the old one may still be connected. Call it once, for example inApplication.onCreate(), or guard it withisConfigured()as above (the demo app does this).BleService.getTerminal()throwsIllegalArgumentExceptionifconfigure()has not been called.BleService.clear()drops the service and its terminals without disconnecting them: calldisconnect()on a connected terminal first.
See BleService.
Get the terminal and attach your listener
val terminal: BleTerminal = BleService.getTerminal(terminalName) // the Bluetooth name the terminal advertises
terminal.bleListener = listener // your BleListener, see "Handle every callback"
- The name is compared exactly with the name the terminal advertises: it is case-sensitive and is not trimmed.
getTerminal()returns the sameBleTerminalfor the same name on every call, and a terminal has a single listener. AssigningbleListenerreplaces the previous listener, so if two screens use the same terminal, only the last one to assign its listener receives the callbacks.- Attach the listener before calling anything else on the terminal: some callbacks are called before the method returns (see Threading).
See BleTerminal.
Threading
Call the SDK from the main thread, as the demo app does. Most callbacks are posted to the main thread. A few are called synchronously, on the calling thread, before the method returns:
onSaveAuthKeySuccessandonError(BleError.SaveAuthKeyFailed)fromsaveAuthKey()onRemoveAuthKeySuccessfromremoveAuthKey()onError(BleError.KeyNotFound)andonError(BleError.KeyInvalid)fromscanAndConnectToPeripheral()onError(BleError.NotConnected)fromsendAction()
Save the key
Each vehicle has a Bluetooth key managed by the Fleet API: fetch it with GET /mikey/{registration}, or the keys of all your vehicles with GET /mikey. The bluetooth_key field holds 32 bytes as 64 hexadecimal characters. Hand it to the SDK:
terminal.saveAuthKey(bluetoothKey)
- The key is encrypted with the Android Keystore and stored in your app's default Shared Preferences. On success the SDK calls
onSaveAuthKeySuccess(terminal, authKey). An empty key, or a key that could not be encrypted, is reported asonError(BleError.SaveAuthKeyFailed). saveAuthKey()does not check the format. A key that is not 64 hexadecimal characters is saved, and connecting then fails withBleError.KeyInvalid. Check the length before saving, as the demo app does.- The SDK stores one key per app, not one per terminal. Saving the key of another vehicle replaces the current one, whichever
BleTerminalyou call it on. terminal.hasKey()andterminal.getAuthKey()(nullwhen no key is stored) read the stored key locally, without network access.- You can rotate a vehicle's key with
PUT /mikey/{registration}. The former key stays active until the new key reaches the terminal; after that, a phone that still holds the former key getsCtgError.InvalidKeywhen connecting and must fetch and save the key again.
Connect
terminal.scanAndConnectToPeripheral() // timeout: 10 000 ms
terminal.scanAndConnectToPeripheral(20_000L) // timeout in milliseconds
A connection goes through these steps, and each one can end it with an error:
| Step | Failure reported through onError |
|---|---|
| 1. The stored key is checked | KeyNotFound (no key) or KeyInvalid (not 64 hexadecimal characters) |
| 2. The phone is checked | BluetoothUnsupported, BluetoothUnauthorized or LocationUnauthorized |
| 3. The SDK scans for a terminal advertising the name, for up to the timeout | TerminalNotFound |
| 4. The phone opens the Bluetooth connection and the terminal answers the handshake. From 3.0.4 this step is also bounded by the timeout | TerminalNotFound if the connection was not accepted in time, CtgError(Timeout) if the terminal did not answer the handshake (both from 3.0.4), GattError |
| 5. The terminal checks the key | CtgError, typically CtgError.InvalidKey when the key does not belong to this vehicle, or CtgError(Timeout) when the terminal does not answer |
Then:
onTerminalConnected(terminal)is called. With the terminal in range this takes 3 to 5 seconds from the call (from 3.0.4; about 7 to 8 seconds with 3.0.3).- Right after, the SDK reads the lock state on its own and reports it as
onTerminalCommandResult(BleAction.GET_LOCK_STATE), although your app did not send that action. Until thenterminal.getLockState()returnsLockState.UNKNOWN. - While the connection is open, the SDK sends a heartbeat to keep it alive and reports the signal strength through
onSignalStrengthabout every 7 seconds.
Things to know:
- The timeout applies to each step, not to the whole attempt. The scan, then (from 3.0.4) the connection and handshake, then each authentication message can each take up to the timeout, so a failure can be reported well after the timeout has passed. Once connected, the same value is how long each command waits for its answer.
- Call
scanAndConnectToPeripheral()once and wait foronTerminalConnectedoronError. A second call while the SDK is scanning is ignored without any callback. - Track the attempt yourself.
getBleConnectionState()returnsCONNECTEDonly once the terminal has accepted the key; during the scan and the handshake it returnsDISCONNECTED. The demo app keeps its own "scanning" state from the call until the first callback. - After a connection attempt that ended in a
CtgError, calldisconnect()before you connect again. The SDK leaves the Bluetooth link as it is after aCtgError. onReconnect()andonSignalStrength()can arrive beforeonTerminalConnected.
See BleTerminal.scanAndConnectToPeripheral.
Send actions
terminal.sendAction(BleAction.UNLOCK)
| Action | What it does | Result callback |
|---|---|---|
LOCK | Locks the vehicle | onTerminalCommandResult(LOCK), called once the SDK has read the new lock state, so getLockState() is up to date inside the callback |
UNLOCK | Unlocks the vehicle | onTerminalCommandResult(UNLOCK), same as LOCK |
UNLOCK_NOKEYFOB | Unlocks without activating the key fob: the driver cannot start the engine | onTerminalCommandResult(UNLOCK_NOKEYFOB) |
HEADLIGHT | Flashes the headlights | onTerminalCommandResult(HEADLIGHT) |
HORN | Sounds the horn | onTerminalCommandResult(HORN) |
GET_LOCK_STATE | Reads the lock state again | onTerminalCommandResult(GET_LOCK_STATE), then read getLockState() |
VEHICLE_GET_CONFIG | Reads the vehicle statistics | onTerminalDidGetVehicleStats(command, stats) with a GetVehicleStats |
VEHICLE_GET_STATUS | Reads the ignition state | onTerminalDidGetVehicleStatus(command, status) with a GetVehicleStatus |
Failures arrive in onError:
BleError.NotConnected: no terminal is connected. The action is not sent.CtgError.TooManyRequest: at most 3 commands can wait for an answer at the same time. The SDK rejects a fourth one without sending it; send it again once a result or an error has arrived. The SDK's own lock-state read right after connecting counts as one of the 3.CtgError.Timeout: the terminal did not answer within the timeout given toscanAndConnectToPeripheral(). The connection stays open.- Any other
CtgError: the terminal refused the command or its answer could not be read. See Error codes.
A CtgError never closes the connection, and the callbacks carry no request identifier: an error does not say which command it belongs to, except CtgError.Fail, whose actionCode identifies the command (see CtgError). If your app must know which command failed, send one at a time and wait for its result.
From 3.0.4, up to 3 commands sent at once are all delivered. With 3.0.3, a command sent while another was waiting for its answer could be lost without any callback: send one command at a time and wait for its result.
Handle every callback
Every method of BleListener must be implemented. The third column is the line the demo app writes to its event list for that callback, so you can compare a session in your app with the same session in the demo.
| Callback | When it is called | Demo app event line |
|---|---|---|
onTerminalConnected(terminal) | The terminal accepted the key: actions can be sent. The lock state is not known yet | Connected to <name> |
onTerminalCommandResult(action) | LOCK, UNLOCK, UNLOCK_NOKEYFOB, HEADLIGHT, HORN or GET_LOCK_STATE succeeded. Also called with GET_LOCK_STATE once right after connecting, without your app sending it | <action> done, lock state <state> |
onTerminalDidGetVehicleStats(command, getVehicleStats) | Answer to VEHICLE_GET_CONFIG. command is the raw terminal command byte and can be ignored | Vehicle stats: odometer <km>, engine <hours>, fuel <level> |
onTerminalDidGetVehicleStatus(command, getVehicleStatus) | Answer to VEHICLE_GET_STATUS. ignitionState is true when the ignition is on | Ignition on or Ignition off |
onSignalStrength(strength, rssiValue) | About every 7 seconds while connected, as STRONG, GOOD or WEAK with the raw RSSI. Can also arrive during the connection attempt | Shown in the header as Signal <strength> (<rssi> dBm) |
onReconnect() | The Bluetooth stack returned GATT_ERROR (133) or GATT_FAILURE (257) and the SDK is retrying. After 3 retries it reports BleError.GattError | Bluetooth stack reconnecting |
onTerminalDisconnected(terminal) | The connection is closed: your app called disconnect() (always reported, even when nothing was connected), the terminal went out of range or was powered off, Bluetooth was turned off, or an error ended the connection | Disconnected from <name> |
onSaveAuthKeySuccess(terminal, authKey) | saveAuthKey() stored the key | Key saved |
onRemoveAuthKeySuccess() | removeAuthKey() deleted the key | Key removed |
onError(bleError) | Something failed: see Error codes | [<errorCode>] <localizedDescription> |
A typical session in the demo app reads as follows (each line is prefixed with the time):
Scanning for <name> (10 s)
Connected to <name>
Read lock state done, lock state Locked
Unlock sent
Unlock done, lock state Unlocked
Disconnected from <name>
The third line is the SDK's own lock-state read; "Scanning for" and "Unlock sent" are written by the demo app itself when it calls the SDK.
A listener with every method:
val terminal = BleService.getTerminal(terminalName)
val listener = object : BleListener {
override fun onTerminalConnected(terminal: BleTerminal) {
// Ready for actions. getLockState() is UNKNOWN until the first GET_LOCK_STATE result.
}
override fun onTerminalCommandResult(action: BleAction) {
// Also called once with GET_LOCK_STATE right after connecting.
val lockState: LockState = terminal.getLockState()
}
override fun onTerminalDidGetVehicleStats(command: Byte, getVehicleStats: GetVehicleStats) {
val odometerKm: Int? = getVehicleStats.odometer
}
override fun onTerminalDidGetVehicleStatus(command: Byte, getVehicleStatus: GetVehicleStatus) {
val ignitionOn: Boolean = getVehicleStatus.ignitionState == true
}
override fun onSignalStrength(strength: BleSignalStrength, rssiValue: Int) {}
override fun onReconnect() {}
override fun onTerminalDisconnected(terminal: BleTerminal) {
// Also called after your own disconnect(), and when nothing was connected.
}
override fun onSaveAuthKeySuccess(terminal: BleTerminal, authKey: String) {}
override fun onRemoveAuthKeySuccess() {}
override fun onError(bleError: BleError) {
when (bleError) {
is BleError.CtgError -> {
// The connection, if any, is still open.
// From 3.0.4, bleError.ctgError also gives the CtgError itself.
when (bleError.errorCode) {
CtgError.InvalidKey.errorCode -> { /* fetch and save the key again */ }
CtgError.TooManyRequest.errorCode -> { /* send again after the next result */ }
else -> { /* see the CtgError table */ }
}
}
BleError.TerminalNotFound -> { /* see Troubleshooting */ }
else -> { /* see the BleError table */ }
}
}
}
terminal.bleListener = listener
Error codes
Every error reaches your app through onError(bleError: BleError). Match on the type (is BleError.GattError, BleError.KeyNotFound, ...) rather than on errorCode: BleError codes run from 1 to 9 and CtgError codes from 21 to 28, but BleError.GattError.errorCode is the raw Android GATT status, which can coincide with a BleError code (5 is both KeyNotFound and GATT_INSUFFICIENT_AUTHENTICATION). From 3.0.4, BleError.CtgError.ctgError and BleError.GattError.gattError give you the underlying error to match on.
Handle onError and onTerminalDisconnected independently:
- A
CtgErrornever closes the connection and arrives on its own. - Any other error that ends a connection, or a connection attempt that has reached the terminal (for example
GattError, or from 3.0.4 aTerminalNotFoundfrom step 4 of Connect), is reported afteronTerminalDisconnected. - The other errors arrive on their own.
BleError
| Code | Error | When | What to do |
|---|---|---|---|
| 1 | BluetoothUnauthorized | Android 12 and higher: BLUETOOTH_SCAN or BLUETOOTH_CONNECT is not granted | Request both at run time |
| 2 | BluetoothDisabled | Bluetooth was turned off while the SDK was in use | The SDK reports onTerminalDisconnected as soon as Bluetooth starts turning off: handle that callback rather than waiting for this error. Before connecting, check that Bluetooth is on yourself (see Location services and Bluetooth) |
| 3 | BluetoothUnsupported | The phone has no Bluetooth Low Energy | None: the phone cannot be used |
| 4 | SaveAuthKeyFailed | saveAuthKey() got an empty key, or the key could not be encrypted | Fetch the key again and retry |
| 5 | KeyNotFound | scanAndConnectToPeripheral() found no stored key | Save the key first |
| 6 | KeyInvalid | The stored key is not 64 hexadecimal characters | Fetch the key again and save it |
| 7 | TerminalNotFound | No terminal with that name was seen before the timeout; from 3.0.4 also when the terminal did not accept the Bluetooth connection in time | See Troubleshooting |
| 8 | NotConnected | sendAction() was called with no terminal connected | Connect first |
| 9 | LocationUnauthorized | No location permission is granted, or location services are off | Request the permission and ask the user to turn location on |
| raw GATT status | GattError | The Android Bluetooth stack reported an error. GATT_ERROR (133) and GATT_FAILURE (257) are retried 3 times first, with onReconnect each time | Connect again. See GattError for each status |
| 21 to 28 | CtgError | The terminal refused a command or the key, or the SDK gave up on a command | See the table below |
See BleError.
CtgError
BleError.CtgError.errorCode carries the code of the underlying CtgError.
| Code | Error | Raised by | When | What to do |
|---|---|---|---|---|
| 21 | InvalidKey | Terminal | The terminal rejected the key: it belongs to another vehicle, or the key was rotated | Fetch the vehicle's key from the Fleet API, save it, call disconnect() and connect again |
| 22 | Timeout | SDK | The terminal did not answer a command, or (from 3.0.4) the handshake, within the timeout | Retry; if commands keep timing out, disconnect and connect again |
| 23 | Fail | Terminal | The terminal refused the command. actionCode identifies it, see CtgError.Fail | Retry, then contact Cartrack support if it persists |
| 24 | NoKey | Terminal | The terminal reported that it has no key | Contact Cartrack support |
| 25 | Unsuccessful | SDK | The answer to VEHICLE_GET_CONFIG or VEHICLE_GET_STATUS could not be read | Retry |
| 26 | ScriptFail | Terminal | The terminal reported that the command's script failed | Retry, then contact Cartrack support if it persists |
| 27 | NoScript | Terminal | The terminal reported that it has no script for the command. Command availability depends on the vehicle hardware and provisioning | Contact Cartrack support |
| 28 | TooManyRequest | SDK | 3 commands are already waiting for an answer; this one was not sent | Send it again after the next result or error |
See CtgError.
Disconnect and clean up
terminal.disconnect()
disconnect()stops a scan in progress, closes the connection and always callsonTerminalDisconnected(terminal), even when nothing was connected. From 3.0.4 it is called once perdisconnect(); with 3.0.3 it was called twice, once immediately and once when the Bluetooth stack confirmed the disconnection.onTerminalDisconnectedis also called when the connection drops on its own, so if your app needs to tell the two apart, record that it calleddisconnect()before calling it.- After a disconnection
getLockState()returnsUNKNOWNuntil the next connection. - Disconnect when the user leaves the screen that controls the vehicle. The demo app disconnects in
onPause().
When the rental ends, delete the key from the phone:
terminal.removeAuthKey()
removeAuthKey() deletes the stored key and calls onRemoveAuthKeySuccess(). It does not disconnect, and it does not change the key on the terminal: to stop the key from working, rotate it with PUT /mikey/{registration}.
Next steps
- Troubleshooting: what the questions integrators ask most often come down to.
- API reference: BleService, BleTerminal, BleListener, BleError, CtgError, GattError.
- Release notes.