bass: Add BassClientStateMachine unit tests

Bug: 239546757
Fixes: 239546757
Tag: #feature
Sponsor: jpawlowski@
Test: atest BassClientStateMachineTest
Change-Id: Ief24ac1d180028fa05bb7d815358eb256f1e33e9
diff --git a/android/app/src/com/android/bluetooth/bass_client/BassClientStateMachine.java b/android/app/src/com/android/bluetooth/bass_client/BassClientStateMachine.java
index 448dc54..d7d5db3 100755
--- a/android/app/src/com/android/bluetooth/bass_client/BassClientStateMachine.java
+++ b/android/app/src/com/android/bluetooth/bass_client/BassClientStateMachine.java
@@ -138,6 +138,10 @@
     static final int PSYNC_ACTIVE_TIMEOUT = 14;
     static final int CONNECT_TIMEOUT = 15;
 
+    // NOTE: the value is not "final" - it is modified in the unit tests
+    @VisibleForTesting
+    static int sConnectTimeoutMs = BassConstants.CONNECT_TIMEOUT_MS;
+
     /*key is combination of sourceId, Address and advSid for this hashmap*/
     private final Map<Integer, BluetoothLeBroadcastReceiveState>
             mBluetoothLeBroadcastReceiveStates =
@@ -159,7 +163,6 @@
     private boolean mDiscoveryInitiated = false;
     @VisibleForTesting
     BassClientService mService;
-    private BluetoothGatt mBluetoothGatt = null;
 
     private BluetoothGattCharacteristic mBroadcastScanControlPoint;
     private boolean mFirstTimeBisDiscovery = false;
@@ -185,6 +188,9 @@
     private int mBroadcastSourceIdLength = 3;
     private byte mNextSourceId = 0;
 
+    BluetoothGatt mBluetoothGatt = null;
+    BluetoothGattCallback mGattCallback = null;
+
     BassClientStateMachine(BluetoothDevice device, BassClientService svc, Looper looper) {
         super(TAG + "(" + device.toString() + ")", looper);
         mDevice = device;
@@ -242,6 +248,7 @@
             mBluetoothGatt.disconnect();
             mBluetoothGatt.close();
             mBluetoothGatt = null;
+            mGattCallback = null;
         }
         mPendingOperation = -1;
         mPendingSourceId = -1;
@@ -825,8 +832,7 @@
 
     // Implements callback methods for GATT events that the app cares about.
     // For example, connection change and services discovered.
-    private final BluetoothGattCallback mGattCallback =
-            new BluetoothGattCallback() {
+    final class GattCallback extends BluetoothGattCallback {
                 @Override
                 public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
                     boolean isStateChanged = false;
@@ -980,6 +986,25 @@
             };
 
     /**
+     * Connects to the GATT server of the device.
+     *
+     * @return {@code true} if it successfully connects to the GATT server.
+     */
+    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+    public boolean connectGatt(Boolean autoConnect) {
+        if (mGattCallback == null) {
+            mGattCallback = new GattCallback();
+        }
+
+        mBluetoothGatt = mDevice.connectGatt(mService, autoConnect,
+                mGattCallback, BluetoothDevice.TRANSPORT_LE,
+                (BluetoothDevice.PHY_LE_1M_MASK
+                        | BluetoothDevice.PHY_LE_2M_MASK
+                        | BluetoothDevice.PHY_LE_CODED_MASK), null);
+        return mBluetoothGatt != null;
+    }
+
+    /**
      * getAllSources
      */
     public List<BluetoothLeBroadcastReceiveState> getAllSources() {
@@ -1052,11 +1077,7 @@
                 if (mLastConnectionState != BluetoothProfile.STATE_DISCONNECTED) {
                     // Reconnect in background if not disallowed by the service
                     if (mService.okToConnect(mDevice)) {
-                        mBluetoothGatt = mDevice.connectGatt(mService, true,
-                                mGattCallback, BluetoothDevice.TRANSPORT_LE,
-                                (BluetoothDevice.PHY_LE_1M_MASK
-                                        | BluetoothDevice.PHY_LE_2M_MASK
-                                        | BluetoothDevice.PHY_LE_CODED_MASK), null);
+                        connectGatt(false);
                     }
                 }
             }
@@ -1082,16 +1103,10 @@
                         mBluetoothGatt.close();
                         mBluetoothGatt = null;
                     }
-                    mBluetoothGatt = mDevice.connectGatt(mService, mIsAllowedList,
-                            mGattCallback, BluetoothDevice.TRANSPORT_LE, false,
-                            (BluetoothDevice.PHY_LE_1M_MASK
-                                    | BluetoothDevice.PHY_LE_2M_MASK
-                                    | BluetoothDevice.PHY_LE_CODED_MASK), null);
-                    if (mBluetoothGatt == null) {
-                        Log.e(TAG, "Disconnected: error connecting to " + mDevice);
-                        break;
-                    } else {
+                    if (connectGatt(mIsAllowedList)) {
                         transitionTo(mConnecting);
+                    } else {
+                        Log.e(TAG, "Disconnected: error connecting to " + mDevice);
                     }
                     break;
                 case DISCONNECT:
@@ -1132,7 +1147,7 @@
         public void enter() {
             log("Enter Connecting(" + mDevice + "): "
                     + messageWhatToString(getCurrentMessage().what));
-            sendMessageDelayed(CONNECT_TIMEOUT, mDevice, BassConstants.CONNECT_TIMEOUT_MS);
+            sendMessageDelayed(CONNECT_TIMEOUT, mDevice, sConnectTimeoutMs);
             broadcastConnectionState(
                     mDevice, mLastConnectionState, BluetoothProfile.STATE_CONNECTING);
         }
@@ -1788,7 +1803,7 @@
         public void enter() {
             log("Enter Disconnecting(" + mDevice + "): "
                     + messageWhatToString(getCurrentMessage().what));
-            sendMessageDelayed(CONNECT_TIMEOUT, mDevice, BassConstants.CONNECT_TIMEOUT_MS);
+            sendMessageDelayed(CONNECT_TIMEOUT, mDevice, sConnectTimeoutMs);
             broadcastConnectionState(
                     mDevice, mLastConnectionState, BluetoothProfile.STATE_DISCONNECTING);
         }
diff --git a/android/app/tests/unit/src/com/android/bluetooth/bass_client/BassClientStateMachineTest.java b/android/app/tests/unit/src/com/android/bluetooth/bass_client/BassClientStateMachineTest.java
new file mode 100644
index 0000000..e021c42
--- /dev/null
+++ b/android/app/tests/unit/src/com/android/bluetooth/bass_client/BassClientStateMachineTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 2022 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.bluetooth.bass_client;
+
+import static android.bluetooth.BluetoothGatt.GATT_SUCCESS;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.after;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.reset;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.HandlerThread;
+import android.os.Looper;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.LargeTest;
+import androidx.test.filters.MediumTest;
+
+import com.android.bluetooth.R;
+import com.android.bluetooth.TestUtils;
+import com.android.bluetooth.btservice.AdapterService;
+
+import org.hamcrest.core.IsInstanceOf;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@MediumTest
+@RunWith(JUnit4.class)
+public class BassClientStateMachineTest {
+    @Rule
+    public final MockitoRule mockito = MockitoJUnit.rule();
+
+    private BluetoothAdapter mAdapter;
+    private Context mTargetContext;
+    private HandlerThread mHandlerThread;
+    private StubBassClientStateMachine mBassClientStateMachine;
+    private static final int CONNECTION_TIMEOUT_MS = 1_000;
+    private static final int TIMEOUT_MS = 2_000;
+    private static final int WAIT_MS = 1_200;
+
+    private BluetoothDevice mTestDevice;
+    @Mock private AdapterService mAdapterService;
+    @Mock private BassClientService mBassClientService;
+
+    @Before
+    public void setUp() throws Exception {
+        mTargetContext = InstrumentationRegistry.getTargetContext();
+        TestUtils.setAdapterService(mAdapterService);
+
+        mAdapter = BluetoothAdapter.getDefaultAdapter();
+
+        // Get a device for testing
+        mTestDevice = mAdapter.getRemoteDevice("00:01:02:03:04:05");
+
+        // Set up thread and looper
+        mHandlerThread = new HandlerThread("BassClientStateMachineTestHandlerThread");
+        mHandlerThread.start();
+        mBassClientStateMachine = new StubBassClientStateMachine(mTestDevice,
+                mBassClientService, mHandlerThread.getLooper());
+        // Override the timeout value to speed up the test
+        BassClientStateMachine.sConnectTimeoutMs = CONNECTION_TIMEOUT_MS;
+        mBassClientStateMachine.start();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mBassClientStateMachine.doQuit();
+        mHandlerThread.quit();
+        TestUtils.clearAdapterService(mAdapterService);
+    }
+
+    /**
+     * Test that default state is disconnected
+     */
+    @Test
+    public void testDefaultDisconnectedState() {
+        Assert.assertEquals(BluetoothProfile.STATE_DISCONNECTED,
+                mBassClientStateMachine.getConnectionState());
+    }
+
+    /**
+     * Allow/disallow connection to any device.
+     *
+     * @param allow if true, connection is allowed
+     */
+    private void allowConnection(boolean allow) {
+        when(mBassClientService.okToConnect(any(BluetoothDevice.class))).thenReturn(allow);
+    }
+
+    private void allowConnectGatt(boolean allow) {
+        mBassClientStateMachine.mShouldAllowGatt = allow;
+    }
+
+    /**
+     * Test that an incoming connection with policy forbidding connection is rejected
+     */
+    @Test
+    public void testOkToConnectFails() {
+        allowConnection(false);
+        allowConnectGatt(true);
+
+        // Inject an event for when incoming connection is requested
+        mBassClientStateMachine.sendMessage(BassClientStateMachine.CONNECT);
+
+        // Verify that no connection state broadcast is executed
+        verify(mBassClientService, after(WAIT_MS).never()).sendBroadcast(any(Intent.class),
+                anyString());
+
+        // Check that we are in Disconnected state
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Disconnected.class));
+    }
+
+    @Test
+    public void testFailToConnectGatt() {
+        allowConnection(true);
+        allowConnectGatt(false);
+
+        // Inject an event for when incoming connection is requested
+        mBassClientStateMachine.sendMessage(BassClientStateMachine.CONNECT);
+
+        // Verify that no connection state broadcast is executed
+        verify(mBassClientService, after(WAIT_MS).never()).sendBroadcast(any(Intent.class),
+                anyString());
+
+        // Check that we are in Disconnected state
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Disconnected.class));
+        assertNull(mBassClientStateMachine.mBluetoothGatt);
+    }
+
+    @Test
+    public void testSuccessfullyConnected() {
+        allowConnection(true);
+        allowConnectGatt(true);
+
+        // Inject an event for when incoming connection is requested
+        mBassClientStateMachine.sendMessage(BassClientStateMachine.CONNECT);
+
+        // Verify that one connection state broadcast is executed
+        ArgumentCaptor<Intent> intentArgument1 = ArgumentCaptor.forClass(Intent.class);
+        verify(mBassClientService, timeout(TIMEOUT_MS).times(1)).sendBroadcast(
+                intentArgument1.capture(), anyString(), any(Bundle.class));
+        Assert.assertEquals(BluetoothProfile.STATE_CONNECTING,
+                intentArgument1.getValue().getIntExtra(BluetoothProfile.EXTRA_STATE, -1));
+
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Connecting.class));
+
+        assertNotNull(mBassClientStateMachine.mGattCallback);
+        mBassClientStateMachine.notifyConnectionStateChanged(
+                GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED);
+
+        // Verify that the expected number of broadcasts are executed:
+        // - two calls to broadcastConnectionState(): Disconnected -> Connecting -> Connected
+        ArgumentCaptor<Intent> intentArgument2 = ArgumentCaptor.forClass(Intent.class);
+        verify(mBassClientService, timeout(TIMEOUT_MS).times(2)).sendBroadcast(
+                intentArgument2.capture(), anyString(), any(Bundle.class));
+
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Connected.class));
+    }
+
+    @Test
+    public void testConnectGattTimeout() {
+        allowConnection(true);
+        allowConnectGatt(true);
+
+        // Inject an event for when incoming connection is requested
+        mBassClientStateMachine.sendMessage(BassClientStateMachine.CONNECT);
+
+        // Verify that one connection state broadcast is executed
+        ArgumentCaptor<Intent> intentArgument1 = ArgumentCaptor.forClass(Intent.class);
+        verify(mBassClientService, timeout(TIMEOUT_MS).times(1)).sendBroadcast(
+                intentArgument1.capture(), anyString(), any(Bundle.class));
+        Assert.assertEquals(BluetoothProfile.STATE_CONNECTING,
+                intentArgument1.getValue().getIntExtra(BluetoothProfile.EXTRA_STATE, -1));
+
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Connecting.class));
+
+        // Verify that one connection state broadcast is executed
+        ArgumentCaptor<Intent> intentArgument2 = ArgumentCaptor.forClass(Intent.class);
+        verify(mBassClientService, timeout(TIMEOUT_MS).times(
+                2)).sendBroadcast(intentArgument2.capture(), anyString(), any(Bundle.class));
+        Assert.assertEquals(BluetoothProfile.STATE_DISCONNECTED,
+                intentArgument2.getValue().getIntExtra(BluetoothProfile.EXTRA_STATE, -1));
+
+        Assert.assertThat(mBassClientStateMachine.getCurrentState(),
+                IsInstanceOf.instanceOf(BassClientStateMachine.Disconnected.class));
+    }
+
+    // It simulates GATT connection for testing.
+    public class StubBassClientStateMachine extends BassClientStateMachine {
+        boolean mShouldAllowGatt = true;
+
+        StubBassClientStateMachine(BluetoothDevice device, BassClientService service, Looper looper) {
+            super(device, service, looper);
+        }
+
+        @Override
+        public boolean connectGatt(Boolean autoConnect) {
+            mGattCallback = new GattCallback();
+            return mShouldAllowGatt;
+        }
+
+        public void notifyConnectionStateChanged(int status, int newState) {
+            if (mGattCallback != null) {
+                mGattCallback.onConnectionStateChange(mBluetoothGatt, status, newState);
+            }
+        }
+    }
+}