/* * Copyright 2019 Fitbit, Inc. All rights reserved. * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package com.fitbit.bluetooth.fbgatt; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattServer; import android.bluetooth.BluetoothProfile; import android.os.Build; import android.os.Handler; import android.os.Looper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import timber.log.Timber; /** * Gatt server connection wrapper, there will be only one and it will be maintained on the * {@link FitbitGatt} * * Created by iowens on 11/17/17. */ public class GattServerConnection implements Closeable { private BluetoothGattServer server; private TransactionQueueController serverQueue; private GattState state; private AtomicLong intraTransactionDelay = new AtomicLong(0); private GattStateTransitionValidator guard; private final ConcurrentHashMap<ServerConnectionEventListener, Boolean> asynchronousEventListeners = new ConcurrentHashMap<>(); private HashSet<FitbitBluetoothDevice> connectedDevices = new HashSet<>(); private Handler mainHandler; private boolean mockMode; protected GattServerConnection(@Nullable BluetoothGattServer server, Looper looper) { this.server = server; this.serverQueue = new TransactionQueueController(); this.guard = new GattStateTransitionValidator(); this.state = GattState.IDLE; this.mainHandler = new Handler(looper); } public synchronized GattState getGattState(){ return state; } public BluetoothGattServer getServer(){ return server; } synchronized GattStateTransitionValidator.GuardState checkTransaction(GattTransaction tx) { return guard.checkTransaction(getGattState(), tx); } public void registerConnectionEventListener(@NonNull ServerConnectionEventListener eventListener) { if(this.asynchronousEventListeners.putIfAbsent(eventListener, true) != null) { Timber.v("[%s] This listener is already registered", Build.MODEL); } } @SuppressWarnings("WeakerAccess") // API Method public void unregisterConnectionEventListener(@NonNull ServerConnectionEventListener eventListener) { Boolean previousValue = asynchronousEventListeners.remove(eventListener); if(previousValue == null) { // null when returned from ConcurrentHashMap.remove() means the key was not present. Timber.v("[%s] There are no event listeners to remove", Build.MODEL); } } @NonNull ArrayList<ServerConnectionEventListener> getConnectionEventListeners(){ //We want a copy of the listeners set, so that clients can't modify it. return new ArrayList<>(asynchronousEventListeners.keySet()); } public synchronized void setState(GattState state) { Timber.v("[%s] Transitioning from state %s to state %s", Build.MODEL, this.state.name(), state.name()); this.state = state; } @SuppressWarnings("unused") // API Method void resetStates(){ this.setState(GattState.DISCONNECTED); } @VisibleForTesting void setMockMode(boolean mockMode) { this.mockMode = mockMode; } /** * To set or change an intra-transaction delay... this value is initialized to zero, setting * it to any non-zero value will cause it to be posted to the connection handler * @param txDelay The delay in milliseconds to wait before queueing the next transaction */ void setIntraTransactionDelay(long txDelay) { long oldValue = intraTransactionDelay.getAndSet(txDelay); Timber.v("[%s] Changing intra-transaction delay from %dms, to %dms", Build.MODEL, oldValue, intraTransactionDelay.get()); } /** * Will return the intra-transaction delay * @return The delay in milliseconds to wait before queueing the next transaction */ @SuppressWarnings("unused") // API Method public long getIntraTransactionDelay(){ return intraTransactionDelay.get(); } /** * Will run the provided transaction once the execution thread is ready, internally will queue the * transaction on the calling thread. If these come in too quickly from arbitrary threads * the second thread, who should wait, will get invalid state. We should post the commits * to the connection thread. If a transaction is running on the thread, this runnable will * be queued, and executed later, once the other tx has completed. The tx can be delayed by * setting {@link GattConnection#setIntraTransactionDelay(long)}. The recommended delay is 3ms * this seems to prevent gatt_if queue wedging for most phones, although more or less delay * maybe usable for the library user depending on the performance of the phone, it's BT stack, * and the peripheral * @param transaction The transaction to run * @param callback The gatt transaction callback */ public void runTx(GattTransaction transaction, GattTransactionCallback callback) { if(intraTransactionDelay.get() == 0) { queueTransaction(transaction, callback); } else { // uses the main handler final long currentTimeMillis = System.currentTimeMillis(); Timber.v("[%s] Posting tx to queue in %dms", Build.MODEL, intraTransactionDelay.get()); getMainHandler().postDelayed(() -> { final long queueTimeMillis = System.currentTimeMillis(); long timeToQueue = queueTimeMillis - currentTimeMillis; Timber.v("[%s] Queueing tx %dms after posting", Build.MODEL, timeToQueue); queueTransaction(transaction, callback); }, intraTransactionDelay.get()); } } private void queueTransaction(GattTransaction transaction, GattTransactionCallback callback) { serverQueue.queueTransaction(() -> transaction.commit(callback)); } /** * Will return the present state of this connection, it will return false if bluetooth is turned off * or if this connection is in the process of disconnecting or is disconnected. Note, disconnected * DOES NOT mean that the peripheral is actually disconnected from the phone, it just means that we have * deregistered the client_if. If you don't know what a client_if is, have a read * https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/bluetooth/BluetoothGatt.java * @return true if the client if for the peripheral is registered and the peripheral is connected, false if the peripheral is disconnecting, or disconnected, or bt is off. */ @SuppressWarnings("WeakerAccess") // API Method public boolean isConnected(){ return !getGattState().equals(GattState.DISCONNECTED) && !getGattState().equals(GattState.DISCONNECTING) && !getGattState().equals(GattState.BT_OFF); } TransactionQueueController getServerTransactionQueueController() { return serverQueue; } public void connect(FitbitBluetoothDevice device) { if(mockMode) { connectedDevices.add(device); mockConnect(); return; } if(connectedDevices.contains(device)) { return; } boolean success = server.connect(device.getBtDevice(), true); if(success) { connectedDevices.add(device); setState(GattState.CONNECTING); } else { setState(GattState.FAILURE_CONNECTING); } } private void mockConnect() { Timber.i("[%s] Mock connecting!!!!", Build.MODEL); setState(GattState.CONNECTING); mainHandler.postDelayed(() -> FitbitGatt.getInstance().getServerCallback(). onConnectionStateChange(null, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED), 1499); } private void mockDisconnect() { Timber.i("[%s] Mock disconnecting!!!", Build.MODEL); setState(GattState.DISCONNECTING); mainHandler.postDelayed(() -> FitbitGatt.getInstance().getServerCallback().onConnectionStateChange(null, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_DISCONNECTED), 150); } @SuppressWarnings("unused") // API Method public boolean isDeviceConnected(FitbitBluetoothDevice device) { return connectedDevices.contains(device); } public void disconnect(FitbitBluetoothDevice device) { if(mockMode) { connectedDevices.remove(device); mockDisconnect(); return; } if(!connectedDevices.contains(device)) { return; } /* * It is important to note that after disconnect is processed, there can be a long * supervision timeout if the device disconnects itself, the state will remain * {@link GattState.DISCONNECTING} until that is complete ... */ server.cancelConnection(device.getBtDevice()); connectedDevices.remove(device); setState(GattState.DISCONNECTING); } @SuppressWarnings("unused") // API method and warning protected void closeGattServer(){ Timber.v("Unregistering gatt server listeners"); asynchronousEventListeners.clear(); BluetoothGattServer server = getServer(); if(server != null) { setState(GattState.CLOSING_GATT_SERVER); Timber.v("Clearing gatt server services"); server.clearServices(); Timber.v("Closing gatt server"); // it seems to me that close should not be used unless the process is dying // it always prevents adding services on the GS9+ ( Exynos ) and on the Pixel 2 ( Q ) server.close(); setState(GattState.CLOSE_GATT_SERVER_SUCCESS); if(serverQueue != null) { serverQueue.stop(); } } } /** * Will log off the state of this connection * @return The state of this connection as a string */ @NonNull @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append("Object.toString(): "); builder.append(super.toString()); builder.append(" isConnected: "); builder.append(isConnected()); builder.append(" state: "); GattState gattState = getGattState(); builder.append(gattState); if (gattState != null) { builder.append(" state type: "); builder.append(getGattState().stateType); } builder.append(" numConnEvtListeners: "); builder.append(getConnectionEventListeners().size()); return builder.toString(); } /** * Will return a handler on the main thread for use in a transaction * @return The main looper based handler */ public Handler getMainHandler() { return mainHandler; } @Override public void close() { finish(); } @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) synchronized void finish() { if(serverQueue != null) { serverQueue.stop(); } } }