Skip to content

Android MLBluetoothSDK User Guide

Updated November 2024

About MLBluetoothSDK

The Master Lock Bluetooth SDK provides the interface necessary to interact with Master Lock Bluetooth products. The framework hides the complexity of working directly with Master Lock hardware by exposing an interface that behaves similarly to an asynchronous API.

Note: While we intend to keep the interface as unchanging as possible, new product lines and functionality may cause breaking changes. See the Release Notes for more information.

Project Setup

To import the SDK into your project, begin by following the steps in the README.md

Key Principles

Data is exchanged asynchronously between the mobile device and the Master Lock product through Bluetooth. MLCommandCallback blocks are used to return that asynchronous result. Delegates are used to inform the implementer of bluetooth changes and other system state changes.

Android Permissions

For devices running android 6 (API 23) and above, you must request and acquire Bluetooth Scanning permissions at runtime before calling MLBluetoothSDK.startScanning().

Prior to android 12, this permission is grouped together with ACCESS_FINE_LOCATION (or before API 29 ACCESS_COARSE_LOCATION is also acceptable).

* ACCESS_FINE_LOCATION (Required for devices with API 29+) * ACCESS_COURSE_LOCATION (Required for devices < API 29)

If using targetSDKVersion 31, you must request the following permissions at runtime for devices running android 12 (API 31) or higher:

* BLUETOOTH_SCAN (Required for devices with API 31+) * BLUETOOTH_CONNECT (Required for devices with API 31+)

Note Android 12 allows connecting to locks without ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION permission, however, if you do not also request these permissions no location data will be recorded for lock interactions.

The demo app shows an example workflow for requesting permissions, as well has handling exceptions when the user does not grant location permissions.

For more details about requesting runtime permissions see the Android Developer Guide.

Master Lock SDK Core Elements

Your app will interact with four key elements:

  • MLBluetoothSDK.java -> the entry point of the SDK and lock scanning operations, must be initialized with a license String.
  • MLProduct.java -> Represents a physical lock that you wish to interact with
  • IMLProductDelegate -> An interface that you must implement and provide to the MLProduct to monitor connection status changes and changes to Lock State
  • IMLLockScannerDelegate -> An interface that you must implement and provide to the MLBluetoothSDK to receive and act on BLE scan results and monitor status changes to the state of the Master Lock BLE scanner

MLBluetoothSDK has a singleton instance with one Lock Scanner. Begin interacting with the scanner by calling the static initialization method with your Master Lock SDK encrypted license string:

String myLicense = ...// Use EncryptedLicense.txt provided by Master Lock 
MLBluetoothSDK.getInstance(myLicense, context) 

Once you have initialized the SDK, you may call MLBluetoothSDK.getInstance() to expose the SDK’s instance methods–this will allow you set your LockScannerDelegate:

MyMLLockScannerDelegateImpl myScanDelegate = ...// provide your implementation
MLBluetoothSDK.getInstance().setLockScannerDelegate(myScanDelegate)


Setting the LockScannerDelegate starts the Scanner Service. When the Scanner is ready, the SDK calls the LockScannerDelegate’s bluetoothReady() method, after which you may call MLBluetoothSDK.getInstance().startScanning() to begin scanning for Master Lock bluetooth products.

If a bluetooth Adapter becomes unavailable, the LockScannerDelegate’s ‘bluetoothDown()’ method will be called. (See more about IMLLockScannerDelegate.)


// IMLLockScannerDelegateImpl
    @Override
    public void bluetoothReady() {
        if (havePermission()) {
        MLBluetoothSDK.getInstance().startScanning();
        // Note that you also must acquire the user's 
        // ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION 
        // permission before calling .startScanning()!
        } else {
            getPermission();
            // You can call startScanning() in your 
            // permission results callback if they are 
            // granted
        }
    }

Once you have started scanning, the SDK will notify you of any discovered Master Lock BLE devices by calling the IMLLockScannerDelegate’s didDiscover() method:

// IMLLockScannerDelegateImpl
   @Override
    public void didDiscoverDevice(String deviceId) {
        // This indicates that the SDK see's an advertisement
        // with the identifier `deviceId`
    }

In order to begin interacting with the Lock, you must provide an MLProduct and set its MLProductDelegate in the IMLLockScannerDelegate’s productForDevice(String deviceId) method.

To connect to this lock you will need to provide this MLProduct with:

1) Access Profile (String)
2) Expected Lock Firmware Version (int)

Request these elements from Master Lock Connected Products API accessprofile/create and /device endpoints, respectively.

The SDK provides an alternative constructor for MLProduct where only the deviceID String and IMLProductDelegate are required. This is useful for receiving onLockStateChanged() callbacks from your MLProductDelegate to monitor if a lock is still visible to the scanner. MLProducts initialized without an access profile must call MLProduct.setAccessProfile(String accessProfile, int expectedFirmwareVersion) before connecting to the lock.

Below is a sample implementation for productForDevice(String deviceId). Note that product for device doesn’t block the main thread and wait for an MLProduct to be ready.

The MLProduct will be called again as long as the lock is nearby and advertising and the IMLockScannerDelegate’s shouldConnect method returns “true”

Also note that setAccessProfile is only called when the new MLProduct is created.

The productForDevice method is called multiple times through the life of a lock interaction, so calling setAccessProfile after the connection has already been established will end lock communication.

You must maintain a list of all MLProducts you are using. An MLProduct should be initialized once and a reference to the object should be held so the underlying library can access the pending commands.

// IMLLockScannerDelegateImpl

Map<String, MLProduct> myProducts = new HashMap<>();
Boolean gettingRemoteLockInfo = false;
@Override
public MLProduct productForDevice(String deviceId) {

   if (myProducts[deviceId] == null) {
     // only "new up" a MLProduct if you don't already have one cached in memory
     doInBackGround(() -> buildNewMLProduct(deviceId));
   }
   // always return the cached MLProduct or return null
   return myProducts[deviceId];
}

/**
 *  1. A new MLProduct is created for the deviceId.
 *  2. See if you have already downloaded info for this lock from your API
 *  3. If yes, set the product's access profile and store it your cached products
 *  4. If local info is missing and no remote retrieval is in progress, initiate getRemoteLockInfo to fetch it asynchronously.
 *  5. Upon downloading from your api, set the product's access profile and store it in myProducts.
 */
private void buildNewMLProduct(String deviceId) {
  MLProduct newProduct = new MLProduct(deviceId, myDelegate);

  getLocalLockInfo(deviceId, (localLockInfo) -> {

     if (localLockInfo != null) { 
         newProduct.setAccessProfile(localLockInfo.accessProfile, localLockInfo.firmwareVersion);
         myProducts[deviceId] = newProduct;
         return;
     }

     if (!gettingRemoteLockInfo) {
          gettingRemoteLockInfo = true;
          getRemoteLockInfo(deviceId, (remoteLockInfo) -> {
                  newProduct.setAccessProfile(remoteLockInfo.accessProfile, remoteLockInfo.firmwareVersion);  
                  gettingRemoteLockInfo = false;
                  myProducts[deviceId] = newProduct;
         });
     }
  });
}

Once you have created an MLProduct with a valid access profile, you can begin queueing MLProduct session commands by calling the MLProduct’s command instance methods. Commands will be executed the next time that the lock connects, and the SDK starts a session with a lock

Before the lock connects, the SDK will call the LockScannerDelegate’s shouldConnect method. The shouldConnect delegate method is called to ask if you wish to connect to a specified device.

// IMLLockScannerDelegateImpl
    @Override
    public boolean shouldConnect(String deviceId, int rssi) {

        boolean result = ... // Do I have a product for this 
        // device?
        // Do I want to connect?
        return result;
    }

The RSSI parameter could be used to assist with “distance” calibration, since bluetooth operates over a larger distance than may be desired. This should not be a long running operation, so a blocking API call to determine entry rights is not recommended.

Product Delegate methods

Monitoring LockState and MechanismState with onLockStateChanged()

The 2.0 version of the Master Lock SDK introduces a new way of monitoring the state of a Lock. Prior to 2.0, determining a lock’s state required monitoring the didConnect() and didChangeState() delegate calls, as well as calling user session commands to read state upon connecting to the lock.

With 2.0, the SDK handles reading state, and a total picture of the Lock’s state is broadcast automatically through the onLockStateChange() delegate method via the LockState object.

For LockState to work as intended, you must use the new 2.0 MLProduct.unlock() commands

onLockStateChange(): This delegate reports updates to the overall state change of a lock, including Visibility (weather a lock is in range and accessible to your app), whether or not the keypad is active (useful for implementing unlock on keypad touch), as well as the state(s) of the locking mechanism(s).

//IMLProductDelegateImpl.java
@Override
    public void onLockStateChange(MLProduct product, LockState lockState) {
        myViewModel.updateUIForLock(product, lockState);
        // Note, this delegate callback will be called from a background 
        // thread-- Touching views directly from this delegate will cause
        // an error unless you explicitly call runOnUiThread
    }

The IMLProductDelegate also has a legacy method didChangeState() that provides an MLBroadCastState for the lock. The current SDK continues to support this method, however, it is recommended that you migrate from using this delegate in favor of onLockStateChanged, because MLBroadcastState provides an incomplete picture of the state of the lock.

Monitoring Connectivity

Note that in the default mode, the SDK will repeatedly connect and disconnect from a lock for up to 30 seconds (or indefinitely for a door controller) from the time that a lock begins advertising. This pattern is intentional, as it will only maintain a connection when a client app has commands to execute. If you have more commands to execute, simply return “true” in shouldConnect, and they will be executed upon the next connection.

didConnect(): This delegate is called when a product is connected to.

    @Override
    public void didConnect(MLProduct mlProduct) {}

didDisconnect(): This delegate is called when a product disconnected from.

    @Override
    public void didDisconnect(MLProduct mlProduct) {}

didFailToConnect(): This delegate is called when there is an error connecting to a product. This could be caused because of a bad or expired accessProfile, or the lock refused the connection for another reason such as already being connected to another device or App instance. Below is pseudocode for steps to follow in this circumstance.

    @Override
    public void didFailToConnect(MLProduct mlProduct, Exception e) {
        doInBackground(() -> {
            // Verify that you have a valid access profile string, apply it to the MLProduct
            if (haveValidProductInfo(myCachedLockInfo(deviceId))) {
                String accessProfile = myCachedLockInfo(deviceId).accessProfile; // get a locally cached accessProfile 
                int firmwareVersion = myCachedLockInfo(deviceId).firmwareVersion; // get a locally cached version
                // Maybe we downloaded a new profile but didn't apply it to the cached product yet? We'll try that here
                mlProduct.setAccessProfile(newProfile, newFirmwareVersion);
                // Maybe it is a low battery? Some other unexpected interference? We'll do some logging here 
                myLogger.logException(e);
            } else {
                // If not valid, e.g. you had an expired profile, call accessprofile/create to get updated access profile
                getUpdatedProductInfoFromApi((newLockInfo) -> {
                    mlProduct.setAccessProfile(newLockInfo.accessProfile, newLockInfo.firmwareVersion);
                });
            }
        });
    }

shouldUpdateProductData(): This delegate is similar to didFailToConnect, but specifically indicates that there is a mismatch between the firmware version specified when creating the MLProduct and the firmware version that is advertised by the device. This would occur when a firmware update was performed and the backend systems need to be notified. The following is a pseudocode example of what should occur in this scenario.

    @Override
    public void shouldUpdateProductData(MLProduct mlProduct) {
      doInBackground(() -> {
          getUpdatedProductInfoFromApi((newLockInfo) -> {
            mlProduct.setAccessProfile(newLockInfo.accessProfile, newLockInfo.firmwareVersion);
          });
      });

    }
firmwareUpdateStatusUpdate(): This delegate provides information about the product’s firmware update state.

    @Override
    public void firmwareUpdateStatusUpdate(MLProduct mlProduct, MLFirmwareUpdateStatus mlFirmwareUpdateStatus) {}

Receiving Audit Trail Data:

didUploadAuditEntries() and didReadAuditEntries()

These delegates are called to report audit events in the form of MLAuditTrailEntry. You can switch on the AuditTrailEntryType enum to determine the event type.

didReadAuditEntries(): This will be called at the time that audit events are read. Entries will not be read more than once, so it is not possible to ‘replay’ calls to this delegate.

    @Override
    public void didReadAuditEntries(MLProduct product, MLAuditTrailEntry[] entries) {

        logEntries(entries, "AuditTrailRead");
    }
}
didUploadAuditEntries(): This will only be called when the phone is connected to the Internet. If offline, audit events are cached and reported when the network is reachable. Once a batch of entries has been successfully uploaded, the cached entries are deleted from the SDK’s internal cache and will not be uploaded again.

    @Override
    public void didUploadAuditEntries(MLProduct mlProduct, MLAuditTrailEntry[] entries) {

        logEntries(entries, "AuditTrailUp");
    }
}

Deadbolt Support

Handedness (Bolt Direction)

Supporting the D1000 Deadbolt requires implementing a user interface for the user to connect to the lock and set the direction that the deadbolt extends from the lock after initial installation. Examples of this interface are seen in Vault Enterprise and Vault Home by going to the lock detail, selecting “About This Lock” and “Deadbolt Configuration.”

To implement this interface, prompt the user to wake the lock, return “true” in IMLProductDelegate.shouldConnect(String deviceId), and then provide the user with buttons to call MLProduct.setDeadboltLeftHanded()

Firmware Update

The SDK provides the MLProduct.updateFirmware(int firmwareVersion) command for requesting and applying OTA firmware updates.

A firmware update is applied with these steps:

  1. The an app running the SDK connects to the lock with a valid profile
  2. An MLProduct.updateFirmware command is issued with a valid upgrade version (Contact CP API for a list of available versions)
  3. The SDK automatically issues a series of commands that put the device into bootloader mode, apply the update, and restore the lock to normal working mode. Updates to the status of a firmware update are sent to IMLProductDelegate.firmwareUpdateStatusUpdate
  4. Upon reconnecting to the lock with the same MLProduct that issued the command, the SDK will notice a mismatch between the lock’s new firmware version and the expected firmware version that you provided when you created the MLProduct. This causes the SDK to acknowledge with the connected products API that the requested update has been applied.
  5. The SDK calls the IMLProductDelegate.shouldUpdateProductData method alerts you to call the connected products api to update the locks expected firmware version as well as download a new access profile before reconnecting to the lock.

A full working demo of firmware update is provided in the Android Demo App.

Configurable Temporary Code

Change the length and roll over time of a temporary code.

Overview

You can change the duration and roll over time of a temporary code by using the Master Lock Connected Products API. The response from the API is an array of base 64 encoded strings that you will in turn send to the lock one at a time via MLProduct.writeAuthenticatedCommand().

Tip: It is best to perform this workflow while online since an API acknowledgement is required before the Connected Products API will return temporary codes with the changed configuration.

fun changeTemporaryCodeConfiguration(onSuccess: () -> Unit, onError: () -> Unit) {
    // Call Connected Products API to get commands. 
    val commandsFromApi = listOf(
        "Y29tbWFuZCAx",
        "Y29tbWFuZCAy",
        "Y29tbWFuZCAz",
    )
    val writeErrors = mutableListOf<Throwable>()

    commandsFromApi.forEachIndexed { index, commandString ->
        if (writeErrors.isEmpty()) {
            mlProduct.writeAuthenticatedCommand(commandString) { result, error ->
                if (error != null) {
                    writeErrors.add(error)
                } else if (writeErrors.isEmpty() && index == commandsFromApi.lastIndex) {
                    notifyConnectedProductsApi(onSuccess, onError) 
                    // Once we get an OK response from CPAPI, we are done. 
                }

                if (writeErrors.isNotEmpty()) {
                    onError()
                }
            }
        }
    }
}