| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.car.trust; |
| |
| import android.annotation.IntDef; |
| import android.annotation.Nullable; |
| import android.bluetooth.BluetoothDevice; |
| import android.car.encryptionrunner.EncryptionRunner; |
| import android.car.encryptionrunner.EncryptionRunnerFactory; |
| import android.car.encryptionrunner.HandshakeException; |
| import android.car.encryptionrunner.HandshakeMessage; |
| import android.car.encryptionrunner.Key; |
| import android.content.SharedPreferences; |
| import android.util.Log; |
| |
| import com.android.car.BLEStreamProtos.BLEOperationProto.OperationType; |
| import com.android.car.Utils; |
| import com.android.internal.annotations.GuardedBy; |
| |
| import com.google.security.cryptauth.lib.securegcm.D2DConnectionContext; |
| import com.google.security.cryptauth.lib.securemessage.CryptoOps; |
| |
| import java.io.PrintWriter; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.security.InvalidKeyException; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.util.LinkedList; |
| import java.util.Queue; |
| import java.util.UUID; |
| |
| import javax.crypto.spec.SecretKeySpec; |
| |
| /** |
| * A service that interacts with the Trust Agent {@link CarBleTrustAgent} and a comms (BLE) service |
| * {@link CarTrustAgentBleManager} to receive the necessary credentials to authenticate |
| * an Android user. |
| * |
| * <p> |
| * The unlock flow is as follows: |
| * <ol> |
| * <li>IHU advertises via BLE when it is in a locked state. The advertisement includes its |
| * identifier. |
| * <li>Phone (Trusted device) scans, finds and connects to the IHU. |
| * <li>Protocol versions are exchanged and verified. |
| * <li>Phone sends its identifier in plain text. |
| * <li>IHU verifies that the phone is enrolled as a trusted device from its identifier. |
| * <li>IHU, then sends an ACK back to the phone. |
| * <li>Phone & IHU go over the key exchange (using UKEY2) for encrypting this new session. |
| * <li>Key exchange is completed without any numeric comparison. |
| * <li>Phone sends its MAC (digest) that is computed from the context from this new session and the |
| * previous session. |
| * <li>IHU computes Phone's MAC and validates against what the phone sent. On validation failure, |
| * the stored encryption keys for the phone are deleted. This would require the phone to re-enroll |
| * again. |
| * <li>IHU sends its MAC that is computed similarly from the new session and previous session |
| * contexts. |
| * <li>Phone computes IHU's MAC internally and validates it against what it received. |
| * <li>At this point, the devices have mutually authenticated each other and also have keys to |
| * encrypt |
| * current session. |
| * <li>IHU saves the current session keys. This would serve for authenticating the next session. |
| * <li>Phone sends the encrypted escrow token and handle to the IHU. |
| * <li>IHU retrieves the user id and authenticates the user. |
| * </ol> |
| */ |
| public class CarTrustAgentUnlockService { |
| private static final String TAG = "CarTrustAgentUnlock"; |
| private static final String TRUSTED_DEVICE_UNLOCK_ENABLED_KEY = "trusted_device_unlock_enabled"; |
| |
| // Arbitrary log size |
| private static final int MAX_LOG_SIZE = 20; |
| private static final byte[] RESUME = "RESUME".getBytes(); |
| private static final byte[] SERVER = "SERVER".getBytes(); |
| private static final byte[] CLIENT = "CLIENT".getBytes(); |
| private static final int RESUME_HMAC_LENGTH = 32; |
| |
| private static final byte[] ACKNOWLEDGEMENT_MESSAGE = "ACK".getBytes(); |
| |
| // State of the unlock process. Important to maintain the same order in both phone and IHU. |
| // State increments to the next state on successful completion. |
| private static final int UNLOCK_STATE_WAITING_FOR_UNIQUE_ID = 0; |
| private static final int UNLOCK_STATE_KEY_EXCHANGE_IN_PROGRESS = 1; |
| private static final int UNLOCK_STATE_WAITING_FOR_CLIENT_AUTH = 2; |
| private static final int UNLOCK_STATE_MUTUAL_AUTH_ESTABLISHED = 3; |
| private static final int UNLOCK_STATE_TOKEN_RECEIVED = 4; |
| private static final int UNLOCK_STATE_HANDLE_RECEIVED = 5; |
| |
| /** @hide */ |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef(prefix = {"UNLOCK_STATE_"}, value = {UNLOCK_STATE_WAITING_FOR_UNIQUE_ID, |
| UNLOCK_STATE_KEY_EXCHANGE_IN_PROGRESS, UNLOCK_STATE_WAITING_FOR_CLIENT_AUTH, |
| UNLOCK_STATE_MUTUAL_AUTH_ESTABLISHED, UNLOCK_STATE_TOKEN_RECEIVED, |
| UNLOCK_STATE_HANDLE_RECEIVED}) |
| @interface UnlockState { |
| } |
| |
| @UnlockState |
| private int mCurrentUnlockState = UNLOCK_STATE_WAITING_FOR_UNIQUE_ID; |
| |
| private final CarTrustedDeviceService mTrustedDeviceService; |
| private final CarTrustAgentBleManager mCarTrustAgentBleManager; |
| private CarTrustAgentUnlockDelegate mUnlockDelegate; |
| private String mClientDeviceId; |
| private final Queue<String> mLogQueue = new LinkedList<>(); |
| |
| // Locks |
| private final Object mTokenLock = new Object(); |
| private final Object mHandleLock = new Object(); |
| private final Object mDeviceLock = new Object(); |
| |
| @GuardedBy("mTokenLock") |
| private byte[] mUnlockToken; |
| @GuardedBy("mHandleLock") |
| private byte[] mUnlockHandle; |
| |
| @GuardedBy("mDeviceLock") |
| private BluetoothDevice mRemoteUnlockDevice; |
| |
| private EncryptionRunner mEncryptionRunner = EncryptionRunnerFactory.newRunner(); |
| private HandshakeMessage mHandshakeMessage; |
| private Key mEncryptionKey; |
| @HandshakeMessage.HandshakeState |
| private int mEncryptionState = HandshakeMessage.HandshakeState.UNKNOWN; |
| |
| private D2DConnectionContext mPrevContext; |
| private D2DConnectionContext mCurrentContext; |
| |
| CarTrustAgentUnlockService(CarTrustedDeviceService service, |
| CarTrustAgentBleManager bleService) { |
| mTrustedDeviceService = service; |
| mCarTrustAgentBleManager = bleService; |
| } |
| |
| /** |
| * The interface that an unlock delegate has to implement to get the auth credentials from |
| * the unlock service. |
| */ |
| interface CarTrustAgentUnlockDelegate { |
| /** |
| * Called when the Unlock service has the auth credentials to pass. |
| * |
| * @param user user being authorized |
| * @param token escrow token for the user |
| * @param handle the handle corresponding to the escrow token |
| */ |
| void onUnlockDataReceived(int user, byte[] token, long handle); |
| } |
| |
| /** |
| * Enable or disable authentication of the head unit with a trusted device. |
| * |
| * @param isEnabled when set to {@code false}, head unit will not be |
| * discoverable to unlock the user. Setting it to {@code true} will enable it |
| * back. |
| */ |
| public void setTrustedDeviceUnlockEnabled(boolean isEnabled) { |
| SharedPreferences.Editor editor = mTrustedDeviceService.getSharedPrefs().edit(); |
| editor.putBoolean(TRUSTED_DEVICE_UNLOCK_ENABLED_KEY, isEnabled); |
| if (!editor.commit()) { |
| Log.wtf(TAG, "Unlock Enable Failed. Enable? " + isEnabled); |
| } |
| } |
| |
| /** |
| * Set a delegate that implements {@link CarTrustAgentUnlockDelegate}. The delegate will be |
| * handed the auth related data (token and handle) when it is received from the remote |
| * trusted device. The delegate is expected to use that to authorize the user. |
| */ |
| void setUnlockRequestDelegate(CarTrustAgentUnlockDelegate delegate) { |
| mUnlockDelegate = delegate; |
| } |
| |
| /** |
| * Start Unlock Advertising |
| */ |
| void startUnlockAdvertising() { |
| queueMessageForLog("startUnlockAdvertising"); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "startUnlockAdvertising"); |
| } |
| if (!mTrustedDeviceService.getSharedPrefs().getBoolean(TRUSTED_DEVICE_UNLOCK_ENABLED_KEY, |
| true)) { |
| Log.e(TAG, "Trusted Device Unlock is disabled"); |
| return; |
| } |
| mTrustedDeviceService.getCarTrustAgentEnrollmentService().stopEnrollmentAdvertising(); |
| stopUnlockAdvertising(); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "start unlock advertising"); |
| } |
| mCarTrustAgentBleManager.startUnlockAdvertising(); |
| } |
| |
| /** |
| * Stop unlock advertising |
| */ |
| void stopUnlockAdvertising() { |
| queueMessageForLog("stopUnlockAdvertising"); |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "stopUnlockAdvertising"); |
| } |
| mCarTrustAgentBleManager.stopUnlockAdvertising(); |
| // Also disconnect from the peer. |
| if (mRemoteUnlockDevice != null) { |
| mCarTrustAgentBleManager.disconnectRemoteDevice(); |
| } |
| } |
| |
| void init() { |
| mCarTrustAgentBleManager.setupUnlockBleServer(); |
| } |
| |
| void release() { |
| synchronized (mDeviceLock) { |
| mRemoteUnlockDevice = null; |
| } |
| mPrevContext = null; |
| mCurrentContext = null; |
| } |
| |
| void onRemoteDeviceConnected(BluetoothDevice device) { |
| synchronized (mDeviceLock) { |
| if (mRemoteUnlockDevice != null) { |
| // TBD, return when this is encountered? |
| Log.e(TAG, "Unexpected: Cannot connect to another device when already connected"); |
| } |
| queueMessageForLog("onRemoteDeviceConnected (addr:" + device.getAddress() + ")"); |
| mRemoteUnlockDevice = device; |
| } |
| resetEncryptionState(); |
| mCurrentUnlockState = UNLOCK_STATE_WAITING_FOR_UNIQUE_ID; |
| } |
| |
| void onRemoteDeviceDisconnected(BluetoothDevice device) { |
| // sanity checking |
| if (!device.equals(mRemoteUnlockDevice) && device.getAddress() != null) { |
| Log.e(TAG, "Disconnected from an unknown device:" + device.getAddress()); |
| } |
| queueMessageForLog("onRemoteDeviceDisconnected (addr:" + device.getAddress() + ")"); |
| synchronized (mDeviceLock) { |
| mRemoteUnlockDevice = null; |
| } |
| resetEncryptionState(); |
| mCurrentUnlockState = UNLOCK_STATE_WAITING_FOR_UNIQUE_ID; |
| } |
| |
| void onUnlockDataReceived(byte[] value) { |
| switch (mCurrentUnlockState) { |
| case UNLOCK_STATE_WAITING_FOR_UNIQUE_ID: |
| mClientDeviceId = convertToDeviceId(value); |
| if (mClientDeviceId == null) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Phone not enrolled as a trusted device"); |
| } |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| sendAckToClient(/* isEncrypted = */ false); |
| // Next step is to wait for the client to start the encryption handshake. |
| mCurrentUnlockState = UNLOCK_STATE_KEY_EXCHANGE_IN_PROGRESS; |
| break; |
| case UNLOCK_STATE_KEY_EXCHANGE_IN_PROGRESS: |
| try { |
| processKeyExchangeHandshakeMessage(value); |
| } catch (HandshakeException e) { |
| Log.e(TAG, "Handshake failure", e); |
| resetUnlockStateOnFailure(); |
| } |
| break; |
| case UNLOCK_STATE_WAITING_FOR_CLIENT_AUTH: |
| if (!authenticateClient(value)) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, |
| "HMAC from the phone is not correct. Cannot resume session. Need" |
| + " to re-enroll"); |
| } |
| mTrustedDeviceService.clearEncryptionKey(mClientDeviceId); |
| resetUnlockStateOnFailure(); |
| |
| return; |
| } |
| sendServerAuthToClient(); |
| mCurrentUnlockState = UNLOCK_STATE_MUTUAL_AUTH_ESTABLISHED; |
| break; |
| case UNLOCK_STATE_MUTUAL_AUTH_ESTABLISHED: |
| if (mEncryptionKey == null) { |
| Log.e(TAG, "Current session key null. Unexpected at this stage: " |
| + mCurrentUnlockState); |
| // Clear the previous session key. Need to re-enroll the trusted device. |
| mTrustedDeviceService.clearEncryptionKey(mClientDeviceId); |
| resetUnlockStateOnFailure(); |
| } |
| // Save the current session to be used for authenticating the next session |
| mTrustedDeviceService.saveEncryptionKey(mClientDeviceId, mEncryptionKey.asBytes()); |
| |
| onUnlockTokenReceived(value); |
| mCurrentUnlockState = UNLOCK_STATE_TOKEN_RECEIVED; |
| // Let the phone know that the token was received. |
| sendAckToClient(/* isEncrypted = */ true); |
| break; |
| // TODO(b/131124919) Combine token and handle in the same packet |
| case UNLOCK_STATE_TOKEN_RECEIVED: |
| onUnlockHandleReceived(value); |
| mCurrentUnlockState = UNLOCK_STATE_HANDLE_RECEIVED; |
| break; |
| case UNLOCK_STATE_HANDLE_RECEIVED: |
| // Should never get here because the unlock process should be completed now. |
| Log.e(TAG, "Landed on unexpected state: " + mCurrentUnlockState); |
| break; |
| default: |
| break; |
| } |
| } |
| |
| private void sendAckToClient(boolean isEncrypted) { |
| // Let the phone know that the handle was received. |
| byte[] ack = isEncrypted ? mEncryptionKey.encryptData(ACKNOWLEDGEMENT_MESSAGE) |
| : ACKNOWLEDGEMENT_MESSAGE; |
| mCarTrustAgentBleManager.sendUnlockMessage(mRemoteUnlockDevice, ack, |
| OperationType.CLIENT_MESSAGE, /* isPayloadEncrypted= */ isEncrypted); |
| } |
| |
| @Nullable |
| private String convertToDeviceId(byte[] id) { |
| // Validate if the id exists i.e., if the phone is enrolled already |
| UUID deviceId = Utils.bytesToUUID(id); |
| if (deviceId == null |
| || mTrustedDeviceService.getEncryptionKey(deviceId.toString()) == null) { |
| if (deviceId != null) { |
| Log.e(TAG, "Unknown phone connected: " + deviceId.toString()); |
| } |
| return null; |
| } |
| |
| return deviceId.toString(); |
| } |
| |
| private void processKeyExchangeHandshakeMessage(byte[] message) throws HandshakeException { |
| switch (mEncryptionState) { |
| case HandshakeMessage.HandshakeState.UNKNOWN: |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Responding to handshake init request."); |
| } |
| |
| mHandshakeMessage = mEncryptionRunner.respondToInitRequest(message); |
| mEncryptionState = mHandshakeMessage.getHandshakeState(); |
| mCarTrustAgentBleManager.sendUnlockMessage(mRemoteUnlockDevice, |
| mHandshakeMessage.getNextMessage(), |
| OperationType.ENCRYPTION_HANDSHAKE, |
| /* isPayloadEncrypted= */ false); |
| |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Updated encryption state: " + mEncryptionState); |
| } |
| break; |
| |
| case HandshakeMessage.HandshakeState.IN_PROGRESS: |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Continuing handshake."); |
| } |
| |
| mHandshakeMessage = mEncryptionRunner.continueHandshake(message); |
| mEncryptionState = mHandshakeMessage.getHandshakeState(); |
| |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Updated encryption state: " + mEncryptionState); |
| } |
| |
| // The state is updated after a call to continueHandshake(). Thus, need to check |
| // if we're in the next stage. |
| if (mEncryptionState == HandshakeMessage.HandshakeState.VERIFICATION_NEEDED) { |
| showVerificationCode(); |
| return; |
| } |
| |
| // control shouldn't get here with Ukey2 |
| mCarTrustAgentBleManager.sendUnlockMessage(mRemoteUnlockDevice, |
| mHandshakeMessage.getNextMessage(), |
| OperationType.ENCRYPTION_HANDSHAKE, /*isPayloadEncrypted= */false); |
| break; |
| case HandshakeMessage.HandshakeState.VERIFICATION_NEEDED: |
| case HandshakeMessage.HandshakeState.FINISHED: |
| // Should never reach this case since this state should occur after a verification |
| // code has been accepted. But it should mean handshake is done and the message |
| // is one for the escrow token. Start Mutual Auth from server - compute MACs and |
| // send it over |
| showVerificationCode(); |
| break; |
| |
| default: |
| Log.w(TAG, "Encountered invalid handshake state: " + mEncryptionState); |
| break; |
| } |
| } |
| |
| /** |
| * Verify the handshake. |
| * TODO(b/134073741) combine this with the method in CarTrustAgentEnrollmentService and |
| * have this take a boolean to blindly confirm the numeric code. |
| */ |
| private void showVerificationCode() { |
| HandshakeMessage handshakeMessage; |
| |
| // Blindly accept the verification code. |
| try { |
| handshakeMessage = mEncryptionRunner.verifyPin(); |
| } catch (HandshakeException e) { |
| Log.e(TAG, "Verify pin failed for new keys - Unexpected"); |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| |
| if (handshakeMessage.getHandshakeState() != HandshakeMessage.HandshakeState.FINISHED) { |
| Log.e(TAG, "Handshake not finished after calling verify PIN. Instead got state: " |
| + handshakeMessage.getHandshakeState()); |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| |
| mEncryptionState = HandshakeMessage.HandshakeState.FINISHED; |
| mEncryptionKey = handshakeMessage.getKey(); |
| mCurrentContext = D2DConnectionContext.fromSavedSession(mEncryptionKey.asBytes()); |
| |
| if (mClientDeviceId == null) { |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| byte[] oldSessionKeyBytes = mTrustedDeviceService.getEncryptionKey(mClientDeviceId); |
| if (oldSessionKeyBytes == null) { |
| Log.e(TAG, |
| "Could not retrieve previous session keys! Have to re-enroll trusted device"); |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| |
| mPrevContext = D2DConnectionContext.fromSavedSession(oldSessionKeyBytes); |
| if (mPrevContext == null) { |
| resetUnlockStateOnFailure(); |
| return; |
| } |
| |
| // Now wait for the phone to send its MAC. |
| mCurrentUnlockState = UNLOCK_STATE_WAITING_FOR_CLIENT_AUTH; |
| } |
| |
| private void sendServerAuthToClient() { |
| byte[] resumeBytes = computeMAC(mPrevContext, mCurrentContext, SERVER); |
| if (resumeBytes == null) { |
| return; |
| } |
| // send to client |
| mCarTrustAgentBleManager.sendUnlockMessage(mRemoteUnlockDevice, resumeBytes, |
| OperationType.CLIENT_MESSAGE, /* isPayloadEncrypted= */false); |
| } |
| |
| @Nullable |
| private byte[] computeMAC(D2DConnectionContext previous, D2DConnectionContext next, |
| byte[] info) { |
| try { |
| SecretKeySpec inputKeyMaterial = new SecretKeySpec( |
| Utils.concatByteArrays(previous.getSessionUnique(), next.getSessionUnique()), |
| "" /* key type is just plain raw bytes */); |
| return CryptoOps.hkdf(inputKeyMaterial, RESUME, info); |
| } catch (NoSuchAlgorithmException | InvalidKeyException e) { |
| // Does not happen in practice |
| Log.e(TAG, "Compute MAC failed"); |
| return null; |
| } |
| } |
| |
| private boolean authenticateClient(byte[] message) { |
| if (message.length != RESUME_HMAC_LENGTH) { |
| Log.e(TAG, "failing because message.length is " + message.length); |
| return false; |
| } |
| return MessageDigest.isEqual(message, |
| computeMAC(mPrevContext, mCurrentContext, CLIENT)); |
| } |
| |
| void onUnlockTokenReceived(byte[] value) { |
| synchronized (mTokenLock) { |
| mUnlockToken = value; |
| } |
| |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Unlock Token received: " + Utils.byteArrayToHexString(mUnlockToken)); |
| } |
| |
| queueMessageForLog("onUnlockTokenReceived"); |
| } |
| |
| /** |
| * Reset the whole unlock state. Disconnects from the peer device |
| * |
| * <p>This method should be called from any stage in the middle of unlock where we |
| * encounter a failure. |
| */ |
| private void resetUnlockStateOnFailure() { |
| mCarTrustAgentBleManager.disconnectRemoteDevice(); |
| resetEncryptionState(); |
| } |
| |
| /** |
| * Resets the encryption status of this service. |
| * |
| * <p>This method should be called each time a device connects so that a new handshake can be |
| * started and encryption keys exchanged. |
| */ |
| private void resetEncryptionState() { |
| mEncryptionRunner = EncryptionRunnerFactory.newRunner(); |
| mHandshakeMessage = null; |
| mEncryptionKey = null; |
| mEncryptionState = HandshakeMessage.HandshakeState.UNKNOWN; |
| mCurrentUnlockState = UNLOCK_STATE_WAITING_FOR_UNIQUE_ID; |
| if (mCurrentContext != null) { |
| mCurrentContext = null; |
| } |
| if (mPrevContext != null) { |
| mPrevContext = null; |
| } |
| } |
| |
| void onUnlockHandleReceived(byte[] value) { |
| synchronized (mHandleLock) { |
| mUnlockHandle = value; |
| } |
| |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Unlock Handl received: " + Utils.byteArrayToHexString(mUnlockHandle)); |
| } |
| |
| queueMessageForLog("onUnlockHandleReceived"); |
| |
| if (mUnlockToken == null || mUnlockHandle == null) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "Unlock Token not available yet"); |
| } |
| return; |
| } |
| |
| if (mUnlockDelegate == null) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "No Unlock delegate"); |
| } |
| return; |
| } |
| |
| mUnlockDelegate.onUnlockDataReceived( |
| mTrustedDeviceService.getUserHandleByTokenHandle(Utils.bytesToLong(mUnlockHandle)), |
| mUnlockToken, |
| Utils.bytesToLong(mUnlockHandle)); |
| |
| synchronized (mUnlockToken) { |
| mUnlockToken = null; |
| } |
| synchronized (mHandleLock) { |
| mUnlockHandle = null; |
| } |
| resetEncryptionState(); |
| } |
| |
| void dump(PrintWriter writer) { |
| writer.println("*CarTrustAgentUnlockService*"); |
| writer.println("Unlock Service Logs:"); |
| for (String log : mLogQueue) { |
| writer.println("\t" + log); |
| } |
| } |
| |
| private void queueMessageForLog(String message) { |
| if (mLogQueue.size() >= MAX_LOG_SIZE) { |
| mLogQueue.remove(); |
| } |
| mLogQueue.add(System.currentTimeMillis() + " : " + message); |
| } |
| } |