From 60ff81faf8200fc3b3a81a38443668dfcc39a8c8 Mon Sep 17 00:00:00 2001 From: Evan Chen Date: Wed, 19 Feb 2025 00:18:52 +0000 Subject: [PATCH] Remove all associaitons when forget a device in Settings The associations will be removed one forget the device. Test: manually test Bug: 365613753 Flag: com.android.settings.flags.enable_remove_association_bt_unpair Change-Id: Ic2224952b6f8e776ffcf07ce4fa6953a98475490 --- .../settings_bluetooth_declarations.aconfig | 8 ++ res-product/values/strings.xml | 6 + .../bluetooth/ForgetDeviceDialogFragment.java | 103 ++++++++++++-- .../ForgetDeviceDialogFragmentTest.java | 129 +++++++++++++++++- 4 files changed, 230 insertions(+), 16 deletions(-) diff --git a/aconfig/settings_bluetooth_declarations.aconfig b/aconfig/settings_bluetooth_declarations.aconfig index 4d2528a71db..7f3089500f6 100644 --- a/aconfig/settings_bluetooth_declarations.aconfig +++ b/aconfig/settings_bluetooth_declarations.aconfig @@ -54,3 +54,11 @@ flag { purpose: PURPOSE_BUGFIX } } + +flag { + name: "enable_remove_association_bt_unpair" + is_exported: true + namespace: "companion_device_manager" + description: "Allow to disassociate when to forget a BT pair device" + bug: "365613753" + } diff --git a/res-product/values/strings.xml b/res-product/values/strings.xml index c121406a03f..48810b9cc49 100644 --- a/res-product/values/strings.xml +++ b/res-product/values/strings.xml @@ -419,6 +419,12 @@ Your tablet will no longer be paired with %1$s Your device will no longer be paired with %1$s + + Your phone will no longer be paired with %1$s.\u0020%2$s will no longer manage the device + + Your tablet will no longer be paired with %1$s.\u0020%2$s will no longer manage the device + + Your device will no longer be paired with %1$s.\u0020%2$s will no longer manage the device %1$s will no longer be connected to this phone. If you continue, some apps and app streaming may stop working. diff --git a/src/com/android/settings/bluetooth/ForgetDeviceDialogFragment.java b/src/com/android/settings/bluetooth/ForgetDeviceDialogFragment.java index 60d63c637df..7cfa5ea30e6 100644 --- a/src/com/android/settings/bluetooth/ForgetDeviceDialogFragment.java +++ b/src/com/android/settings/bluetooth/ForgetDeviceDialogFragment.java @@ -16,31 +16,52 @@ package com.android.settings.bluetooth; +import static com.android.internal.util.CollectionUtils.filter; + import android.app.Activity; import android.app.Dialog; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothDevice; +import android.companion.AssociationInfo; +import android.companion.CompanionDeviceManager; import android.content.Context; import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.icu.text.ListFormatter; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; +import com.android.settings.flags.Flags; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.google.common.base.Objects; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + /** Implements an AlertDialog for confirming that a user wishes to unpair or "forget" a paired * device*/ public class ForgetDeviceDialogFragment extends InstrumentedDialogFragment { public static final String TAG = "ForgetBluetoothDevice"; private static final String KEY_DEVICE_ADDRESS = "device_address"; - private CachedBluetoothDevice mDevice; - + @VisibleForTesting + CachedBluetoothDevice mDevice; + @VisibleForTesting + CompanionDeviceManager mCompanionDeviceManager; + @VisibleForTesting + PackageManager mPackageManager; public static ForgetDeviceDialogFragment newInstance(String deviceAddress) { Bundle args = new Bundle(1); args.putString(KEY_DEVICE_ADDRESS, deviceAddress); @@ -63,29 +84,93 @@ public class ForgetDeviceDialogFragment extends InstrumentedDialogFragment { } @Override - public Dialog onCreateDialog(Bundle inState) { - Context context = getContext(); + public void onAttach(@NonNull Context context) { + super.onAttach(context); + mCompanionDeviceManager = context.getSystemService(CompanionDeviceManager.class); + mPackageManager = context.getPackageManager(); mDevice = getDevice(context); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle inState) { if (mDevice == null) { - Log.e(TAG, "onCreateDialog: Device is null."); - return null; + throw new IllegalStateException("Device must not be null when creating dialog."); + } + List associationInfos = getAssociations(mDevice.getAddress()); + Set packageNames = new HashSet<>(); + if (Flags.enableRemoveAssociationBtUnpair()) { + for (AssociationInfo ai : associationInfos) { + CharSequence appLabel = getAppLabel(ai.getPackageName()); + if (!TextUtils.isEmpty(appLabel)) { + packageNames.add(appLabel.toString()); + } + } } DialogInterface.OnClickListener onConfirm = (dialog, which) -> { + // 1. Unpair the device. mDevice.unpair(); + // 2. Remove the associations if any. + if (Flags.enableRemoveAssociationBtUnpair()) { + for (AssociationInfo ai : associationInfos) { + mCompanionDeviceManager.disassociate(ai.getId()); + } + } + Activity activity = getActivity(); if (activity != null) { activity.finish(); } }; - AlertDialog dialog = new AlertDialog.Builder(context) + + AlertDialog dialog = new AlertDialog.Builder(getActivity()) .setPositiveButton(R.string.bluetooth_unpair_dialog_forget_confirm_button, onConfirm) .setNegativeButton(android.R.string.cancel, null) .create(); + dialog.setTitle(R.string.bluetooth_unpair_dialog_title); - dialog.setMessage(context.getString(R.string.bluetooth_unpair_dialog_body, - mDevice.getName())); + String message = buildUnpairMessage( + getActivity(), mDevice, associationInfos, packageNames.stream().toList()); + dialog.setMessage(message); + return dialog; } + + private List getAssociations(String address) { + return filter( + mCompanionDeviceManager.getAllAssociations(), + a -> Objects.equal(address, a.getDeviceMacAddressAsString())); + } + + private String buildUnpairMessage(Context context, CachedBluetoothDevice device, + List associationInfos, List packageNames) { + if (Flags.enableRemoveAssociationBtUnpair() && !associationInfos.isEmpty()) { + String appNamesString = getAppNamesString(packageNames.stream().toList()); + return context.getString(R.string.bluetooth_unpair_dialog_with_associations_body, + device.getName(), appNamesString); + } else { + return context.getString(R.string.bluetooth_unpair_dialog_body, device.getName()); + } + } + + private String getAppNamesString(List appNames) { + if (appNames == null || appNames.isEmpty()) { + return ""; + } + + ListFormatter formatter = ListFormatter.getInstance(Locale.getDefault()); + return formatter.format(appNames); + } + + private CharSequence getAppLabel(String packageName) { + try { + return mPackageManager.getApplicationLabel( + mPackageManager.getApplicationInfo(packageName, 0)); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Package Not Found", e); + return ""; + } + } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/ForgetDeviceDialogFragmentTest.java b/tests/robotests/src/com/android/settings/bluetooth/ForgetDeviceDialogFragmentTest.java index 2394c7c9740..a743018ea4d 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ForgetDeviceDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/ForgetDeviceDialogFragmentTest.java @@ -20,24 +20,36 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.bluetooth.BluetoothDevice; +import android.companion.AssociationInfo; +import android.companion.CompanionDeviceManager; import android.content.Context; +import android.content.DialogInterface; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.net.MacAddress; +import android.os.Bundle; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.android.settings.R; -import com.android.settings.testutils.FakeFeatureFactory; +import com.android.settings.flags.Flags; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import org.junit.Before; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Answers; @@ -48,41 +60,64 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowDialog; +import org.robolectric.shadows.ShadowLooper; import org.robolectric.shadows.androidx.fragment.FragmentController; -@Ignore +import java.util.ArrayList; +import java.util.List; + @RunWith(RobolectricTestRunner.class) -@Config(shadows = {ShadowAlertDialogCompat.class}) +@Config(shadows = { + com.android.settings.testutils.shadow.ShadowFragment.class, + ShadowAlertDialogCompat.class, +}) public class ForgetDeviceDialogFragmentTest { private static final String DEVICE_NAME = "Nightshade"; + private static final String PACKAGE_NAME = "com.android.test"; + private static final CharSequence APP_NAME = "test"; @Mock(answer = Answers.RETURNS_DEEP_STUBS) private CachedBluetoothDevice mCachedDevice; @Mock private BluetoothDevice mBluetoothDevice; + @Mock + private CompanionDeviceManager mCompanionDeviceManager; + @Mock + private PackageManager mPackageManager; private ForgetDeviceDialogFragment mFragment; private FragmentActivity mActivity; private AlertDialog mDialog; private Context mContext; + private List mAssociations; + + @Rule + public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); @Before public void setUp() { MockitoAnnotations.initMocks(this); - mContext = RuntimeEnvironment.application; - FakeFeatureFactory.setupForTest(); String deviceAddress = "55:66:77:88:99:AA"; + mAssociations = new ArrayList<>(); + mFragment = spy(ForgetDeviceDialogFragment.newInstance(deviceAddress)); + mContext = spy(RuntimeEnvironment.application); + mFragment.mCompanionDeviceManager = mCompanionDeviceManager; + mFragment.mPackageManager = mPackageManager; + mFragment.mDevice = mCachedDevice; + mActivity = Robolectric.setupActivity(FragmentActivity.class); + + when(mFragment.getActivity()).thenReturn(mActivity); when(mCachedDevice.getAddress()).thenReturn(deviceAddress); when(mCachedDevice.getIdentityAddress()).thenReturn(deviceAddress); when(mCachedDevice.getDevice()).thenReturn(mBluetoothDevice); when(mCachedDevice.getName()).thenReturn(DEVICE_NAME); - mFragment = spy(ForgetDeviceDialogFragment.newInstance(deviceAddress)); + when(mCompanionDeviceManager.getAllAssociations()).thenReturn(mAssociations); doReturn(mCachedDevice).when(mFragment).getDevice(any()); - mActivity = Robolectric.setupActivity(FragmentActivity.class); } + @Ignore("b/253386225") @Test public void cancelDialog() { initDialog(); @@ -92,6 +127,7 @@ public class ForgetDeviceDialogFragmentTest { assertThat(mActivity.isFinishing()).isFalse(); } + @Ignore("b/253386225") @Test public void confirmDialog() { initDialog(); @@ -101,6 +137,7 @@ public class ForgetDeviceDialogFragmentTest { assertThat(mActivity.isFinishing()).isTrue(); } + @Ignore("b/253386225") @Test public void createDialog_normalDevice_showNormalMessage() { when(mBluetoothDevice.getMetadata(BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET)) @@ -115,8 +152,86 @@ public class ForgetDeviceDialogFragmentTest { mContext.getString(R.string.bluetooth_unpair_dialog_body, DEVICE_NAME)); } + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_REMOVE_ASSOCIATION_BT_UNPAIR) + public void cancelDialog_with_association() { + addAssociation(); + final AlertDialog dialog = (AlertDialog) mFragment.onCreateDialog(Bundle.EMPTY); + dialog.show(); + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).performClick(); + ShadowLooper.idleMainLooper(); + + verify(mCachedDevice, never()).unpair(); + verify(mCompanionDeviceManager, never()).disassociate(1); + assertThat(mActivity.isFinishing()).isFalse(); + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_REMOVE_ASSOCIATION_BT_UNPAIR) + public void confirmDialog_with_association() { + addAssociation(); + final AlertDialog dialog = (AlertDialog) mFragment.onCreateDialog(Bundle.EMPTY); + dialog.show(); + dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick(); + ShadowLooper.idleMainLooper(); + + verify(mCachedDevice).unpair(); + verify(mCompanionDeviceManager).disassociate(1); + + assertThat(mActivity.isFinishing()).isTrue(); + } + + @Test + @RequiresFlagsEnabled(Flags.FLAG_ENABLE_REMOVE_ASSOCIATION_BT_UNPAIR) + public void createDialog_showMessage_with_association() { + addAssociation(); + final AlertDialog dialog = (AlertDialog) mFragment.onCreateDialog(Bundle.EMPTY); + dialog.show(); + ShadowLooper.idleMainLooper(); + + ShadowAlertDialogCompat shadowDialog = ShadowAlertDialogCompat.shadowOf(dialog); + assertThat(shadowDialog.getMessage().toString()).isEqualTo( + mContext.getString( + R.string.bluetooth_unpair_dialog_with_associations_body, + DEVICE_NAME, APP_NAME) + ); + } + private void initDialog() { mActivity.getSupportFragmentManager().beginTransaction().add(mFragment, null).commit(); mDialog = (AlertDialog) ShadowDialog.getLatestDialog(); } + + private void addAssociation() { + setupLabelAndInfo(PACKAGE_NAME, APP_NAME); + final AssociationInfo association = new AssociationInfo( + 1, + /* userId */ 0, + PACKAGE_NAME, + MacAddress.fromString(mCachedDevice.getAddress()), + /* displayName */ null, + /* deviceProfile */ "", + /* associatedDevice */ null, + /* selfManaged */ false, + /* notifyOnDeviceNearby */ true, + /* revoked */ false, + /* pending */ false, + /* timeApprovedMs */ System.currentTimeMillis(), + /* lastTimeConnected */ Long.MAX_VALUE, + /* systemDataSyncFlags */ -1, + /* deviceIcon */ null, + /* deviceId */ null); + + mAssociations.add(association); + } + + private void setupLabelAndInfo(String packageName, CharSequence appName) { + ApplicationInfo appInfo = mock(ApplicationInfo.class); + try { + when(mPackageManager.getApplicationInfo(packageName, 0)).thenReturn(appInfo); + when(mPackageManager.getApplicationLabel(appInfo)).thenReturn(appName); + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + } }