Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 261bdd19 authored by Jakub Tyszkowski's avatar Jakub Tyszkowski Committed by Jakub Tyszkowski
Browse files

BassClient: Add initial support for group operations

This add the mechanism for coordinated group operations
on BASS capable devices. It uses CAP context for discovering
device groups.

Adding source with group operation flag sets the sticky flag
on a particular device (and sourceId assigned as a result of
the add source request). After that, any modification on this
source or any BASS source on the other group member with the
same configured broadcast (since each device can assign a different
sourceId for the same add source request) results in such
modification being performed on each group member.

Sticky group flag is removed from each group member device
when removeSource() is called for a particular sourceId on each
device with the sticky flag already set.

Bug: 230559809
Tag: #feature
Sponsor: jpawlowski@
Test: atest BluetoothInstrumentationTests
Change-Id: Ia1d38777e08851c8c0ed49687d11dfbdd701b918
Merged-In: Ia1d38777e08851c8c0ed49687d11dfbdd701b918
(cherry picked from commit 2d678a471eb2223abeb36613b106a41782132db9)
parent b64273fa
Loading
Loading
Loading
Loading
+337 −89
Original line number Diff line number Diff line
@@ -44,11 +44,14 @@ import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.sysprop.BluetoothProperties;
import android.util.Log;
import android.util.Pair;

import com.android.bluetooth.Utils;
import com.android.bluetooth.btservice.AdapterService;
import com.android.bluetooth.btservice.ProfileService;
import com.android.bluetooth.btservice.ServiceFactory;
import com.android.bluetooth.btservice.storage.DatabaseManager;
import com.android.bluetooth.csip.CsipSetCoordinatorService;
import com.android.internal.annotations.VisibleForTesting;

import java.util.ArrayList;
@@ -57,6 +60,8 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Broacast Assistant Scan Service
@@ -72,6 +77,11 @@ public class BassClientService extends ProfileService {
    private final Object mSearchScanCallbackLock = new Object();
    private final Map<Integer, ScanResult> mScanBroadcasts = new HashMap<>();

    private final Map<BluetoothDevice, List<Pair<Integer, Object>>> mPendingGroupOp =
            new ConcurrentHashMap<>();
    private final Map<BluetoothDevice, List<Integer>> mGroupManagedSources =
            new ConcurrentHashMap<>();

    private HandlerThread mStateMachinesThread;
    private HandlerThread mCallbackHandlerThread;
    private AdapterService mAdapterService;
@@ -92,6 +102,9 @@ public class BassClientService extends ProfileService {
    private ScanCallback mSearchScanCallback;
    private Callbacks mCallbacks;

    @VisibleForTesting
    ServiceFactory mServiceFactory = new ServiceFactory();

    public static boolean isEnabled() {
        return BluetoothProperties.isProfileBapBroadcastAssistEnabled().orElse(false);
    }
@@ -273,6 +286,9 @@ public class BassClientService extends ProfileService {
            mBassUtils.cleanUp();
            mBassUtils = null;
        }
        if (mPendingGroupOp != null) {
            mPendingGroupOp.clear();
        }
        return true;
    }

@@ -311,6 +327,156 @@ public class BassClientService extends ProfileService {
        sService = instance;
    }

    private void enqueueSourceGroupOp(BluetoothDevice sink, Integer msgId, Object obj) {
        log("enqueueSourceGroupOp device: " + sink + ", msgId: " + msgId);

        if (!mPendingGroupOp.containsKey(sink)) {
            mPendingGroupOp.put(sink, new ArrayList());
        }
        mPendingGroupOp.get(sink).add(new Pair<Integer, Object>(msgId, obj));
    }

    private boolean isSuccess(int status) {
        boolean ret = false;
        switch (status) {
            case BluetoothStatusCodes.REASON_LOCAL_APP_REQUEST:
            case BluetoothStatusCodes.REASON_LOCAL_STACK_REQUEST:
            case BluetoothStatusCodes.REASON_REMOTE_REQUEST:
            case BluetoothStatusCodes.REASON_SYSTEM_POLICY:
                ret = true;
                break;
            default:
                break;
        }
        return ret;
    }

    private void checkForPendingGroupOpRequest(BluetoothDevice sink, int reason, int reqMsg,
            Object obj) {
        log("checkForPendingGroupOpRequest device: " + sink + ", reason: " + reason
                + ", reqMsg: " + reqMsg);

        List<Pair<Integer, Object>> operations = mPendingGroupOp.get(sink);
        if (operations == null) {
            return;
        }

        switch (reqMsg) {
            case BassClientStateMachine.ADD_BCAST_SOURCE:
                if (obj == null) {
                    return;
                }
                // Identify the operation by operation type and broadcastId
                if (isSuccess(reason)) {
                    BluetoothLeBroadcastReceiveState sourceState =
                            (BluetoothLeBroadcastReceiveState) obj;
                    boolean removed = operations.removeIf(m ->
                            (m.first.equals(BassClientStateMachine.ADD_BCAST_SOURCE))
                            && (sourceState.getBroadcastId()
                                    == ((BluetoothLeBroadcastMetadata) m.second).getBroadcastId()));
                    if (removed) {
                        setSourceGroupManaged(sink, sourceState.getSourceId(), true);

                    }
                } else {
                    BluetoothLeBroadcastMetadata metadata = (BluetoothLeBroadcastMetadata) obj;
                    operations.removeIf(m ->
                            (m.first.equals(BassClientStateMachine.ADD_BCAST_SOURCE))
                            && (metadata.getBroadcastId()
                                    == ((BluetoothLeBroadcastMetadata) m.second).getBroadcastId()));
                }
                break;
            case BassClientStateMachine.REMOVE_BCAST_SOURCE:
                // Identify the operation by operation type and sourceId
                Integer sourceId = (Integer) obj;
                operations.removeIf(m ->
                        m.first.equals(BassClientStateMachine.REMOVE_BCAST_SOURCE)
                        && (sourceId.equals((Integer) m.second)));
                setSourceGroupManaged(sink, sourceId, false);
                break;
            default:
                break;
        }
    }

    private void setSourceGroupManaged(BluetoothDevice sink, int sourceId, boolean isGroupOp) {
        log("setSourceGroupManaged device: " + sink);
        if (isGroupOp) {
            if (!mGroupManagedSources.containsKey(sink)) {
                mGroupManagedSources.put(sink, new ArrayList<>());
            }
            mGroupManagedSources.get(sink).add(sourceId);
        } else {
            List<Integer> sources = mGroupManagedSources.get(sink);
            if (sources != null) {
                sources.removeIf(e -> e.equals(sourceId));
            }
        }
    }

    private Pair<BluetoothLeBroadcastMetadata, Map<BluetoothDevice, Integer>>
            getGroupManagedDeviceSources(BluetoothDevice sink, Integer sourceId) {
        log("getGroupManagedDeviceSources device: " + sink + " sourceId: " + sourceId);
        Map map = new HashMap<BluetoothDevice, Integer>();

        if (mGroupManagedSources.containsKey(sink)
                && mGroupManagedSources.get(sink).contains(sourceId)) {
            BassClientStateMachine stateMachine = getOrCreateStateMachine(sink);
            BluetoothLeBroadcastMetadata metadata =
                    stateMachine.getCurrentBroadcastMetadata(sourceId);
            if (metadata != null) {
                int broadcastId = metadata.getBroadcastId();

                for (BluetoothDevice device: getTargetDeviceList(sink, true)) {
                    List<BluetoothLeBroadcastReceiveState> sources =
                            getOrCreateStateMachine(device).getAllSources();

                    // For each device, find the source ID having this broadcast ID
                    Optional<BluetoothLeBroadcastReceiveState> receiver = sources.stream()
                            .filter(e -> e.getBroadcastId() == broadcastId)
                            .findAny();
                    if (receiver.isPresent()) {
                        map.put(device, receiver.get().getSourceId());
                    } else {
                        // Put invalid source ID if the remote doesn't have it
                        map.put(device, BassConstants.INVALID_SOURCE_ID);
                    }
                }
                return new Pair<BluetoothLeBroadcastMetadata,
                        Map<BluetoothDevice, Integer>>(metadata, map);
            } else {
                Log.e(TAG, "Couldn't find broadcast metadata for device: "
                        + sink.getAnonymizedAddress() + ", and sourceId:" + sourceId);
            }
        }

        // Just put this single device if this source is not group managed
        map.put(sink, sourceId);
        return new Pair<BluetoothLeBroadcastMetadata, Map<BluetoothDevice, Integer>>(null, map);
    }

    private List<BluetoothDevice> getTargetDeviceList(BluetoothDevice device, boolean isGroupOp) {
        if (isGroupOp) {
            CsipSetCoordinatorService csipClient = mServiceFactory.getCsipSetCoordinatorService();
            if (csipClient != null) {
                // Check for coordinated set of devices in the context of CAP
                List<BluetoothDevice> csipDevices = csipClient.getGroupDevicesOrdered(device,
                        BluetoothUuid.CAP);
                if (!csipDevices.isEmpty()) {
                    return csipDevices;
                } else {
                    Log.w(TAG, "CSIP group is empty.");
                }
            } else {
                Log.e(TAG, "CSIP service is null. No grouping information available.");
            }
        }

        List<BluetoothDevice> devices = new ArrayList<>();
        devices.add(device);
        return devices;
    }

    private boolean isValidBroadcastSourceAddition(
            BluetoothDevice device, BluetoothLeBroadcastMetadata metaData) {
        boolean retval = true;
@@ -755,37 +921,52 @@ public class BassClientService extends ProfileService {
            boolean isGroupOp) {
        log("addSource: device: " + sink + " sourceMetadata" + sourceMetadata
                + " isGroupOp" + isGroupOp);
        BassClientStateMachine stateMachine = getOrCreateStateMachine(sink);

        List<BluetoothDevice> devices = getTargetDeviceList(sink, isGroupOp);
        // Don't coordinate it as a group if there's no group or there is one device only
        if (devices.size() < 2) {
            isGroupOp = false;
        }
        for (BluetoothDevice device : devices) {
            BassClientStateMachine stateMachine = getOrCreateStateMachine(device);
            if (sourceMetadata == null || stateMachine == null) {
            log("Error bad parameters: sourceMetadata = " + sourceMetadata);
            mCallbacks.notifySourceAddFailed(sink, sourceMetadata,
                log("addSource: Error bad parameters: sourceMetadata = " + sourceMetadata);
                mCallbacks.notifySourceAddFailed(device, sourceMetadata,
                        BluetoothStatusCodes.ERROR_BAD_PARAMETERS);
            return;
                continue;
            }
        if (getConnectionState(sink) != BluetoothProfile.STATE_CONNECTED) {
            if (getConnectionState(device) != BluetoothProfile.STATE_CONNECTED) {
                log("addSource: device is not connected");
            mCallbacks.notifySourceAddFailed(sink, sourceMetadata,
                mCallbacks.notifySourceAddFailed(device, sourceMetadata,
                        BluetoothStatusCodes.ERROR_REMOTE_LINK_ERROR);
            return;
                continue;
            }
            if (stateMachine.hasPendingSourceOperation()) {
                throw new IllegalStateException("addSource: source operation already pending");
            }
        if (!hasRoomForBroadcastSourceAddition(sink)) {
            if (!hasRoomForBroadcastSourceAddition(device)) {
                log("addSource: device has no room");
            mCallbacks.notifySourceAddFailed(sink, sourceMetadata,
                mCallbacks.notifySourceAddFailed(device, sourceMetadata,
                        BluetoothStatusCodes.ERROR_REMOTE_NOT_ENOUGH_RESOURCES);
            return;
                continue;
            }
        if (!isValidBroadcastSourceAddition(sink, sourceMetadata)) {
            mCallbacks.notifySourceAddFailed(sink, sourceMetadata,
            if (!isValidBroadcastSourceAddition(device, sourceMetadata)) {
                log("addSource: not a valid broadcast source addition");
                mCallbacks.notifySourceAddFailed(device, sourceMetadata,
                        BluetoothStatusCodes.ERROR_LE_BROADCAST_ASSISTANT_DUPLICATE_ADDITION);
            return;
                continue;
            }

            if (isGroupOp) {
                enqueueSourceGroupOp(device, BassClientStateMachine.ADD_BCAST_SOURCE,
                        sourceMetadata);
            }

            Message message = stateMachine.obtainMessage(BassClientStateMachine.ADD_BCAST_SOURCE);
            message.obj = sourceMetadata;
            stateMachine.sendMessage(message);
        }
    }

    /**
     * Modify the Broadcast Source information on a Broadcast Sink
@@ -798,31 +979,43 @@ public class BassClientService extends ProfileService {
    public void modifySource(BluetoothDevice sink, int sourceId,
            BluetoothLeBroadcastMetadata updatedMetadata) {
        log("modifySource: device: " + sink + " sourceId " + sourceId);
        BassClientStateMachine stateMachine = getOrCreateStateMachine(sink);
        if (sourceId == BassConstants.INVALID_SOURCE_ID
                    || updatedMetadata == null
                    || stateMachine == null) {
            log("Error bad parameters: sourceId = " + sourceId

        Map<BluetoothDevice, Integer> devices = getGroupManagedDeviceSources(sink, sourceId).second;
        for (Map.Entry<BluetoothDevice, Integer> deviceSourceIdPair : devices.entrySet()) {
            BluetoothDevice device = deviceSourceIdPair.getKey();
            Integer deviceSourceId = deviceSourceIdPair.getValue();
            BassClientStateMachine stateMachine = getOrCreateStateMachine(device);
            if (updatedMetadata == null || stateMachine == null) {
                log("modifySource: Error bad parameters: sourceId = " + deviceSourceId
                        + " updatedMetadata = " + updatedMetadata);
            mCallbacks.notifySourceModifyFailed(sink, sourceId,
                mCallbacks.notifySourceModifyFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_BAD_PARAMETERS);
            return;
                continue;
            }
        if (getConnectionState(sink) != BluetoothProfile.STATE_CONNECTED) {
            if (deviceSourceId == BassConstants.INVALID_SOURCE_ID) {
                log("modifySource: no such sourceId for device: " + device);
                mCallbacks.notifySourceModifyFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_LE_BROADCAST_ASSISTANT_INVALID_SOURCE_ID);
                continue;
            }
            if (getConnectionState(device) != BluetoothProfile.STATE_CONNECTED) {
                log("modifySource: device is not connected");
            mCallbacks.notifySourceModifyFailed(sink, sourceId,
                mCallbacks.notifySourceModifyFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_REMOTE_LINK_ERROR);
            return;
                continue;
            }
            if (stateMachine.hasPendingSourceOperation()) {
                throw new IllegalStateException("modifySource: source operation already pending");
            }
        Message message = stateMachine.obtainMessage(BassClientStateMachine.UPDATE_BCAST_SOURCE);
        message.arg1 = sourceId;

            Message message =
                    stateMachine.obtainMessage(BassClientStateMachine.UPDATE_BCAST_SOURCE);
            message.arg1 = deviceSourceId;
            message.arg2 = BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_INVALID;
            message.obj = updatedMetadata;
            stateMachine.sendMessage(message);
        }
    }

    /**
     * Removes the Broadcast Source from a Broadcast Sink
@@ -832,28 +1025,38 @@ public class BassClientService extends ProfileService {
     * @param sourceId source ID as delivered in onSourceAdded
     */
    public void removeSource(BluetoothDevice sink, int sourceId) {
        log("removeSource: device = " + sink
                + "sourceId " + sourceId);
        BassClientStateMachine stateMachine = getOrCreateStateMachine(sink);
        if (sourceId == BassConstants.INVALID_SOURCE_ID
                || stateMachine == null) {
            log("removeSource: Error bad parameters: sourceId = " + sourceId);
            mCallbacks.notifySourceRemoveFailed(sink, sourceId,
        log("removeSource: device = " + sink + "sourceId " + sourceId);

        Map<BluetoothDevice, Integer> devices = getGroupManagedDeviceSources(sink, sourceId).second;
        for (Map.Entry<BluetoothDevice, Integer> deviceSourceIdPair : devices.entrySet()) {
            BluetoothDevice device = deviceSourceIdPair.getKey();
            Integer deviceSourceId = deviceSourceIdPair.getValue();
            BassClientStateMachine stateMachine = getOrCreateStateMachine(device);
            if (stateMachine == null) {
                log("removeSource: Error bad parameters: device = " + device);
                mCallbacks.notifySourceRemoveFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_BAD_PARAMETERS);
            return;
                continue;
            }
            if (deviceSourceId == BassConstants.INVALID_SOURCE_ID) {
                log("removeSource: no such sourceId for device: " + device);
                mCallbacks.notifySourceRemoveFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_LE_BROADCAST_ASSISTANT_INVALID_SOURCE_ID);
                continue;
            }
        if (getConnectionState(sink) != BluetoothProfile.STATE_CONNECTED) {
            if (getConnectionState(device) != BluetoothProfile.STATE_CONNECTED) {
                log("removeSource: device is not connected");
            mCallbacks.notifySourceRemoveFailed(sink, sourceId,
                mCallbacks.notifySourceRemoveFailed(device, sourceId,
                        BluetoothStatusCodes.ERROR_REMOTE_LINK_ERROR);
            return;
                continue;
            }

            BluetoothLeBroadcastReceiveState recvState =
                    stateMachine.getBroadcastReceiveStateForSourceId(sourceId);
            BluetoothLeBroadcastMetadata metaData =
                    stateMachine.getCurrentBroadcastMetadata(sourceId);
        if (metaData != null && recvState != null && recvState.getPaSyncState() ==
                BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED) {
            if (metaData != null && recvState != null && recvState.getPaSyncState()
                    == BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED) {
                log("Force source to lost PA sync");
                Message message = stateMachine.obtainMessage(
                        BassClientStateMachine.UPDATE_BCAST_SOURCE);
@@ -863,13 +1066,23 @@ public class BassClientService extends ProfileService {
                message.obj = metaData;
                stateMachine.sendMessage(message);

            return;
                continue;
            }
        Message message = stateMachine.obtainMessage(BassClientStateMachine.REMOVE_BCAST_SOURCE);
        message.arg1 = sourceId;

            Message message =
                    stateMachine.obtainMessage(BassClientStateMachine.REMOVE_BCAST_SOURCE);
            message.arg1 = deviceSourceId;
            stateMachine.sendMessage(message);
        }

        for (Map.Entry<BluetoothDevice, Integer> deviceSourceIdPair : devices.entrySet()) {
            BluetoothDevice device = deviceSourceIdPair.getKey();
            Integer deviceSourceId = deviceSourceIdPair.getValue();
            enqueueSourceGroupOp(device, BassClientStateMachine.REMOVE_BCAST_SOURCE,
                    new Integer(deviceSourceId));
        }
    }

    /**
     * Get information about all Broadcast Sources
     *
@@ -942,8 +1155,37 @@ public class BassClientService extends ProfileService {
            mCallbacks.unregister(callback);
        }

        private void checkForPendingGroupOpRequest(Message msg) {
            if (sService == null) {
                Log.e(TAG, "Service is null");
                return;
            }

            final int reason = msg.arg1;
            BluetoothDevice sink;

            switch (msg.what) {
                case MSG_SOURCE_ADDED:
                case MSG_SOURCE_ADDED_FAILED:
                    ObjParams param = (ObjParams) msg.obj;
                    sink = (BluetoothDevice) param.mObj1;
                    sService.checkForPendingGroupOpRequest(sink, reason,
                            BassClientStateMachine.ADD_BCAST_SOURCE, param.mObj2);
                    break;
                case MSG_SOURCE_REMOVED:
                case MSG_SOURCE_REMOVED_FAILED:
                    sink = (BluetoothDevice) msg.obj;
                    sService.checkForPendingGroupOpRequest(sink, reason,
                            BassClientStateMachine.REMOVE_BCAST_SOURCE, new Integer(msg.arg2));
                    break;
                default:
                    break;
            }
        }

        @Override
        public void handleMessage(Message msg) {
            checkForPendingGroupOpRequest(msg);
            final int n = mCallbacks.beginBroadcast();
            for (int i = 0; i < n; i++) {
                final IBluetoothLeBroadcastAssistantCallback callback =
@@ -990,7 +1232,9 @@ public class BassClientService extends ProfileService {
                    callback.onSourceFound((BluetoothLeBroadcastMetadata) msg.obj);
                    break;
                case MSG_SOURCE_ADDED:
                    callback.onSourceAdded((BluetoothDevice) msg.obj, sourceId, reason);
                    param = (ObjParams) msg.obj;
                    sink = (BluetoothDevice) param.mObj1;
                    callback.onSourceAdded(sink, sourceId, reason);
                    break;
                case MSG_SOURCE_ADDED_FAILED:
                    param = (ObjParams) msg.obj;
@@ -1006,10 +1250,12 @@ public class BassClientService extends ProfileService {
                    callback.onSourceModifyFailed((BluetoothDevice) msg.obj, sourceId, reason);
                    break;
                case MSG_SOURCE_REMOVED:
                    callback.onSourceRemoved((BluetoothDevice) msg.obj, sourceId, reason);
                    sink = (BluetoothDevice) msg.obj;
                    callback.onSourceRemoved(sink, sourceId, reason);
                    break;
                case MSG_SOURCE_REMOVED_FAILED:
                    callback.onSourceRemoveFailed((BluetoothDevice) msg.obj, sourceId, reason);
                    sink = (BluetoothDevice) msg.obj;
                    callback.onSourceRemoveFailed(sink, sourceId, reason);
                    break;
                case MSG_RECEIVESTATE_CHANGED:
                    param = (ObjParams) msg.obj;
@@ -1044,8 +1290,10 @@ public class BassClientService extends ProfileService {
            obtainMessage(MSG_SOURCE_FOUND, 0, 0, source).sendToTarget();
        }

        void notifySourceAdded(BluetoothDevice sink, int sourceId, int reason) {
            obtainMessage(MSG_SOURCE_ADDED, reason, sourceId, sink).sendToTarget();
        void notifySourceAdded(BluetoothDevice sink, BluetoothLeBroadcastReceiveState recvState,
                int reason) {
            ObjParams param = new ObjParams(sink, recvState);
            obtainMessage(MSG_SOURCE_ADDED, reason, 0, param).sendToTarget();
        }

        void notifySourceAddFailed(BluetoothDevice sink, BluetoothLeBroadcastMetadata source,
+2 −2
Original line number Diff line number Diff line
@@ -785,8 +785,8 @@ public class BassClientStateMachine extends StateMachine {
            if (oldRecvState.getSourceDevice() == null
                    || oldRecvState.getSourceDevice().getAddress().equals(emptyBluetoothDevice)) {
                log("New Source Addition");
                mService.getCallbacks().notifySourceAdded(mDevice,
                        recvState.getSourceId(), BluetoothStatusCodes.REASON_LOCAL_APP_REQUEST);
                mService.getCallbacks().notifySourceAdded(mDevice, recvState,
                        BluetoothStatusCodes.REASON_LOCAL_APP_REQUEST);
                if (mPendingMetadata != null) {
                    setCurrentBroadcastMetadata(recvState.getSourceId(), mPendingMetadata);
                }
+19 −0
Original line number Diff line number Diff line
@@ -52,6 +52,7 @@ import com.android.internal.annotations.VisibleForTesting;
import com.android.modules.utils.SynchronousResultReceiver;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -633,6 +634,24 @@ public class CsipSetCoordinatorService extends ProfileService {
                .collect(Collectors.toList());
    }

    /**
     * Get grouped devices
     * @param device group member device
     * @param uuid
     * @return related list of devices sorted from the lowest to the highest rank value.
     */
    public @NonNull List<BluetoothDevice> getGroupDevicesOrdered(BluetoothDevice device,
            ParcelUuid uuid) {
        List<Integer> groupIds = getAllGroupIds(uuid);
        for (Integer id : groupIds) {
            List<BluetoothDevice> devices = getGroupDevicesOrdered(id);
            if (devices.contains(device)) {
                return devices;
            }
        }
        return Collections.emptyList();
    }

    /**
     * Get group desired size
     * @param groupId group ID
+669 −1

File changed.

Preview size limit exceeded, changes collapsed.