Link Mobile SDK Android
This page is the integration guide for embedding the Link Mobile Android SDK. Classes, packages, and methods are covered in the hosted API reference.
Overview
The Link Mobile Android SDK integrates your app with Last Lock devices over BLE and runs Proxy so locks can exchange encrypted traffic with Link via the handset.
Example integration apps: last-lock/link-mobile-sdk-prod — examples on GitHub.
Prerequisites
- GitHub access to the SDK packages
- PAT with
repoandread:packages(used as Gradle credentials) - Android Studio Giraffe or newer
- Physical Android device
- Kotlin / toolchain versions required by the SDK release you use (see release notes and hosted docs)
Link allowlisting
For each Android app that connects to Link, add its package name and signing certificate SHA-256 to the plane attestation allowlist. Link verifies both values from the Android key-attestation certificate. A package name by itself is not sufficient for certificate-based Android attestation.
| Item | Detail |
|---|---|
| Package name | Your Gradle applicationId for that variant — the same value as defaultConfig.applicationId and the final package in the merged manifest. For example, com.example.app. |
| Signing certificate SHA-256 | The SHA-256 fingerprint of the certificate that signs the installed APK. Debug, release, and Play App Signing builds commonly use different certificates. |
| Product flavors | If product flavors change applicationId, add a separate android_attestation_apps entry for each package. Add every certificate that can sign that package. |
| Device integrity | Attestation requires hardware-backed keys and a device state Link accepts (non–software-only security level, verified boot expectations per Link’s policy). Rooted or heavily modified devices may fail. |
Provide Last Lock the package name and certificate fingerprint for every build you intend to distribute to allow the app to connect and communicate with Link via the SDK:
android_attestation_apps: {
package_name: "com.example.linkmobile_android_example"
signing_cert_sha256: "YOUR_SIGNING_CERTIFICATE_SHA256"
}
To find the default Android debug certificate fingerprint on macOS or Linux, run:
keytool -list -v \
-keystore ~/.android/debug.keystore \
-alias androiddebugkey \
-storepass android \
-keypass android | grep SHA256
For a production app, obtain the SHA-256 from the certificate used for the
distributed APK or from Play App Signing. The legacy android_apps field is
for package-only entries; use android_attestation_apps for new integrations.
Runtime requirements
- Internet is required when the SDK runs the attestation and assertion exchange with Link.
Allowlist updates are applied on the Link side (operator tooling / plane configuration). Until the installed app's exact package and signing certificate are registered, attestation will fail and plane connectivity (Proxy, streams) will not be available. Coordinate timing with your Last Lock contact.
Setup
Gradle credentials (gpr.user / gpr.key)
# ~/.gradle/gradle.properties
gpr.user=github_username
gpr.key=github_PAT
Importing the SDK
Add the GitHub Packages Maven repository and the SDK dependency. Exact com.lastlock coordinates and version strings are release-specific—copy them from your onboarding email or the same tag you selected on the docs site.
// libs.versions.toml (example — align with your repo)
kotlin = "2.2.20"
// settings.gradle.kts
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/last-lock/link-mobile-sdk-prod")
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: ""
password = providers.gradleProperty("gpr.key").orNull ?: ""
}
}
// jitpack required for importing deps not published on Google/MavenCentral
maven { url = uri("https://jitpack.io") }
}
}
// build.gradle.kts (illustrative SDK / API levels — follow current SDK release)
android {
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 36
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
val sdkVersion = "2.7.0"
implementation("com.lastlock:bridge-android:$sdkVersion")
}
Sync the project. A successful Gradle sync should resolve all dependencies without errors.
Permissions and starting the SDK
Android runtime permission sequence
Request the following permissions based on device OS version:
| Android version | Required permission request |
|---|---|
| Android 12+ (API 31+) | Request BLUETOOTH_SCAN and BLUETOOTH_CONNECT together. On API 33+, POST_NOTIFICATIONS is optional for showing notifications in the notification drawer. |
| Android 10–11 (API 29–30) | Request ACCESS_FINE_LOCATION first, with legacy BLUETOOTH and BLUETOOTH_ADMIN declarations. Only after foreground location is granted, request ACCESS_BACKGROUND_LOCATION separately for full background scanning. On Android 11, explain the need for background location before the user selects Allow all the time in Settings. |
| Android 8–9 (API 26–28) | Request foreground ACCESS_FINE_LOCATION and declare legacy Bluetooth permissions. |
For foreground-only Bridge.startLimited, do not request background location. For Bridge.start on Android 10–11, background BLE capability requires ACCESS_BACKGROUND_LOCATION before starting. Never combine foreground and background location in the same runtime request on Android 11: the system ignores that request. Handle denial by showing an explanation and providing a retry path.
BLUETOOTH_SCAN and BLUETOOTH_CONNECT are required before Bridge.start or Bridge.startLimited. On older APIs, use the legacy Bluetooth + location permissions pattern from the SDK examples.
The POST_NOTIFICATIONS permission is only present on Android 13 (API 33) and above. Bridge.start does not require POST_NOTIFICATIONS to function: it will always show its foreground notification, regardless of the Android version or whether the POST_NOTIFICATIONS permission is granted. For devices running API 33+, granting or denying POST_NOTIFICATIONS simply controls whether the notification appears in the user's notification drawer; if denied, the notification is hidden from the drawer, but the service still runs properly. For API 32 and below, no runtime notification permission exists, and the notification always appears. Bridge.startLimited never displays a notification on any API level.
For reliable SDK behavior (such as background operations), always initialize Bridge in a custom Application subclass, not just in an activity. This ensures that background tasks and services tied to the SDK are started consistently.
// LinkApplication.kt
class LinkApplication : Application() {
override fun onCreate() {
super.onCreate()
Bridge.init(this)
}
}
<!-- AndroidManifest.xml -->
<application
android:name=".LinkApplication"
... />
Initialization defaults to the production hostname plane.lastlock.com. Both Bridge.start() and Bridge.startLimited() default to BLE service UUID FC5A. Override these settings only when instructed by Last Lock.
After permissions are granted, call Bridge.start() when BLE and Proxy must keep running after the app is moved to the background, or Bridge.startLimited() when that work should run only while the app is in the foreground (see Bridge.startLimited). Keep a single coherent startup path—avoid starting the SDK from both Application and Activity.
Discovery and UI
Use Bridge.getBlueLink() and an implementation of BlueLinkEventListener (see hosted reference for exact method signatures) to populate a list of BluetoothDevice instances.
// ApplicationViewModel.kt (inside the ViewModel)
private val blueLink: BlueLink = Bridge.getBlueLink()
private val _allDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
val allDevices = _allDevices.asStateFlow()
private val blueLinkEventListener = object : BlueLinkEventListener() {
override fun onDeviceDiscovered(
device: BluetoothDevice,
advertisementData: BluetoothAdvertisement,
rssi: Int,
signalStrength: SignalStrength,
connectionStatus: BlueLinkDeviceConnectionStatus
) {
viewModelScope.launch(Dispatchers.Main) {
if (_allDevices.value.none { it.deviceId == device.deviceId }) {
_allDevices.update { it + device }
}
}
}
}
Collect allDevices in Compose and render each discovered lock as a device
tile. The example app pairs the device ID with a BLE Unlock button. It
enables that button only after a seven-day pass has been stored and disables it
while an unlock is in progress. The pass-issuance action remains available at
the bottom of the screen:
// DevicesScreen.kt (UI excerpts)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceScreen(viewModel: ApplicationViewModel) {
val devices by viewModel.allDevices.collectAsStateWithLifecycle()
val state by viewModel.tokenUnlockUiState.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text("Discovered Locks") }) },
bottomBar = {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
state.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Button(
onClick = viewModel::createTemporaryUnlockCredential,
enabled = !state.isBusy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Issue a 7 day unlock pass")
}
}
},
) { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
items(devices, key = { it.deviceId.toString() }) { device ->
DeviceCard(
device = device,
state = state,
onUnlock = { viewModel.performTemporaryUnlock(device) },
)
}
}
}
}
@Composable
private fun DeviceCard(
device: BluetoothDevice,
state: TokenUnlockUiState,
onUnlock: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Device: ${device.deviceId}",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
)
Button(
onClick = onUnlock,
enabled = state.temporaryCredentialReady && !state.isBusy,
) {
Text("BLE Unlock")
}
}
}
}
TokenUnlockUiState and the unlock methods are defined in
ApplicationViewModel.kt below. For additional discovery callbacks and
threading rules, use the hosted Android API reference linked above.
Example Server for Aliro Unlock
A reference Python server is available to issue Aliro BLE token-unlock passes and to
configure the trust material required by a lock. The implementation of the server
is present in the /examples directory of the SDK repo.
The server exposes the following endpoints:
| Endpoint | Purpose |
|---|---|
GET /healthz | Liveness check; no authentication required. |
POST /v1/configure | Fetches a lock's reported reader public key from Link, issues a reader certificate, and queues its Aliro configuration in Link. |
POST /v1/issue-unlock-pass | Issues a pass for an SDK-created credential public key and requested duration in seconds. |
The SDK creates the mobile credential's key pair and gives the app its public key. The private key stays on the phone. The app sends the public key to the integrating server, which signs an Access Document and returns it with reader trust material as an unlock pass. An Access Document is the issuer's signed statement that a mobile credential may be considered for access during a specified validity period. The lock checks the signature, validity period, and proof that the phone holds the matching private key before making its access decision.
The lock must first be configured to trust the issuer that signs the document. Configuration also tells the lock which access element identifier to accept; the identifier in the document must match. A reader group identifier identifies the lock's reader trust group so the SDK can select the correct trust material and authenticate the lock. It is not a device ID and is not supplied by the phone when requesting a pass. For a detailed account of these values and the document format, see the reference server's architectural overview.
The access_document response field is the signed document;
reader_groups carries the trust material the SDK needs to authenticate the
lock. The example server issues passes for locks configured with its trust material.
It uses shared trust and the same accepted access element across configured
locks, so the app does not include a device ID in the pass request. A
production backend must still decide which locks a user may access before it
issues a pass.
The Android example app requests a seven-day pass for a persisted credential; the optional ephemeral workflow below requests a two-minute pass. The example server stores its signing keys locally. A production server must keep them in durable, protected storage.
The example server does not authenticate HTTP requests and must be kept on a trusted development network. A production server must use the application's normal user authentication, authorize every lock before issuing a pass, and retain its issuer and reader CA keys in durable protected storage.
Prepare the Android app for the server
The app needs Internet access to request an unlock pass from the integrating server. Declare the permission in the application manifest:
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".LinkApplication"
... />
</manifest>
Use HTTPS for an integrating server. For local testing over HTTP, Android
blocks cleartext traffic by default. Allow only the development host in
network_security_config.xml; the domain must match the host in the app's
server URL. Do not include the scheme or port in the <domain> value.
<!-- AndroidManifest.xml: local development only -->
<application
android:networkSecurityConfig="@xml/network_security_config"
... />
<!-- network_security_config.xml: replace with the development host -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.0.2.10</domain>
</domain-config>
</network-security-config>
Configure the server base URL for your environment, including the http:// or
https:// scheme and port where applicable. On an emulator, localhost
refers to the emulator; use 10.0.2.2 to reach a server running on the host
machine. A physical phone needs a host reachable from its network. The Link
API key belongs only on the backend; do not embed it in the app.
// MainActivity.kt
private val businessServerUrl = "https://access.example.com"
private val applicationViewModel by lazy {
ApplicationViewModel(applicationContext, businessServerUrl)
}
The following shared types and request helper use only Android platform APIs.
Create TokenUnlockClient.kt in the same package as the class that coordinates
your unlock UI. The code runs network work on Dispatchers.IO and converts the
Base64 response from the example server into the SDK's TokenUnlockPass type.
// TokenUnlockClient.kt
import android.util.Base64
import com.lastlock.bridge.credlink.interfaces.ReaderGroupTrust
import com.lastlock.bridge.credlink.interfaces.TokenUnlockPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
private data class IssueTokenUnlockPassResponse(
val accessDocument: String,
val readerGroups: List<ReaderGroup>,
) {
data class ReaderGroup(
val readerGroupID: String,
val issuerCAPublicKey: String,
val issuerCACertificateDER: String,
)
companion object {
fun fromJson(json: JSONObject): IssueTokenUnlockPassResponse {
val groups = json.getJSONArray("reader_groups")
return IssueTokenUnlockPassResponse(
accessDocument = json.getString("access_document"),
readerGroups = List(groups.length()) { index ->
val group = groups.getJSONObject(index)
ReaderGroup(
readerGroupID = group.getString("reader_group_id"),
issuerCAPublicKey = group.getString("issuer_ca_public_key"),
issuerCACertificateDER = group.getString("issuer_ca_certificate_der"),
)
},
)
}
}
}
class TokenUnlockClient(private val businessServerUrl: String) {
suspend fun issueTemporaryUnlockPass(credentialPublicKey: ByteArray): TokenUnlockPass =
issueTokenUnlockPass(credentialPublicKey, 7 * 24 * 60 * 60)
private suspend fun issueTokenUnlockPass(
credentialPublicKey: ByteArray,
duration: Int,
): TokenUnlockPass = withContext(Dispatchers.IO) {
val url = URL("${businessServerUrl.trimEnd('/')}/v1/issue-unlock-pass")
val connection = url.openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.connectTimeout = 15_000
connection.readTimeout = 15_000
connection.doOutput = true
val requestBody = JSONObject().apply {
put(
"credential_public_key",
Base64.encodeToString(credentialPublicKey, Base64.NO_WRAP),
)
put("duration", duration)
}.toString()
connection.outputStream.use { output ->
output.write(requestBody.toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
val responseBody = (if (responseCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
})?.bufferedReader()?.use { it.readText() }.orEmpty()
if (responseCode !in 200..299) {
throw IOException("Token-unlock pass request failed (HTTP $responseCode): $responseBody")
}
val response = IssueTokenUnlockPassResponse.fromJson(JSONObject(responseBody))
TokenUnlockPass(
accessDocument = Base64.decode(response.accessDocument, Base64.DEFAULT),
readerGroups = response.readerGroups.map { group ->
ReaderGroupTrust(
readerGroupIdentifier = Base64.decode(group.readerGroupID, Base64.DEFAULT),
issuerCAPublicKey = Base64.decode(group.issuerCAPublicKey, Base64.DEFAULT),
issuerCACertificateDer = Base64.decode(group.issuerCACertificateDER, Base64.DEFAULT),
)
},
)
} finally {
connection.disconnect()
}
}
}
Issuing and using an ephemeral Aliro BLE credential
An ephemeral Aliro BLE credential is intended for short-lived access, such as testing a newly installed lock. This optional workflow is not wired into the current Android example app UI; that app exposes the persisted credential workflow below. Link Mobile SDK creates a P-256 key pair and retains its private key only for the active app session. The app sends the public key to the integrating server, which authorizes the request and returns a signed Aliro access document with reader trust material. The SDK retains the credential for up to two minutes or until the app session ends, whichever occurs first.
Configure target locks
Before a pass can be used, configure every target lock with the trust material
from the example server. Call /v1/configure for each lock. This does not
issue an Access Document or grant access: it gives the lock the reader
certificate, issuer trust, and accepted access element identifier needed to
validate a later pass. Link queues the configuration, and the lock must receive
it while connected to the SDK. If the endpoint reports that a device has not
reported an Aliro reader public key, wait for provisioning to complete and try
again.
curl -i -X POST http://localhost:3006/v1/configure \
-H 'Content-Type: application/json' \
-d '{"device_id":442}'
# On success, the endpoint returns 202 Accepted and a reader_group_id.
Create the ephemeral credential and request an unlock pass
Use createEphemeralTokenUnlockCredential() to create a new credential. The
SDK exposes its public key but never exposes the private key. For this optional
workflow, add a two-minute request method inside TokenUnlockClient:
// TokenUnlockClient.kt: add inside TokenUnlockClient
suspend fun issueEphemeralUnlockPass(credentialPublicKey: ByteArray): TokenUnlockPass =
issueTokenUnlockPass(credentialPublicKey, 120)
This sends the public key to /v1/issue-unlock-pass with duration set to
120 seconds. The shared request helper decodes the response into a
TokenUnlockPass for the SDK.
Retain the returned EphemeralTokenUnlockCredential only for the active app
session. Its id is the namespace required to perform the unlock. Do not save
that identifier for use after restart: the SDK does not restore the matching
private key or pass. If pass issuance or storage fails, discard the credential
before allowing another attempt.
// ApplicationViewModel.kt
import com.lastlock.bridge.Bridge
import com.lastlock.bridge.credlink.EphemeralTokenUnlockCredential
import com.lastlock.bridge.credlink.interfaces.TokenUnlockPass
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
private val credLink = Bridge.getCredLink()
private var ephemeralCredential: EphemeralTokenUnlockCredential? = null
fun createEphemeralUnlockCredential() {
viewModelScope.launch {
ephemeralCredential?.let { credential ->
credLink.discardEphemeralTokenUnlockCredential(credential)
}
ephemeralCredential = null
val credential = credLink.createEphemeralTokenUnlockCredential()
?: throw IllegalStateException("Unable to create an ephemeral Aliro BLE credential")
try {
val pass: TokenUnlockPass = tokenUnlockClient.issueEphemeralUnlockPass(
credential.publicKey,
)
check(credLink.storeEphemeralTokenUnlockPass(credential, pass)) {
"Unable to store the ephemeral unlock pass"
}
ephemeralCredential = credential
} catch (error: Exception) {
credLink.discardEphemeralTokenUnlockCredential(credential)
throw error
}
}
}
Perform an ephemeral credential unlock
After storing the pass, perform the token-unlock operation using the ephemeral credential's namespace. The SDK presents the credential over BLE and validates the lock's reader certificate using the trust material returned by the server. The lock validates the signed access document and its validity interval before granting access.
// ApplicationViewModel.kt
import com.lastlock.bridge.bluelink.BluetoothDevice
import com.lastlock.bridge.credlink.config.CredLinkOperation
import kotlinx.coroutines.launch
fun performEphemeralUnlock(device: BluetoothDevice) {
val credential = ephemeralCredential
?: throw IllegalStateException("Create an ephemeral credential before unlocking")
viewModelScope.launch {
credLink.performCredLinkOperation(
peripheral = device,
operation = CredLinkOperation.TokenUnlock(namespace = credential.id),
)
}
}
performCredLinkOperation queues the BLE operation; it does not mean the lock
unlocked. The example ViewModel implements CredentialExchangeListener for
the final result and clears its listeners when destroyed. The temporary flow
uses the same callback.
// ApplicationViewModel.kt
init {
blueLink.addListener(blueLinkEventListener)
credLink.credentialExchangeListener = this
refreshTemporaryCredentialState()
}
override fun onCredentialExchangeResult(
device: BluetoothDevice,
result: CredentialExchangeResult,
) {
_tokenUnlockUiState.update {
it.copy(isBusy = false, status = "${device.deviceId}: $result")
}
}
override fun onCleared() {
blueLink.removeListener(blueLinkEventListener)
if (credLink.credentialExchangeListener === this) {
credLink.credentialExchangeListener = null
}
super.onCleared()
}
Issuing and using a temporary Aliro BLE credential
Use a temporary Aliro BLE credential when access must remain available across app launches for a bounded period. For example, a resident may need access for the week after move-in, an installer may return to complete work over several days, or a contractor may need access during a scheduled maintenance window.
Unlike an ephemeral credential, a temporary credential is stored by the SDK in
Android secure storage. The app keeps a stable namespace that identifies the
credential. After an app restart, the same namespace gives the SDK access to
the stored private key and unlock pass. The private key never leaves the
device. The reference server's /v1/issue-unlock-pass endpoint issues a pass
valid for seven days when duration is 604800 seconds.
Configure the locks using /v1/configure before requesting a temporary pass.
This step is identical to the ephemeral-credential workflow. The pass request
contains the credential public key and duration; the reference server issues
passes for locks configured with its trust material.
Persist the credential namespace
Persist one namespace for each temporary credential. The namespace is not a secret; it identifies the SDK-managed key pair and stored pass. In production, associate it with the authenticated user and access grant, and remove it when access is revoked or the user signs out.
// TemporaryUnlockCredentialStore.kt
import android.content.Context
import java.util.UUID
class TemporaryUnlockCredentialStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
"temporary_unlock_credential",
Context.MODE_PRIVATE,
)
fun existingNamespace(): String? = preferences.getString("namespace", null)
fun getOrCreateNamespace(): String {
existingNamespace()?.let { return it }
return UUID.randomUUID().toString().also { namespace ->
preferences.edit().putString("namespace", namespace).apply()
}
}
}
Use getOrCreateNamespace() when creating a credential. For unlock, use
existingNamespace() so a missing credential does not create a new namespace.
Construct TemporaryUnlockCredentialStore once with an application context,
in the ViewModel that owns the unlock UI. The example app also keeps the
pass-readiness and operation state in that view model:
// ApplicationViewModel.kt
data class TokenUnlockUiState(
val isBusy: Boolean = false,
val temporaryCredentialReady: Boolean = false,
val status: String? = null,
)
class ApplicationViewModel(
context: Context,
businessServerUrl: String,
) : ViewModel(), CredentialExchangeListener {
private val credLink = Bridge.getCredLink()
private val tokenUnlockClient = TokenUnlockClient(businessServerUrl)
private val temporaryCredentialStore = TemporaryUnlockCredentialStore(context)
private val _tokenUnlockUiState = MutableStateFlow(TokenUnlockUiState())
val tokenUnlockUiState = _tokenUnlockUiState.asStateFlow()
// Include the discovery listener and the methods below in this ViewModel.
}
At startup, look up the existing namespace and pass before enabling BLE Unlock. Reading the state must not create a new namespace:
// ApplicationViewModel.kt (inside the ViewModel)
private fun refreshTemporaryCredentialState() {
viewModelScope.launch {
val namespace = temporaryCredentialStore.existingNamespace()
val hasPass = namespace != null && credLink.getTokenUnlockPass(namespace) != null
_tokenUnlockUiState.update { it.copy(temporaryCredentialReady = hasPass) }
}
}
The ViewModel initializer above calls refreshTemporaryCredentialState().
Create the temporary credential and request an unlock pass
Call fetchPublicCredential(namespace) to obtain a public key for the
namespace. The SDK creates and stores a P-256 key pair when that namespace is
first used, then returns the same public key on later calls. Send it to
/v1/issue-unlock-pass with duration set to 604800 seconds. The server
returns the same IssueTokenUnlockPassResponse format used for an ephemeral
credential; the shared request helper converts it into a TokenUnlockPass.
Store the pass with storeTokenUnlockPass(namespace, pass). The SDK persists
the pass with the key pair, so both remain available after the app restarts.
// ApplicationViewModel.kt
fun createTemporaryUnlockCredential() {
viewModelScope.launch {
_tokenUnlockUiState.update { it.copy(isBusy = true, status = "Creating temporary BLE credential…") }
try {
val namespace = temporaryCredentialStore.getOrCreateNamespace()
val publicKey = credLink.fetchPublicCredential(namespace)
val pass = tokenUnlockClient.issueTemporaryUnlockPass(publicKey)
check(credLink.storeTokenUnlockPass(namespace, pass)) {
"Unable to store the temporary unlock pass"
}
_tokenUnlockUiState.update {
it.copy(
isBusy = false,
temporaryCredentialReady = true,
status = "Temporary unlock pass is ready for seven days.",
)
}
} catch (error: Exception) {
_tokenUnlockUiState.update {
it.copy(isBusy = false, temporaryCredentialReady = false, status = error.message)
}
}
}
}
Perform a temporary credential unlock
Use the same namespace for every unlock. CredLinkOperation.TokenUnlock
retrieves the persisted key pair and pass from the SDK, then performs the BLE
credential presentation. The SDK checks the lock's reader certificate against
the returned reader trust material; the lock checks the Access Document's
issuer signature, accepted access element, and validity interval. The
operation's final result arrives through CredentialExchangeListener, not
from performCredLinkOperation returning.
// ApplicationViewModel.kt
fun performTemporaryUnlock(device: BluetoothDevice) {
viewModelScope.launch {
val namespace = temporaryCredentialStore.existingNamespace() ?: return@launch
if (credLink.getTokenUnlockPass(namespace) == null) return@launch
_tokenUnlockUiState.update { it.copy(isBusy = true, status = "Unlocking ${device.deviceId}…") }
try {
credLink.performCredLinkOperation(
peripheral = device,
operation = CredLinkOperation.TokenUnlock(namespace = namespace),
)
} catch (error: Exception) {
_tokenUnlockUiState.update { it.copy(isBusy = false, status = error.message) }
}
}
}
When the pass expires, call createTemporaryUnlockCredential() again to
request a new pass for the existing namespace. To revoke local access or when
the user signs out, delete the SDK-stored pass and key pair before removing the
namespace from app storage.
The example app does not expose a removal action. If your app needs one, add a
remove() method to TemporaryUnlockCredentialStore that removes its saved
namespace, then call it only after the SDK pass has been deleted.
// TemporaryUnlockCredentialStore.kt: optional method inside the store
fun remove() {
preferences.edit().remove("namespace").apply()
}
// ApplicationViewModel.kt
fun removeTemporaryUnlockCredential() {
viewModelScope.launch {
val namespace = temporaryCredentialStore.existingNamespace() ?: return@launch
check(credLink.deleteTokenUnlockPass(namespace)) {
"Unable to remove the temporary unlock pass"
}
temporaryCredentialStore.remove()
}
}
Deleting the local pass and key pair does not revoke an issued document on the server; implement server-side revocation or expiry policy as required by your access model.
Security considerations
- Do not create or sign passes in the mobile application.
- Keep the Link API key, credential issuer private key, and reader CA private key exclusively on the backend.
- Authenticate every server request and authorize access to every lock before issuing a pass.
- Preserve the backend's key storage. Rotating the issuer or reader CA keys requires affected locks to be configured again.
Foreground service notification
The SDK may run as a foreground service with a persistent notification. Customize icon, title, and body via manifest <meta-data> entries (names and behavior are documented under Bridge / packaging in the hosted reference). Typical keys:
<meta-data
android:name="com.lastlock.bridge.notification_icon"
android:resource="@android:drawable/stat_sys_data_bluetooth"
tools:replace="android:resource" />
<meta-data
android:name="com.lastlock.bridge.notification_title"
android:value="Seamless Mobile"
tools:replace="android:value" />
<meta-data
android:name="com.lastlock.bridge.notification_text"
android:value="Scanning for nearby devices..."
tools:replace="android:value" />
These entries apply to Bridge.start. Bridge.startLimited does not post this notification.
Bridge.startLimited
Bridge.start(serviceUuid) runs the SDK as a foreground service and posts the foreground service notification. Use it when your app requires SDK functionalities to continue operating even after the app has been moved to the background.
Bridge.startLimited() runs the same work as a background service and does not post a notification. Use it when the SDK only needs to run while your app is on screen.
You must call Bridge.startLimited each time your app returns to the foreground (on every instance of a foreground transition), or at minimum once when your app is initialized. This ensures the SDK is started and remains active during any period when your app is visible to the user.
// Call only while the app is in the foreground, including each return to the foreground.
override fun onStart() {
super.onStart()
if (permissionsGranted) {
Bridge.startLimited()
}
}
- Foreground only. On Android 8 (API 26) and later, starting a background service throws an exception if the app is already in the background. After the user leaves the app, the system stops the service. Call
Bridge.startLimitedagain each time the app returns to the foreground. A repeat call while that background session is still running does not restart the scan, proxy, or credential session. - No automatic restart. If the process is killed, Android does not bring this service back. The next foreground
startLimitedcall starts a new session. - Continuing in the background. Call
Bridge.start(serviceUuid)when work must outlive the foreground. If astartLimitedsession is still running, that call promotes it to a foreground service and posts the notification without tearing the session down. - A later
startLimitedcall does not remove the notification from a session thatBridge.startalready promoted or started.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401/403 from maven.pkg.github.com | Missing or wrong token | Set gpr.user / gpr.key; PAT with read:packages (and repo if needed). |
| Artifact not found | Wrong version or repo | Match sdkVersion to a published tag; confirm Maven URL (dev vs prod). |
| Resolution works locally, fails in CI | Secrets not on runner | Inject same Gradle properties in CI. |
| BLE scan empty | Permissions or timing | Follow release notes + hosted docs for Android 12+ scan requirements. |
| Plane / Proxy never connects; attestation / “invalid app” in logs | The installed app’s applicationId or signing-certificate SHA-256 is not allowlisted on Link, or the flavor uses a different identity | Send Last Lock each shipped applicationId with the SHA-256 fingerprint of its installed signing certificate; verify the merged manifest package; test on a hardware-backed, policy-compliant device. |
IllegalStateException on start, or BLE stops after leaving the app | Bridge.startLimited is a background service | Call it only from the foreground, and again each time the app returns. Use Bridge.start when work must continue after the app moves to the background. |
| Plane / Proxy never connects; attestation / “invalid app” in logs | applicationId not allowlisted on Link, or flavor mismatch | Send Last Lock every shipped applicationId; verify merged manifest package; test on a hardware-backed, policy-compliant device. |