Skip to main content

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 versionRuntime permissions to request
Android 12 (API 31) and higherBLUETOOTH_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.LocationUnauthorized otherwise, 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.TerminalNotFound once 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 next getTerminal() returns a new BleTerminal while the old one may still be connected. Call it once, for example in Application.onCreate(), or guard it with isConfigured() as above (the demo app does this).
  • BleService.getTerminal() throws IllegalArgumentException if configure() has not been called.
  • BleService.clear() drops the service and its terminals without disconnecting them: call disconnect() 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 same BleTerminal for the same name on every call, and a terminal has a single listener. Assigning bleListener replaces 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:

  • onSaveAuthKeySuccess and onError(BleError.SaveAuthKeyFailed) from saveAuthKey()
  • onRemoveAuthKeySuccess from removeAuthKey()
  • onError(BleError.KeyNotFound) and onError(BleError.KeyInvalid) from scanAndConnectToPeripheral()
  • onError(BleError.NotConnected) from sendAction()

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 as onError(BleError.SaveAuthKeyFailed).
  • saveAuthKey() does not check the format. A key that is not 64 hexadecimal characters is saved, and connecting then fails with BleError.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 BleTerminal you call it on.
  • terminal.hasKey() and terminal.getAuthKey() (null when 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 gets CtgError.InvalidKey when 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:

StepFailure reported through onError
1. The stored key is checkedKeyNotFound (no key) or KeyInvalid (not 64 hexadecimal characters)
2. The phone is checkedBluetoothUnsupported, BluetoothUnauthorized or LocationUnauthorized
3. The SDK scans for a terminal advertising the name, for up to the timeoutTerminalNotFound
4. The phone opens the Bluetooth connection and the terminal answers the handshake. From 3.0.4 this step is also bounded by the timeoutTerminalNotFound 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 keyCtgError, typically CtgError.InvalidKey when the key does not belong to this vehicle, or CtgError(Timeout) when the terminal does not answer

Then:

  1. 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).
  2. 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 then terminal.getLockState() returns LockState.UNKNOWN.
  3. While the connection is open, the SDK sends a heartbeat to keep it alive and reports the signal strength through onSignalStrength about 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 for onTerminalConnected or onError. A second call while the SDK is scanning is ignored without any callback.
  • Track the attempt yourself. getBleConnectionState() returns CONNECTED only once the terminal has accepted the key; during the scan and the handshake it returns DISCONNECTED. The demo app keeps its own "scanning" state from the call until the first callback.
  • After a connection attempt that ended in a CtgError, call disconnect() before you connect again. The SDK leaves the Bluetooth link as it is after a CtgError.
  • onReconnect() and onSignalStrength() can arrive before onTerminalConnected.

See BleTerminal.scanAndConnectToPeripheral.

Send actions​

terminal.sendAction(BleAction.UNLOCK)
ActionWhat it doesResult callback
LOCKLocks the vehicleonTerminalCommandResult(LOCK), called once the SDK has read the new lock state, so getLockState() is up to date inside the callback
UNLOCKUnlocks the vehicleonTerminalCommandResult(UNLOCK), same as LOCK
UNLOCK_NOKEYFOBUnlocks without activating the key fob: the driver cannot start the engineonTerminalCommandResult(UNLOCK_NOKEYFOB)
HEADLIGHTFlashes the headlightsonTerminalCommandResult(HEADLIGHT)
HORNSounds the hornonTerminalCommandResult(HORN)
GET_LOCK_STATEReads the lock state againonTerminalCommandResult(GET_LOCK_STATE), then read getLockState()
VEHICLE_GET_CONFIGReads the vehicle statisticsonTerminalDidGetVehicleStats(command, stats) with a GetVehicleStats
VEHICLE_GET_STATUSReads the ignition stateonTerminalDidGetVehicleStatus(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 to scanAndConnectToPeripheral(). 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.

See BleAction and LockState.

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.

CallbackWhen it is calledDemo app event line
onTerminalConnected(terminal)The terminal accepted the key: actions can be sent. The lock state is not known yetConnected 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 ignoredVehicle stats: odometer <km>, engine <hours>, fuel <level>
onTerminalDidGetVehicleStatus(command, getVehicleStatus)Answer to VEHICLE_GET_STATUS. ignitionState is true when the ignition is onIgnition 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 attemptShown 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.GattErrorBluetooth 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 connectionDisconnected from <name>
onSaveAuthKeySuccess(terminal, authKey)saveAuthKey() stored the keyKey saved
onRemoveAuthKeySuccess()removeAuthKey() deleted the keyKey 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 CtgError never 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 a TerminalNotFound from step 4 of Connect), is reported after onTerminalDisconnected.
  • The other errors arrive on their own.

BleError​

CodeErrorWhenWhat to do
1BluetoothUnauthorizedAndroid 12 and higher: BLUETOOTH_SCAN or BLUETOOTH_CONNECT is not grantedRequest both at run time
2BluetoothDisabledBluetooth was turned off while the SDK was in useThe 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)
3BluetoothUnsupportedThe phone has no Bluetooth Low EnergyNone: the phone cannot be used
4SaveAuthKeyFailedsaveAuthKey() got an empty key, or the key could not be encryptedFetch the key again and retry
5KeyNotFoundscanAndConnectToPeripheral() found no stored keySave the key first
6KeyInvalidThe stored key is not 64 hexadecimal charactersFetch the key again and save it
7TerminalNotFoundNo terminal with that name was seen before the timeout; from 3.0.4 also when the terminal did not accept the Bluetooth connection in timeSee Troubleshooting
8NotConnectedsendAction() was called with no terminal connectedConnect first
9LocationUnauthorizedNo location permission is granted, or location services are offRequest the permission and ask the user to turn location on
raw GATT statusGattErrorThe Android Bluetooth stack reported an error. GATT_ERROR (133) and GATT_FAILURE (257) are retried 3 times first, with onReconnect each timeConnect again. See GattError for each status
21 to 28CtgErrorThe terminal refused a command or the key, or the SDK gave up on a commandSee the table below

See BleError.

CtgError​

BleError.CtgError.errorCode carries the code of the underlying CtgError.

CodeErrorRaised byWhenWhat to do
21InvalidKeyTerminalThe terminal rejected the key: it belongs to another vehicle, or the key was rotatedFetch the vehicle's key from the Fleet API, save it, call disconnect() and connect again
22TimeoutSDKThe terminal did not answer a command, or (from 3.0.4) the handshake, within the timeoutRetry; if commands keep timing out, disconnect and connect again
23FailTerminalThe terminal refused the command. actionCode identifies it, see CtgError.FailRetry, then contact Cartrack support if it persists
24NoKeyTerminalThe terminal reported that it has no keyContact Cartrack support
25UnsuccessfulSDKThe answer to VEHICLE_GET_CONFIG or VEHICLE_GET_STATUS could not be readRetry
26ScriptFailTerminalThe terminal reported that the command's script failedRetry, then contact Cartrack support if it persists
27NoScriptTerminalThe terminal reported that it has no script for the command. Command availability depends on the vehicle hardware and provisioningContact Cartrack support
28TooManyRequestSDK3 commands are already waiting for an answer; this one was not sentSend 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 calls onTerminalDisconnected(terminal), even when nothing was connected. From 3.0.4 it is called once per disconnect(); with 3.0.3 it was called twice, once immediately and once when the Bluetooth stack confirmed the disconnection.
  • onTerminalDisconnected is also called when the connection drops on its own, so if your app needs to tell the two apart, record that it called disconnect() before calling it.
  • After a disconnection getLockState() returns UNKNOWN until 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​