diff --git a/services/companion/java/com/android/server/companion/CompanionApplicationController.java b/services/companion/java/com/android/server/companion/CompanionApplicationController.java new file mode 100644 index 0000000000000..be1bc7907cd55 --- /dev/null +++ b/services/companion/java/com/android/server/companion/CompanionApplicationController.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 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.server.companion; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.SuppressLint; +import android.annotation.UserIdInt; +import android.companion.AssociationInfo; +import android.companion.CompanionDeviceService; +import android.content.ComponentName; +import android.content.Context; +import android.os.Handler; +import android.util.Log; +import android.util.SparseArray; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.infra.PerUser; +import com.android.internal.util.CollectionUtils; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Manages communication with companion applications via + * {@link android.companion.ICompanionDeviceService} interface, including "connecting" (binding) to + * the services, maintaining the connection (the binding), and invoking callback methods such as + * {@link CompanionDeviceService#onDeviceAppeared(AssociationInfo)} and + * {@link CompanionDeviceService#onDeviceDisappeared(AssociationInfo)} in the application process. + * + *

+ * The following is the list of the APIs provided by {@link CompanionApplicationController} (to be + * utilized by {@link CompanionDeviceManagerService}): + *

+ * + * @see CompanionDeviceService + * @see android.companion.ICompanionDeviceService + * @see CompanionDeviceServiceConnector + */ +@SuppressLint("LongLogTag") +class CompanionApplicationController { + static final boolean DEBUG = false; + private static final String TAG = "CompanionDevice_ApplicationController"; + + private static final long REBIND_TIMEOUT = 10 * 1000; // 10 sec + + interface Callback { + /** + * @return {@code true} if should schedule rebinding. + * {@code false} if we do not need to rebind. + */ + boolean onCompanionApplicationBindingDied( + @UserIdInt int userId, @NonNull String packageName); + + /** + * Callback after timeout for previously scheduled rebind has passed. + */ + void onRebindCompanionApplicationTimeout( + @UserIdInt int userId, @NonNull String packageName); + } + + private final @NonNull Context mContext; + private final @NonNull Callback mCallback; + private final @NonNull CompanionServicesRegister mCompanionServicesRegister; + @GuardedBy("mBoundCompanionApplications") + private final @NonNull AndroidPackageMap> + mBoundCompanionApplications; + private final @NonNull AndroidPackageMap mScheduledForRebindingCompanionApplications; + + CompanionApplicationController(Context context, Callback callback) { + mContext = context; + mCallback = callback; + mCompanionServicesRegister = new CompanionServicesRegister(); + mBoundCompanionApplications = new AndroidPackageMap<>(); + mScheduledForRebindingCompanionApplications = new AndroidPackageMap<>(); + } + + void onPackagesChanged(@UserIdInt int userId) { + mCompanionServicesRegister.invalidate(userId); + } + + void bindCompanionApplication(@UserIdInt int userId, @NonNull String packageName) { + if (DEBUG) Log.i(TAG, "bind() u" + userId + "/" + packageName); + + final List companionServices = + mCompanionServicesRegister.forPackage(userId, packageName); + final List serviceConnectors; + + synchronized (mBoundCompanionApplications) { + if (mBoundCompanionApplications.containsValueForPackage(userId, packageName)) { + if (DEBUG) Log.e(TAG, "u" + userId + "/" + packageName + " is ALREADY bound."); + return; + } + + serviceConnectors = CollectionUtils.map(companionServices, componentName -> + new CompanionDeviceServiceConnector(mContext, userId, componentName)); + mBoundCompanionApplications.setValueForPackage(userId, packageName, serviceConnectors); + } + + // The first connector in the list is always the primary connector: set a listener to it. + serviceConnectors.get(0).setListener(this::onPrimaryServiceBindingDied); + + // Now "bind" all the connectors: the primary one and the rest of them. + for (CompanionDeviceServiceConnector serviceConnector : serviceConnectors) { + serviceConnector.connect(); + } + } + + void unbindCompanionApplication(@UserIdInt int userId, @NonNull String packageName) { + if (DEBUG) Log.i(TAG, "unbind() u" + userId + "/" + packageName); + + final List serviceConnectors; + synchronized (mBoundCompanionApplications) { + serviceConnectors = mBoundCompanionApplications.removePackage(userId, packageName); + } + if (serviceConnectors == null) { + if (DEBUG) Log.e(TAG, "u" + userId + "/" + packageName + " is NOT bound"); + return; + } + + for (CompanionDeviceServiceConnector serviceConnector : serviceConnectors) { + serviceConnector.postUnbind(); + } + } + + boolean isCompanionApplicationBound(@UserIdInt int userId, @NonNull String packageName) { + synchronized (mBoundCompanionApplications) { + return mBoundCompanionApplications.containsValueForPackage(userId, packageName); + } + } + + private void scheduleRebinding(@UserIdInt int userId, @NonNull String packageName) { + mScheduledForRebindingCompanionApplications.setValueForPackage(userId, packageName, true); + + Handler.getMain().postDelayed(() -> + onRebindingCompanionApplicationTimeout(userId, packageName), REBIND_TIMEOUT); + } + + boolean isRebindingCompanionApplicationScheduled( + @UserIdInt int userId, @NonNull String packageName) { + return mScheduledForRebindingCompanionApplications + .containsValueForPackage(userId, packageName); + } + + private void onRebindingCompanionApplicationTimeout( + @UserIdInt int userId, @NonNull String packageName) { + mScheduledForRebindingCompanionApplications.removePackage(userId, packageName); + + mCallback.onRebindCompanionApplicationTimeout(userId, packageName); + } + + void notifyCompanionApplicationDeviceAppeared(AssociationInfo association) { + final int userId = association.getUserId(); + final String packageName = association.getPackageName(); + if (DEBUG) { + Log.i(TAG, "notifyDevice_Appeared() id=" + association.getId() + " u" + userId + + "/" + packageName); + } + + final CompanionDeviceServiceConnector primaryServiceConnector = + getPrimaryServiceConnector(userId, packageName); + if (primaryServiceConnector == null) { + if (DEBUG) Log.e(TAG, "u" + userId + "/" + packageName + " is NOT bound."); + return; + } + + primaryServiceConnector.postOnDeviceAppeared(association); + } + + void notifyCompanionApplicationDeviceDisappeared(AssociationInfo association) { + final int userId = association.getUserId(); + final String packageName = association.getPackageName(); + if (DEBUG) { + Log.i(TAG, "notifyDevice_Disappeared() id=" + association.getId() + " u" + userId + + "/" + packageName); + } + + final CompanionDeviceServiceConnector primaryServiceConnector = + getPrimaryServiceConnector(userId, packageName); + if (primaryServiceConnector == null) { + if (DEBUG) Log.e(TAG, "u" + userId + "/" + packageName + " is NOT bound."); + return; + } + + primaryServiceConnector.postOnDeviceDisappeared(association); + } + + private void onPrimaryServiceBindingDied(@UserIdInt int userId, @NonNull String packageName) { + if (DEBUG) Log.i(TAG, "onPrimaryServiceBindingDied() u" + userId + "/" + packageName); + + // First: mark as NOT bound. + synchronized (mBoundCompanionApplications) { + mBoundCompanionApplications.removePackage(userId, packageName); + } + + // Second: invoke callback, schedule rebinding if needed. + final boolean shouldScheduleRebind = + mCallback.onCompanionApplicationBindingDied(userId, packageName); + if (shouldScheduleRebind) { + scheduleRebinding(userId, packageName); + } + } + + private @Nullable CompanionDeviceServiceConnector getPrimaryServiceConnector( + @UserIdInt int userId, @NonNull String packageName) { + final List connectors; + synchronized (mBoundCompanionApplications) { + connectors = mBoundCompanionApplications.getValueForPackage(userId, packageName); + } + return connectors != null ? connectors.get(0) : null; + } + + private class CompanionServicesRegister extends PerUser>> { + @Override + public synchronized @NonNull Map> forUser( + @UserIdInt int userId) { + return super.forUser(userId); + } + + synchronized @NonNull List forPackage( + @UserIdInt int userId, @NonNull String packageName) { + return forUser(userId).getOrDefault(packageName, Collections.emptyList()); + } + + synchronized @NonNull ComponentName primaryForPackage( + @UserIdInt int userId, @NonNull String packageName) { + // The primary service is always at the head of the list. + return forPackage(userId, packageName).get(0); + } + + synchronized void invalidate(@UserIdInt int userId) { + remove(userId); + } + + @Override + protected final @NonNull Map> create(@UserIdInt int userId) { + return PackageUtils.getCompanionServicesForUser(mContext, userId); + } + } + + /** + * Associates an Android package (defined by userId + packageName) with a value of type T. + */ + private static class AndroidPackageMap extends SparseArray> { + + void setValueForPackage( + @UserIdInt int userId, @NonNull String packageName, @NonNull T value) { + Map forUser = get(userId); + if (forUser == null) { + forUser = /* Map */ new HashMap(); + put(userId, forUser); + } + + forUser.put(packageName, value); + } + + boolean containsValueForPackage(@UserIdInt int userId, @NonNull String packageName) { + final Map forUser = get(userId); + return forUser != null && forUser.containsKey(packageName); + } + + T getValueForPackage(@UserIdInt int userId, @NonNull String packageName) { + final Map forUser = get(userId); + return forUser != null ? forUser.get(packageName) : null; + } + + T removePackage(@UserIdInt int userId, @NonNull String packageName) { + final Map forUser = get(userId); + if (forUser == null) return null; + return forUser.remove(packageName); + } + + void dump() { + if (size() == 0) { + Log.d(TAG, ""); + return; + } + + for (int i = 0; i < size(); i++) { + final int userId = keyAt(i); + final Map forUser = get(userId); + if (forUser.isEmpty()) { + Log.d(TAG, "u" + userId + ": "); + } + + for (Map.Entry packageValue : forUser.entrySet()) { + final String packageName = packageValue.getKey(); + final T value = packageValue.getValue(); + Log.d(TAG, "u" + userId + "\\" + packageName + " -> " + value); + } + } + } + } +} diff --git a/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java b/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java new file mode 100644 index 0000000000000..a4fa1c12b29bb --- /dev/null +++ b/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 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.server.companion.presence; + +import android.annotation.NonNull; +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.companion.AssociationInfo; +import android.net.MacAddress; +import android.os.Handler; +import android.os.HandlerExecutor; +import android.util.Log; + +import com.android.server.companion.AssociationStore; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SuppressLint("LongLogTag") +class BluetoothCompanionDeviceConnectionListener + extends BluetoothAdapter.BluetoothConnectionCallback + implements AssociationStore.OnChangeListener { + private static final boolean DEBUG = false; + private static final String TAG = "CompanionDevice_PresenceMonitor_BT"; + + interface Callback { + void onBluetoothCompanionDeviceConnected(int associationId); + + void onBluetoothCompanionDeviceDisconnected(int associationId); + } + + private final @NonNull AssociationStore mAssociationStore; + private final @NonNull Callback mCallback; + /** A set of ALL connected BT device (not only companion.) */ + private final @NonNull Map mAllConnectedDevices = new HashMap<>(); + + BluetoothCompanionDeviceConnectionListener(@NonNull AssociationStore associationStore, + @NonNull Callback callback) { + mAssociationStore = associationStore; + mCallback = callback; + } + + public void init(@NonNull BluetoothAdapter btAdapter) { + if (DEBUG) Log.i(TAG, "init()"); + + btAdapter.registerBluetoothConnectionCallback( + new HandlerExecutor(Handler.getMain()), /* callback */this); + mAssociationStore.registerListener(this); + } + + /** + * Overrides + * {@link BluetoothAdapter.BluetoothConnectionCallback#onDeviceConnected(BluetoothDevice)}. + */ + @Override + public void onDeviceConnected(@NonNull BluetoothDevice device) { + if (DEBUG) Log.i(TAG, "onDevice_Connected() " + toString(device)); + + final MacAddress macAddress = MacAddress.fromString(device.getAddress()); + if (mAllConnectedDevices.put(macAddress, device) != null) { + if (DEBUG) Log.w(TAG, "Device " + toString(device) + " is already connected."); + return; + } + + onDeviceConnectivityChanged(device, true); + } + + /** + * Overrides + * {@link BluetoothAdapter.BluetoothConnectionCallback#onDeviceConnected(BluetoothDevice)}. + * Also invoked when user turns BT off while the device is connected. + */ + @Override + public void onDeviceDisconnected(@NonNull BluetoothDevice device, + @DisconnectReason int reason) { + if (DEBUG) { + Log.i(TAG, "onDevice_Disconnected() " + toString(device)); + Log.d(TAG, " reason=" + disconnectReasonText(reason)); + } + + final MacAddress macAddress = MacAddress.fromString(device.getAddress()); + if (mAllConnectedDevices.remove(macAddress) == null) { + if (DEBUG) Log.w(TAG, "The device wasn't tracked as connected " + toString(device)); + return; + } + + onDeviceConnectivityChanged(device, false); + } + + private void onDeviceConnectivityChanged(@NonNull BluetoothDevice device, boolean connected) { + final List associations = + mAssociationStore.getAssociationsByAddress(device.getAddress()); + + if (DEBUG) { + Log.d(TAG, "onDevice_ConnectivityChanged() " + toString(device) + + " connected=" + connected); + if (associations.isEmpty()) { + Log.d(TAG, " > No CDM associations"); + } else { + Log.d(TAG, " > associations=" + Arrays.toString(associations.toArray())); + } + } + + for (AssociationInfo association : associations) { + final int id = association.getId(); + if (connected) { + mCallback.onBluetoothCompanionDeviceConnected(id); + } else { + mCallback.onBluetoothCompanionDeviceDisconnected(id); + } + } + } + + @Override + public void onAssociationAdded(AssociationInfo association) { + if (DEBUG) Log.d(TAG, "onAssociation_Added() " + association); + + if (mAllConnectedDevices.containsKey(association.getDeviceMacAddress())) { + mCallback.onBluetoothCompanionDeviceConnected(association.getId()); + } + } + + @Override + public void onAssociationUpdated(AssociationInfo association, boolean addressChanged) { + if (DEBUG) { + Log.d(TAG, "onAssociation_Updated() addrChange=" + addressChanged + + " " + association); + } + + if (!addressChanged) { + // Don't need to do anything. + return; + } + + // At the moment CDM does allow changing association addresses, so we will never come here. + // This will be implemented when CDM support updating addresses. + throw new IllegalArgumentException("Address changes are not supported."); + } + + private static String toString(@NonNull BluetoothDevice btDevice) { + final StringBuilder sb = new StringBuilder(btDevice.getAddress()); + + sb.append(" [name="); + final String name = btDevice.getName(); + if (name != null) { + sb.append('\'').append(name).append('\''); + } else { + sb.append("null"); + } + + final String alias = btDevice.getAlias(); + if (alias != null) { + sb.append(", alias='").append(alias).append("'"); + } + + return sb.append(']').toString(); + } +}