Skip to main content

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 repo and read: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)

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.

ItemDetail
Package nameYour 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-256The SHA-256 fingerprint of the certificate that signs the installed APK. Debug, release, and Play App Signing builds commonly use different certificates.
Product flavorsIf product flavors change applicationId, add a separate android_attestation_apps entry for each package. Add every certificate that can sign that package.
Device integrityAttestation 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 versionRequired 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:

EndpointPurpose
GET /healthzLiveness check; no authentication required.
POST /v1/configureFetches a lock's reported reader public key from Link, issues a reader certificate, and queues its Aliro configuration in Link.
POST /v1/issue-unlock-passIssues 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.startLimited again 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 startLimited call starts a new session.
  • Continuing in the background. Call Bridge.start(serviceUuid) when work must outlive the foreground. If a startLimited session is still running, that call promotes it to a foreground service and posts the notification without tearing the session down.
  • A later startLimited call does not remove the notification from a session that Bridge.start already promoted or started.

Troubleshooting​

SymptomCauseFix
401/403 from maven.pkg.github.comMissing or wrong tokenSet gpr.user / gpr.key; PAT with read:packages (and repo if needed).
Artifact not foundWrong version or repoMatch sdkVersion to a published tag; confirm Maven URL (dev vs prod).
Resolution works locally, fails in CISecrets not on runnerInject same Gradle properties in CI.
BLE scan emptyPermissions or timingFollow release notes + hosted docs for Android 12+ scan requirements.
Plane / Proxy never connects; attestation / “invalid app” in logsThe installed app’s applicationId or signing-certificate SHA-256 is not allowlisted on Link, or the flavor uses a different identitySend 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 appBridge.startLimited is a background serviceCall 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 logsapplicationId not allowlisted on Link, or flavor mismatchSend Last Lock every shipped applicationId; verify merged manifest package; test on a hardware-backed, policy-compliant device.