diff --git a/AndroidManifest.xml b/AndroidManifest.xml index c001e03d094..e204dd92154 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -3227,6 +3227,19 @@ + + + + + + + + + + + + + diff --git a/aconfig/settings_bluetooth_declarations.aconfig b/aconfig/settings_bluetooth_declarations.aconfig index 3d14288fcd2..0c423b5a1b7 100644 --- a/aconfig/settings_bluetooth_declarations.aconfig +++ b/aconfig/settings_bluetooth_declarations.aconfig @@ -34,3 +34,13 @@ flag { purpose: PURPOSE_BUGFIX } } + +flag { + name: "enable_bluetooth_key_missing_dialog" + namespace: "cross_device_experiences" + description: "Show a dialog if the bluetooth key is missing when reconnecting" + bug: "360031750" + metadata { + purpose: PURPOSE_BUGFIX + } +} diff --git a/res/layout/bluetooth_key_missing.xml b/res/layout/bluetooth_key_missing.xml new file mode 100644 index 00000000000..b9f8d866bd3 --- /dev/null +++ b/res/layout/bluetooth_key_missing.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 3cfe0686db2..c63e5f4fcf2 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1860,6 +1860,15 @@ Change + + %1$s not connected + + For your security, forget this device, then pair it again + + Forget device + + Cancel + Device details diff --git a/src/com/android/settings/bluetooth/BluetoothKeyMissingDialog.java b/src/com/android/settings/bluetooth/BluetoothKeyMissingDialog.java new file mode 100644 index 00000000000..46975f77726 --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothKeyMissingDialog.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 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.settings.bluetooth; + +import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + +import android.bluetooth.BluetoothDevice; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; + +/** A dialog to ask the user to forget a bluetooth device when the key is missing. */ +public class BluetoothKeyMissingDialog extends FragmentActivity { + public static final String FRAGMENT_TAG = "BtKeyMissingFrg"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + Intent intent = getIntent(); + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if (device == null) { + finish(); + return; + } + BluetoothKeyMissingDialogFragment fragment = new BluetoothKeyMissingDialogFragment(device); + fragment.show(getSupportFragmentManager(), FRAGMENT_TAG); + closeSystemDialogs(); + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogFragment.java b/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogFragment.java new file mode 100644 index 00000000000..a8e3aae175a --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogFragment.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 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.settings.bluetooth; + +import android.app.Dialog; +import android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothDevice; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.android.settings.R; +import com.android.settings.core.instrumentation.InstrumentedDialogFragment; + +/** + * A dialogFragment used by {@link BluetoothKeyMissingDialog} to create a dialog for the + * bluetooth device. + */ +public class BluetoothKeyMissingDialogFragment extends InstrumentedDialogFragment + implements OnClickListener { + + private static final String TAG = "BTKeyMissingDialogFragment"; + + private BluetoothDevice mBluetoothDevice; + + public BluetoothKeyMissingDialogFragment(@NonNull BluetoothDevice bluetoothDevice) { + mBluetoothDevice = bluetoothDevice; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + View view = getActivity().getLayoutInflater().inflate(R.layout.bluetooth_key_missing, null); + TextView keyMissingTitle = view.findViewById(R.id.bluetooth_key_missing_title); + keyMissingTitle.setText( + getString(R.string.bluetooth_key_missing_title, mBluetoothDevice.getName())); + builder.setView(view); + builder.setPositiveButton(getString(R.string.bluetooth_key_missing_forget), this); + builder.setNegativeButton(getString(R.string.bluetooth_key_missing_cancel), this); + AlertDialog dialog = builder.create(); + dialog.setCanceledOnTouchOutside(false); + return dialog; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (!getActivity().isFinishing()) { + getActivity().finish(); + } + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + Log.i( + TAG, + "Positive button clicked, remove bond for " + + mBluetoothDevice.getAnonymizedAddress()); + mBluetoothDevice.removeBond(); + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + Log.i(TAG, "Negative button clicked for " + mBluetoothDevice.getAnonymizedAddress()); + } + if (!getActivity().isFinishing()) { + getActivity().finish(); + } + } + + @Override + public int getMetricsCategory() { + return SettingsEnums.BLUETOOTH_KEY_MISSING_DIALOG_FRAGMENT; + } +} diff --git a/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiver.java b/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiver.java new file mode 100644 index 00000000000..d7a5343d694 --- /dev/null +++ b/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiver.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2024 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.settings.bluetooth; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.bluetooth.BluetoothDevice; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.PowerManager; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import com.android.settings.R; +import com.android.settings.flags.Flags; + +/** + * BluetoothKeyMissingReceiver is a receiver for Bluetooth key missing error when reconnecting to a + * bonded bluetooth device. + */ +public final class BluetoothKeyMissingReceiver extends BroadcastReceiver { + private static final String TAG = "BtKeyMissingReceiver"; + private static final String CHANNEL_ID = "bluetooth_notification_channel"; + private static final int NOTIFICATION_ID = android.R.drawable.stat_sys_data_bluetooth; + + @Override + public void onReceive(Context context, Intent intent) { + if (!Flags.enableBluetoothKeyMissingDialog()) { + return; + } + String action = intent.getAction(); + if (action == null) { + return; + } + + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + PowerManager powerManager = context.getSystemService(PowerManager.class); + if (TextUtils.equals(action, BluetoothDevice.ACTION_KEY_MISSING)) { + Log.d(TAG, "Receive ACTION_KEY_MISSING"); + if (shouldShowDialog(context, device, powerManager)) { + Intent pairingIntent = getKeyMissingDialogIntent(context, device); + Log.d(TAG, "Show key missing dialog:" + device); + context.startActivityAsUser(pairingIntent, UserHandle.CURRENT); + } else { + Log.d(TAG, "Show key missing notification: " + device); + showNotification(context, device); + } + } + } + + private Intent getKeyMissingDialogIntent(Context context, BluetoothDevice device) { + Intent pairingIntent = new Intent(); + pairingIntent.setClass(context, BluetoothKeyMissingDialog.class); + pairingIntent.putExtra(BluetoothDevice.EXTRA_DEVICE, device); + pairingIntent.setAction(BluetoothDevice.ACTION_KEY_MISSING); + pairingIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return pairingIntent; + } + + private boolean shouldShowDialog( + Context context, BluetoothDevice device, PowerManager powerManager) { + return LocalBluetoothPreferences.shouldShowDialogInForeground(context, device) + && powerManager.isInteractive(); + } + + private void showNotification(Context context, BluetoothDevice bluetoothDevice) { + NotificationManager nm = context.getSystemService(NotificationManager.class); + NotificationChannel notificationChannel = + new NotificationChannel( + CHANNEL_ID, + context.getString(R.string.bluetooth), + NotificationManager.IMPORTANCE_HIGH); + nm.createNotificationChannel(notificationChannel); + + PendingIntent pairIntent = + PendingIntent.getActivity( + context, + 0, + getKeyMissingDialogIntent(context, bluetoothDevice), + PendingIntent.FLAG_ONE_SHOT + | PendingIntent.FLAG_UPDATE_CURRENT + | PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, + CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth) + .setTicker(context.getString(R.string.bluetooth_notif_ticker)) + .setLocalOnly(true); + builder.setContentTitle( + context.getString( + R.string.bluetooth_key_missing_title, bluetoothDevice.getName())) + .setContentText(context.getString(R.string.bluetooth_key_missing_message)) + .setContentIntent(pairIntent) + .setAutoCancel(true) + .setDefaults(Notification.DEFAULT_SOUND) + .setColor( + context.getColor( + com.android.internal.R.color.system_notification_accent_color)); + + nm.notify(NOTIFICATION_ID, builder.build()); + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogTest.java new file mode 100644 index 00000000000..a47101e7b79 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingDialogTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 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.settings.bluetooth; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; + +import android.bluetooth.BluetoothDevice; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = ShadowAlertDialogCompat.class) +public class BluetoothKeyMissingDialogTest { + @Mock private BluetoothDevice mBluetoothDevice; + + private BluetoothKeyMissingDialogFragment mFragment = null; + private FragmentActivity mActivity = null; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mActivity = Robolectric.setupActivity(FragmentActivity.class); + mFragment = new BluetoothKeyMissingDialogFragment(mBluetoothDevice); + mActivity + .getSupportFragmentManager() + .beginTransaction() + .add(mFragment, null) + .commit(); + shadowMainLooper().idle(); + } + + @Test + public void clickForgetDevice_removeBond() { + mFragment.onClick(mFragment.getDialog(), AlertDialog.BUTTON_POSITIVE); + + verify(mBluetoothDevice).removeBond(); + assertThat(mActivity.isFinishing()).isTrue(); + } + + @Test + public void clickCancel_notRemoveBond() { + mFragment.onClick(mFragment.getDialog(), AlertDialog.BUTTON_NEGATIVE); + + verify(mBluetoothDevice, never()).removeBond(); + assertThat(mActivity.isFinishing()).isTrue(); + } +} diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiverTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiverTest.java new file mode 100644 index 00000000000..c764ed6cd97 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothKeyMissingReceiverTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2024 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.settings.bluetooth; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import android.app.Notification; +import android.app.NotificationManager; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.UserHandle; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import com.android.settings.flags.Flags; +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowBluetoothUtils; +import com.android.settingslib.bluetooth.LocalBluetoothManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowApplication; + +import java.util.List; +import java.util.stream.Collectors; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowBluetoothAdapter.class, ShadowBluetoothUtils.class}) +public class BluetoothKeyMissingReceiverTest { + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private Context mContext; + private ShadowApplication mShadowApplication; + private ShadowBluetoothAdapter mShadowBluetoothAdapter; + @Mock private LocalBluetoothManager mLocalBtManager; + @Mock private NotificationManager mNm; + @Mock private BluetoothDevice mBluetoothDevice; + + @Before + public void setUp() { + mContext = spy(RuntimeEnvironment.getApplication()); + mShadowApplication = Shadow.extract(mContext); + mShadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, mNm); + mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + mShadowBluetoothAdapter.setEnabled(true); + ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; + } + + @After + public void tearDown() { + ShadowBluetoothUtils.reset(); + } + + @Test + public void broadcastReceiver_isRegistered() { + List registeredReceivers = + mShadowApplication.getRegisteredReceivers(); + + int matchedCount = + registeredReceivers.stream() + .filter( + receiver -> + BluetoothKeyMissingReceiver.class + .getSimpleName() + .equals( + receiver.broadcastReceiver + .getClass() + .getSimpleName())) + .collect(Collectors.toList()) + .size(); + assertThat(matchedCount).isEqualTo(1); + } + + @Test + @DisableFlags(Flags.FLAG_ENABLE_BLUETOOTH_KEY_MISSING_DIALOG) + public void broadcastReceiver_receiveKeyMissingIntentFlagOff_doNothing() { + Intent intent = spy(new Intent(BluetoothDevice.ACTION_KEY_MISSING)); + when(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)).thenReturn(mBluetoothDevice); + BluetoothKeyMissingReceiver bluetoothKeyMissingReceiver = getReceiver(intent); + bluetoothKeyMissingReceiver.onReceive(mContext, intent); + + verifyNoInteractions(mNm); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_BLUETOOTH_KEY_MISSING_DIALOG) + public void broadcastReceiver_background_showNotification() { + Intent intent = spy(new Intent(BluetoothDevice.ACTION_KEY_MISSING)); + when(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)).thenReturn(mBluetoothDevice); + BluetoothKeyMissingReceiver bluetoothKeyMissingReceiver = getReceiver(intent); + bluetoothKeyMissingReceiver.onReceive(mContext, intent); + + verify(mNm).notify(eq(android.R.drawable.stat_sys_data_bluetooth), any(Notification.class)); + verify(mContext, never()).startActivityAsUser(any(), any()); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_BLUETOOTH_KEY_MISSING_DIALOG) + public void broadcastReceiver_foreground_receiveKeyMissingIntent_showDialog() { + when(mLocalBtManager.isForegroundActivity()).thenReturn(true); + Intent intent = spy(new Intent(BluetoothDevice.ACTION_KEY_MISSING)); + when(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)).thenReturn(mBluetoothDevice); + BluetoothKeyMissingReceiver bluetoothKeyMissingReceiver = getReceiver(intent); + bluetoothKeyMissingReceiver.onReceive(mContext, intent); + + verifyNoInteractions(mNm); + ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + verify(mContext).startActivityAsUser(captor.capture(), eq(UserHandle.CURRENT)); + assertThat(captor.getValue().getComponent().getClassName()) + .isEqualTo(BluetoothKeyMissingDialog.class.getName()); + } + + private BluetoothKeyMissingReceiver getReceiver(Intent intent) { + assertThat(mShadowApplication.hasReceiverForIntent(intent)).isTrue(); + List receiversForIntent = + mShadowApplication.getReceiversForIntent(intent); + assertThat(receiversForIntent).hasSize(1); + BroadcastReceiver broadcastReceiver = receiversForIntent.get(0); + assertThat(broadcastReceiver).isInstanceOf(BluetoothKeyMissingReceiver.class); + return (BluetoothKeyMissingReceiver) broadcastReceiver; + } +}