From b2afdb9462c4d5a7ff0ac7c039b81e70edabe2e1 Mon Sep 17 00:00:00 2001
From: Yuyang Huang <yuyangh@google.com>
Date: Tue, 16 May 2023 14:46:15 -0700
Subject: [PATCH] Enhance CLCC inference

sort CLCC response by index
add null check
clear inference when conference call children arrive
clear inference when parent call ends

Bug: 262199042
Test: atest com.android.bluetooth.telephony.BluetoothInCallServiceTest
(cherry picked from https://android-review.googlesource.com/q/commit:de135fd4c6a1ed974314a48f5346bfb7750b81c4)
Merged-In: I35d2f8569af1deb13b830a1f23ec540679435233
Change-Id: I35d2f8569af1deb13b830a1f23ec540679435233
---
 .../telephony/BluetoothInCallService.java     | 112 ++++++++++----
 .../telephony/BluetoothInCallServiceTest.java | 140 ++++++++++++++++++
 2 files changed, 224 insertions(+), 28 deletions(-)

diff --git a/android/app/src/com/android/bluetooth/telephony/BluetoothInCallService.java b/android/app/src/com/android/bluetooth/telephony/BluetoothInCallService.java
index a7fa694fd43..4ebc38dccb6 100644
--- a/android/app/src/com/android/bluetooth/telephony/BluetoothInCallService.java
+++ b/android/app/src/com/android/bluetooth/telephony/BluetoothInCallService.java
@@ -62,6 +62,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Queue;
+import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.UUID;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -75,7 +77,7 @@ import java.util.concurrent.Executors;
 public class BluetoothInCallService extends InCallService {
 
     private static final String TAG = "BluetoothInCallService";
-    private static final String CLCC_INFERENCE = "ConferenceCallInference";
+    @VisibleForTesting static final String CLCC_INFERENCE = "ConferenceCallInference";
 
     // match up with bthf_call_state_t of bt_hf.h
     private static final int CALL_STATE_ACTIVE = 0;
@@ -154,7 +156,7 @@ public class BluetoothInCallService extends InCallService {
     private int mMaxNumberOfCalls = 0;
 
     /**
-     * Listens to connections and disconnections of bluetooth headsets.  We need to save the current
+     * Listens to connections and disconnections of bluetooth headsets. We need to save the current
      * bluetooth headset so that we know where to send BluetoothCall updates.
      */
     @VisibleForTesting
@@ -164,11 +166,13 @@ public class BluetoothInCallService extends InCallService {
                 public void onServiceConnected(int profile, BluetoothProfile proxy) {
                     synchronized (LOCK) {
                         if (profile == BluetoothProfile.HEADSET) {
-                            setBluetoothHeadset(new BluetoothHeadsetProxy((BluetoothHeadset) proxy));
+                            setBluetoothHeadset(
+                                    new BluetoothHeadsetProxy((BluetoothHeadset) proxy));
                             updateHeadsetWithCallState(true /* force */);
                         } else {
-                            setBluetoothLeCallControl(new BluetoothLeCallControlProxy((
-                                    BluetoothLeCallControl) proxy));
+                            setBluetoothLeCallControl(
+                                    new BluetoothLeCallControlProxy(
+                                            (BluetoothLeCallControl) proxy));
                             sendTbsCurrentCallsList();
                         }
                     }
@@ -639,15 +643,30 @@ public class BluetoothInCallService extends InCallService {
         if (mBluetoothCallHashMap.containsKey(call.getId())) {
             mBluetoothCallHashMap.remove(call.getId());
 
-            mBluetoothCallQueue.add(call.getId());
-            mBluetoothConferenceCallInference.put(call.getId(), call);
-            mClccInferenceIndexMap.put(getClccMapKey(call), mClccIndexMap.get(getClccMapKey(call)));
-            // queue size limited to 2 because merge operation only happens on 2 calls
-            // we are only interested in last 2 calls merged
-            if (mBluetoothCallQueue.size() > 2) {
-                Integer callId = mBluetoothCallQueue.peek();
-                mBluetoothCallQueue.remove();
-                mBluetoothConferenceCallInference.remove(callId);
+            DisconnectCause cause = call.getDisconnectCause();
+            if (cause != null && cause.getCode() == DisconnectCause.OTHER) {
+                Log.d(TAG, "add inference call with reason: " + cause.getReason());
+                mBluetoothCallQueue.add(call.getId());
+                mBluetoothConferenceCallInference.put(call.getId(), call);
+                Integer indexValue = mClccIndexMap.get(getClccMapKey(call));
+                mClccInferenceIndexMap.put(getClccMapKey(call), indexValue);
+                if (indexValue == null) {
+                    Log.w(TAG, "CLCC index value is null");
+                }
+                // queue size limited to 2 because merge operation only happens on 2 calls
+                // we are only interested in last 2 calls merged
+                if (mBluetoothCallQueue.size() > 2) {
+                    Integer callId = mBluetoothCallQueue.peek();
+                    mBluetoothCallQueue.remove();
+                    mBluetoothConferenceCallInference.remove(callId);
+                }
+            }
+            // As there is at most 1 conference call, so clear inference when parent call ends
+            if (call.isConference()) {
+                Log.d(TAG, "conference call ends, clear inference");
+                mBluetoothConferenceCallInference.clear();
+                mClccInferenceIndexMap.clear();
+                mBluetoothCallQueue.clear();
             }
         }
 
@@ -745,18 +764,34 @@ public class BluetoothInCallService extends InCallService {
         Log.d(TAG, "is conference call inference enabled: " + isInferenceEnabled);
         for (BluetoothCall call : calls) {
             if (isInferenceEnabled && call.isConference()
-                    && call.getChildrenIds().size() < 2
                     && !mBluetoothConferenceCallInference.isEmpty()) {
-                Log.d(TAG, "conference call inferred size: "
-                        + mBluetoothConferenceCallInference.size()
-                        + "current size: " + mBluetoothCallHashMap.size());
+                SortedMap<Integer, Object[]> clccResponseMap = new TreeMap<>();
+                Log.d(
+                        TAG,
+                        "conference call inferred size: "
+                                + mBluetoothConferenceCallInference.size()
+                                + " current size: "
+                                + mBluetoothCallHashMap.size());
                 // Do conference call inference until at least 2 children arrive
                 // If carrier does send children info, then inference will end when info arrives.
                 // If carrier does not send children info, then inference won't impact actual value.
+                if (call.getChildrenIds().size() >= 2) {
+                    mBluetoothConferenceCallInference.clear();
+                    break;
+                }
                 for (BluetoothCall inferredCall : mBluetoothConferenceCallInference.values()) {
-                    int index = mClccInferenceIndexMap.get(getClccMapKey(inferredCall));
+                    String clccMapKey = getClccMapKey(inferredCall);
+                    if (!mClccInferenceIndexMap.containsKey(clccMapKey)) {
+                        Log.w(TAG, "Inference Index Map does not have: " + clccMapKey);
+                        continue;
+                    }
+                    if (mClccInferenceIndexMap.get(clccMapKey) == null) {
+                        Log.w(TAG, "inferred index is null");
+                        continue;
+                    }
+                    int index = mClccInferenceIndexMap.get(clccMapKey);
                     // save the index so later on when real children arrive, index is the same
-                    mClccIndexMap.put(getClccMapKey(inferredCall), index);
+                    mClccIndexMap.put(clccMapKey, index);
                     int direction = inferredCall.isIncoming() ? 1 : 0;
                     int state = CALL_STATE_ACTIVE;
                     boolean isPartOfConference = true;
@@ -770,17 +805,38 @@ public class BluetoothInCallService extends InCallService {
                     if (address != null) {
                         address = PhoneNumberUtils.stripSeparators(address);
                     }
-
                     int addressType =
                             address == null ? -1 : PhoneNumberUtils.toaFromString(address);
-                    Log.i(TAG, "sending inferred clcc for BluetoothCall "
-                            + index + ", "
-                            + direction + ", "
-                            + state + ", "
-                            + isPartOfConference + ", "
-                            + addressType);
+                    clccResponseMap.put(
+                            index,
+                            new Object[] {
+                                index, direction, state, 0, isPartOfConference, address, addressType
+                            });
+                }
+                // ensure response is sorted by index
+                for (Object[] response : clccResponseMap.values()) {
+                    if (response.length < 7) {
+                        Log.e(TAG, "clccResponseMap entry too short");
+                        continue;
+                    }
+                    Log.i(
+                            TAG,
+                            String.format(
+                                    "sending inferred clcc for BluetoothCall: index %d, direction"
+                                        + " %d, state %d, isPartOfConference %b, addressType %d",
+                                    (int) response[0],
+                                    (int) response[1],
+                                    (int) response[2],
+                                    (boolean) response[4],
+                                    (int) response[6]));
                     mBluetoothHeadset.clccResponse(
-                            index, direction, state, 0, isPartOfConference, address, addressType);
+                            (int) response[0],
+                            (int) response[1],
+                            (int) response[2],
+                            (int) response[3],
+                            (boolean) response[4],
+                            (String) response[5],
+                            (int) response[6]);
                 }
                 sendClccEndMarker();
                 return;
diff --git a/android/app/tests/unit/src/com/android/bluetooth/telephony/BluetoothInCallServiceTest.java b/android/app/tests/unit/src/com/android/bluetooth/telephony/BluetoothInCallServiceTest.java
index 0b503f1659b..ea787aa7330 100644
--- a/android/app/tests/unit/src/com/android/bluetooth/telephony/BluetoothInCallServiceTest.java
+++ b/android/app/tests/unit/src/com/android/bluetooth/telephony/BluetoothInCallServiceTest.java
@@ -16,6 +16,7 @@
 
 package com.android.bluetooth.telephony;
 
+import static com.android.bluetooth.telephony.BluetoothInCallService.CLCC_INFERENCE;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
@@ -31,6 +32,7 @@ import android.os.Binder;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.IBinder;
+import android.provider.DeviceConfig;
 import android.telecom.BluetoothCallQualityReport;
 import android.telecom.Call;
 import android.telecom.Connection;
@@ -766,6 +768,144 @@ public class BluetoothInCallServiceTest {
                 eq(1), eq(1), eq(0), eq(0), eq(true), eq("5551234"), eq(129));
     }
 
+    @Test
+    public void testListCurrentCallsConferenceEmptyChildrenInference() throws Exception {
+        DeviceConfig.setProperty(DeviceConfig.NAMESPACE_BLUETOOTH, CLCC_INFERENCE, "true", false);
+
+        ArrayList<BluetoothCall> calls = new ArrayList<>();
+        when(mMockCallInfo.getBluetoothCalls()).thenReturn(calls);
+
+        // active call is added
+        BluetoothCall activeCall = createActiveCall(UUID.randomUUID());
+        calls.add(activeCall);
+        mBluetoothInCallService.onCallAdded(activeCall);
+
+        when(activeCall.getState()).thenReturn(Call.STATE_ACTIVE);
+        when(activeCall.isIncoming()).thenReturn(false);
+        when(activeCall.isConference()).thenReturn(false);
+        when(activeCall.getHandle()).thenReturn(Uri.parse("tel:555-0001"));
+        when(activeCall.getGatewayInfo())
+                .thenReturn(new GatewayInfo(null, null, Uri.parse("tel:555-0001")));
+
+        // holding call is added
+        BluetoothCall holdingCall = createHeldCall(UUID.randomUUID());
+        calls.add(holdingCall);
+        mBluetoothInCallService.onCallAdded(holdingCall);
+
+        when(holdingCall.getState()).thenReturn(Call.STATE_HOLDING);
+        when(holdingCall.isIncoming()).thenReturn(true);
+        when(holdingCall.isConference()).thenReturn(false);
+        when(holdingCall.getHandle()).thenReturn(Uri.parse("tel:555-0002"));
+        when(holdingCall.getGatewayInfo())
+                .thenReturn(new GatewayInfo(null, null, Uri.parse("tel:555-0002")));
+
+        // needs to have at least one CLCC response before merge to enable call inference
+        clearInvocations(mMockBluetoothHeadset);
+        mBluetoothInCallService.listCurrentCalls();
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        1, 0, CALL_STATE_ACTIVE, 0, false, "5550001", PhoneNumberUtils.TOA_Unknown);
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        2, 1, CALL_STATE_HELD, 0, false, "5550002", PhoneNumberUtils.TOA_Unknown);
+        calls.clear();
+
+        // calls merged for conference call
+        DisconnectCause cause = new DisconnectCause(DisconnectCause.OTHER);
+        when(activeCall.getDisconnectCause()).thenReturn(cause);
+        when(holdingCall.getDisconnectCause()).thenReturn(cause);
+        mBluetoothInCallService.onCallRemoved(activeCall, true);
+        mBluetoothInCallService.onCallRemoved(holdingCall, true);
+
+        BluetoothCall conferenceCall = createActiveCall(UUID.randomUUID());
+        addCallCapability(conferenceCall, Connection.CAPABILITY_MANAGE_CONFERENCE);
+
+        when(conferenceCall.getHandle()).thenReturn(Uri.parse("tel:555-1234"));
+        when(conferenceCall.isConference()).thenReturn(true);
+        when(conferenceCall.getState()).thenReturn(Call.STATE_ACTIVE);
+        when(conferenceCall.hasProperty(Call.Details.PROPERTY_GENERIC_CONFERENCE)).thenReturn(true);
+        when(conferenceCall.can(Connection.CAPABILITY_CONFERENCE_HAS_NO_CHILDREN))
+                .thenReturn(false);
+        when(conferenceCall.isIncoming()).thenReturn(true);
+        when(mMockCallInfo.getBluetoothCalls()).thenReturn(calls);
+
+        // parent call arrived, but children have not, then do inference on children
+        calls.add(conferenceCall);
+        Assert.assertEquals(calls.size(), 1);
+        mBluetoothInCallService.onCallAdded(conferenceCall);
+
+        clearInvocations(mMockBluetoothHeadset);
+        mBluetoothInCallService.listCurrentCalls();
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        1, 0, CALL_STATE_ACTIVE, 0, true, "5550001", PhoneNumberUtils.TOA_Unknown);
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        2, 1, CALL_STATE_ACTIVE, 0, true, "5550002", PhoneNumberUtils.TOA_Unknown);
+
+        // real children arrive, no change on CLCC response
+        calls.add(activeCall);
+        mBluetoothInCallService.onCallAdded(activeCall);
+        when(activeCall.isConference()).thenReturn(true);
+        calls.add(holdingCall);
+        mBluetoothInCallService.onCallAdded(holdingCall);
+        when(holdingCall.getState()).thenReturn(Call.STATE_ACTIVE);
+        when(holdingCall.isConference()).thenReturn(true);
+        when(conferenceCall.getChildrenIds()).thenReturn(new ArrayList<>(Arrays.asList(1, 2)));
+
+        clearInvocations(mMockBluetoothHeadset);
+        mBluetoothInCallService.listCurrentCalls();
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        1, 0, CALL_STATE_ACTIVE, 0, true, "5550001", PhoneNumberUtils.TOA_Unknown);
+        verify(mMockBluetoothHeadset)
+                .clccResponse(
+                        2, 1, CALL_STATE_ACTIVE, 0, true, "5550002", PhoneNumberUtils.TOA_Unknown);
+
+        // when call is terminated, children first removed, then parent
+        cause = new DisconnectCause(DisconnectCause.LOCAL);
+        when(activeCall.getDisconnectCause()).thenReturn(cause);
+        when(holdingCall.getDisconnectCause()).thenReturn(cause);
+        mBluetoothInCallService.onCallRemoved(activeCall, true);
+        mBluetoothInCallService.onCallRemoved(holdingCall, true);
+        calls.remove(activeCall);
+        calls.remove(holdingCall);
+        Assert.assertEquals(calls.size(), 1);
+
+        clearInvocations(mMockBluetoothHeadset);
+        mBluetoothInCallService.listCurrentCalls();
+        verify(mMockBluetoothHeadset).clccResponse(0, 0, 0, 0, false, null, 0);
+        verify(mMockBluetoothHeadset, times(1))
+                .clccResponse(
+                        anyInt(),
+                        anyInt(),
+                        anyInt(),
+                        anyInt(),
+                        anyBoolean(),
+                        nullable(String.class),
+                        anyInt());
+
+        // when parent is removed
+        when(conferenceCall.getDisconnectCause()).thenReturn(cause);
+        calls.remove(conferenceCall);
+        mBluetoothInCallService.onCallRemoved(conferenceCall, true);
+
+        clearInvocations(mMockBluetoothHeadset);
+        mBluetoothInCallService.listCurrentCalls();
+        verify(mMockBluetoothHeadset).clccResponse(0, 0, 0, 0, false, null, 0);
+        verify(mMockBluetoothHeadset, times(1))
+                .clccResponse(
+                        anyInt(),
+                        anyInt(),
+                        anyInt(),
+                        anyInt(),
+                        anyBoolean(),
+                        nullable(String.class),
+                        anyInt());
+
+        DeviceConfig.setProperty(DeviceConfig.NAMESPACE_BLUETOOTH, CLCC_INFERENCE, "false", false);
+    }
+
     @Test
     public void testQueryPhoneState() throws Exception {
         BluetoothCall ringingCall = createRingingCall(UUID.randomUUID());
-- 
GitLab