diff options
author | 2024-12-23 17:00:39 +0800 | |
---|---|---|
committer | 2025-01-06 22:45:06 +0800 | |
commit | 8f561169dc1ce4c6696804e884d842bede05bb77 (patch) | |
tree | 4d0603a4bc131ebdf06f597b735006ab941df7eb | |
parent | 834f96f5787321fd2542965263ea6eb332cb0bf8 (diff) |
[HA Input] Show notification to hint user can change input device when calling
* Listen to phone state 'CALL_STATE_OFFHOOK' and show notification when hearing device is connected
* 'Switch' action button on notification to temporary switch input device
* 'Settings' action button on notification to launch to device details page
* Tap on notification to also bring user to device details page
Bug: 349255906
Test: atest HearingDevicePhoneCallNotificationControllerTest SystemNotificationChannelsTest
Flag: com.android.settingslib.flags.hearing_devices_input_routing_control
Change-Id: Ic4920e2fde5e0c002018b15c2734a3b27b60d038
8 files changed, 601 insertions, 2 deletions
diff --git a/core/java/com/android/internal/notification/SystemNotificationChannels.java b/core/java/com/android/internal/notification/SystemNotificationChannels.java index 4aebde536dcf..5635943cb76e 100644 --- a/core/java/com/android/internal/notification/SystemNotificationChannels.java +++ b/core/java/com/android/internal/notification/SystemNotificationChannels.java @@ -67,6 +67,7 @@ public class SystemNotificationChannels { @Deprecated public static final String SYSTEM_CHANGES_DEPRECATED = "SYSTEM_CHANGES"; public static final String SYSTEM_CHANGES = "SYSTEM_CHANGES_ALERTS"; public static final String ACCESSIBILITY_MAGNIFICATION = "ACCESSIBILITY_MAGNIFICATION"; + public static final String ACCESSIBILITY_HEARING_DEVICE = "ACCESSIBILITY_HEARING_DEVICE"; public static final String ACCESSIBILITY_SECURITY_POLICY = "ACCESSIBILITY_SECURITY_POLICY"; public static final String ABUSIVE_BACKGROUND_APPS = "ABUSIVE_BACKGROUND_APPS"; @@ -203,6 +204,13 @@ public class SystemNotificationChannels { newFeaturePrompt.setBlockable(true); channelsList.add(newFeaturePrompt); + final NotificationChannel accessibilityHearingDeviceChannel = new NotificationChannel( + ACCESSIBILITY_HEARING_DEVICE, + context.getString(R.string.notification_channel_accessibility_hearing_device), + NotificationManager.IMPORTANCE_HIGH); + accessibilityHearingDeviceChannel.setBlockable(true); + channelsList.add(accessibilityHearingDeviceChannel); + final NotificationChannel accessibilitySecurityPolicyChannel = new NotificationChannel( ACCESSIBILITY_SECURITY_POLICY, context.getString(R.string.notification_channel_accessibility_security_policy), diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml index 6313054e47f5..ad9e7252c6a8 100644 --- a/core/res/res/values/strings.xml +++ b/core/res/res/values/strings.xml @@ -881,6 +881,10 @@ <string name="notification_channel_accessibility_magnification">Magnification</string> <!-- Text shown when viewing channel settings for notifications related to accessibility + hearing device. [CHAR_LIMIT=NONE]--> + <string name="notification_channel_accessibility_hearing_device">Hearing device</string> + + <!-- Text shown when viewing channel settings for notifications related to accessibility security policy. [CHAR_LIMIT=NONE]--> <string name="notification_channel_accessibility_security_policy">Accessibility usage</string> @@ -4985,6 +4989,19 @@ <!-- Text used to describe system navigation features, shown within a UI allowing a user to assign system magnification features to the Accessibility button in the navigation bar. --> <string name="accessibility_magnification_chooser_text">Magnification</string> + <!-- Notification title for switching input to the phone's microphone. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_switch_phone_mic_notification_title">Switch to phone mic?</string> + <!-- Notification title for switching input to the hearing device's microphone. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_switch_hearing_mic_notification_title">Switch to hearing aid mic?</string> + <!-- Notification content for switching input to the phone's microphone. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_switch_phone_mic_notification_text">For better sound or if your hearing aid battery is low. This only switches your mic during the call.</string> + <!-- Notification content for switching input to the hearing device's microphone. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_switch_hearing_mic_notification_text">You can use your hearing aid microphone for hands-free calling. This only switches your mic during the call.</string> + <!-- Notification action button. Click it will switch the input between phone's microphone and hearing device's microphone. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_notification_switch_button">Switch</string> + <!-- Notification action button. Click it will open the bluetooth device details page for this hearing device. It will be shown when making a phone call with the hearing device. [CHAR LIMIT=none] --> + <string name="hearing_device_notification_settings_button">Settings</string> + <!-- Text spoken when the current user is switched if accessibility is enabled. [CHAR LIMIT=none] --> <string name="user_switched">Current user <xliff:g id="name" example="Bob">%1$s</xliff:g>.</string> <!-- Message shown when switching to a user [CHAR LIMIT=none] --> diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index a18f923d625b..9634da6e2c83 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -3839,6 +3839,13 @@ <java-symbol type="string" name="reduce_bright_colors_feature_name" /> <java-symbol type="string" name="one_handed_mode_feature_name" /> + <java-symbol type="string" name="hearing_device_switch_phone_mic_notification_title" /> + <java-symbol type="string" name="hearing_device_switch_hearing_mic_notification_title" /> + <java-symbol type="string" name="hearing_device_switch_phone_mic_notification_text" /> + <java-symbol type="string" name="hearing_device_switch_hearing_mic_notification_text" /> + <java-symbol type="string" name="hearing_device_notification_switch_button" /> + <java-symbol type="string" name="hearing_device_notification_settings_button" /> + <!-- com.android.internal.widget.RecyclerView --> <java-symbol type="id" name="item_touch_helper_previous_elevation"/> <java-symbol type="dimen" name="item_touch_helper_max_drag_scroll_per_frame"/> @@ -4025,6 +4032,7 @@ <java-symbol type="string" name="notification_channel_heavy_weight_app" /> <java-symbol type="string" name="notification_channel_system_changes" /> <java-symbol type="string" name="notification_channel_accessibility_magnification" /> + <java-symbol type="string" name="notification_channel_accessibility_hearing_device" /> <java-symbol type="string" name="notification_channel_accessibility_security_policy" /> <java-symbol type="string" name="notification_channel_display" /> <java-symbol type="string" name="config_defaultAutofillService" /> diff --git a/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java b/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java index 0bf406c970f2..2bd3f4df9435 100644 --- a/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java +++ b/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java @@ -17,6 +17,7 @@ package com.android.internal.notification; import static com.android.internal.notification.SystemNotificationChannels.ABUSIVE_BACKGROUND_APPS; +import static com.android.internal.notification.SystemNotificationChannels.ACCESSIBILITY_HEARING_DEVICE; import static com.android.internal.notification.SystemNotificationChannels.ACCESSIBILITY_MAGNIFICATION; import static com.android.internal.notification.SystemNotificationChannels.ACCESSIBILITY_SECURITY_POLICY; import static com.android.internal.notification.SystemNotificationChannels.ACCOUNT; @@ -90,8 +91,8 @@ public class SystemNotificationChannelsTest { DEVELOPER_IMPORTANT, UPDATES, NETWORK_STATUS, NETWORK_ALERTS, NETWORK_AVAILABLE, VPN, DEVICE_ADMIN, ALERTS, RETAIL_MODE, USB, FOREGROUND_SERVICE, HEAVY_WEIGHT_APP, SYSTEM_CHANGES, - ACCESSIBILITY_MAGNIFICATION, ACCESSIBILITY_SECURITY_POLICY, - ABUSIVE_BACKGROUND_APPS); + ACCESSIBILITY_MAGNIFICATION, ACCESSIBILITY_HEARING_DEVICE, + ACCESSIBILITY_SECURITY_POLICY, ABUSIVE_BACKGROUND_APPS); } @Test diff --git a/proto/src/system_messages.proto b/proto/src/system_messages.proto index 648990588d29..3a38152825c9 100644 --- a/proto/src/system_messages.proto +++ b/proto/src/system_messages.proto @@ -420,5 +420,9 @@ message SystemMessage { // Notify the user that accessibility floating menu is hidden. // Package: com.android.systemui NOTE_A11Y_FLOATING_MENU_HIDDEN = 1009; + + // Notify the hearing aid user that input device can be changed to builtin device or hearing device. + // Package: android + NOTE_HEARING_DEVICE_INPUT_SWITCH = 1012; } } diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java index 37d045bf6422..6cd1f721d215 100644 --- a/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java +++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityManagerService.java @@ -413,6 +413,7 @@ public class AccessibilityManagerService extends IAccessibilityManager.Stub private SparseArray<SurfaceControl> mA11yOverlayLayers = new SparseArray<>(); private final FlashNotificationsController mFlashNotificationsController; + private final HearingDevicePhoneCallNotificationController mHearingDeviceNotificationController; private final UserManagerInternal mUmi; private AccessibilityUserState getCurrentUserStateLocked() { @@ -569,6 +570,12 @@ public class AccessibilityManagerService extends IAccessibilityManager.Stub // TODO(b/255426725): not used on tests mVisibleBgUserIds = null; mInputManager = context.getSystemService(InputManager.class); + if (com.android.settingslib.flags.Flags.hearingDevicesInputRoutingControl()) { + mHearingDeviceNotificationController = new HearingDevicePhoneCallNotificationController( + context); + } else { + mHearingDeviceNotificationController = null; + } init(); } @@ -618,6 +625,12 @@ public class AccessibilityManagerService extends IAccessibilityManager.Stub } else { mVisibleBgUserIds = null; } + if (com.android.settingslib.flags.Flags.hearingDevicesInputRoutingControl()) { + mHearingDeviceNotificationController = new HearingDevicePhoneCallNotificationController( + context); + } else { + mHearingDeviceNotificationController = null; + } init(); } @@ -630,6 +643,11 @@ public class AccessibilityManagerService extends IAccessibilityManager.Stub if (enableTalkbackAndMagnifierKeyGestures()) { mInputManager.registerKeyGestureEventHandler(mKeyGestureEventHandler); } + if (com.android.settingslib.flags.Flags.hearingDevicesInputRoutingControl()) { + if (mHearingDeviceNotificationController != null) { + mHearingDeviceNotificationController.startListenForCallState(); + } + } disableAccessibilityMenuToMigrateIfNeeded(); } diff --git a/services/accessibility/java/com/android/server/accessibility/HearingDevicePhoneCallNotificationController.java b/services/accessibility/java/com/android/server/accessibility/HearingDevicePhoneCallNotificationController.java new file mode 100644 index 000000000000..d06daf5db127 --- /dev/null +++ b/services/accessibility/java/com/android/server/accessibility/HearingDevicePhoneCallNotificationController.java @@ -0,0 +1,356 @@ +/* + * 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.server.accessibility; + +import android.Manifest; +import android.annotation.NonNull; +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothUuid; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioDeviceAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.media.MediaRecorder; +import android.os.Bundle; +import android.telephony.TelephonyCallback; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; + +import com.android.internal.R; +import com.android.internal.messages.nano.SystemMessageProto; +import com.android.internal.notification.SystemNotificationChannels; +import com.android.internal.util.ArrayUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +/** + * A controller class to handle notification for hearing device during phone calls. + */ +public class HearingDevicePhoneCallNotificationController { + + private final TelephonyManager mTelephonyManager; + private final TelephonyCallback mTelephonyListener; + private final Executor mCallbackExecutor; + + public HearingDevicePhoneCallNotificationController(@NonNull Context context) { + mTelephonyListener = new CallStateListener(context); + mTelephonyManager = context.getSystemService(TelephonyManager.class); + mCallbackExecutor = Executors.newSingleThreadExecutor(); + } + + @VisibleForTesting + HearingDevicePhoneCallNotificationController(@NonNull Context context, + TelephonyCallback telephonyCallback) { + mTelephonyListener = telephonyCallback; + mTelephonyManager = context.getSystemService(TelephonyManager.class); + mCallbackExecutor = context.getMainExecutor(); + } + + /** + * Registers a telephony callback to listen for call state changed to handle notification for + * hearing device during phone calls. + */ + public void startListenForCallState() { + mTelephonyManager.registerTelephonyCallback(mCallbackExecutor, mTelephonyListener); + } + + /** + * A telephony callback listener to listen to call state changes and show/dismiss notification + */ + @VisibleForTesting + static class CallStateListener extends TelephonyCallback implements + TelephonyCallback.CallStateListener { + + private static final String TAG = + "HearingDevice_CallStateListener"; + private static final String ACTION_SWITCH_TO_BUILTIN_MIC = + "com.android.server.accessibility.hearingdevice.action.SWITCH_TO_BUILTIN_MIC"; + private static final String ACTION_SWITCH_TO_HEARING_MIC = + "com.android.server.accessibility.hearingdevice.action.SWITCH_TO_HEARING_MIC"; + private static final String ACTION_BLUETOOTH_DEVICE_DETAILS = + "com.android.settings.BLUETOOTH_DEVICE_DETAIL_SETTINGS"; + private static final String KEY_BLUETOOTH_ADDRESS = "device_address"; + private static final String EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"; + private static final int MICROPHONE_SOURCE_VOICE_COMMUNICATION = + MediaRecorder.AudioSource.VOICE_COMMUNICATION; + private static final AudioDeviceAttributes BUILTIN_MIC = new AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_INPUT, AudioDeviceInfo.TYPE_BUILTIN_MIC, ""); + + private final Context mContext; + private NotificationManager mNotificationManager; + private AudioManager mAudioManager; + private BroadcastReceiver mHearingDeviceActionReceiver; + private BluetoothDevice mHearingDevice; + private boolean mIsNotificationShown = false; + + CallStateListener(@NonNull Context context) { + mContext = context; + } + + @Override + @SuppressLint("AndroidFrameworkRequiresPermission") + public void onCallStateChanged(int state) { + // NotificationManagerService and AudioService are all initialized after + // AccessibilityManagerService. + // Can not get them in constructor. Need to get these services until callback is + // triggered. + mNotificationManager = mContext.getSystemService(NotificationManager.class); + mAudioManager = mContext.getSystemService(AudioManager.class); + if (mNotificationManager == null || mAudioManager == null) { + Log.w(TAG, "NotificationManager or AudioManager is not prepare yet."); + return; + } + + if (state == TelephonyManager.CALL_STATE_IDLE) { + dismissNotificationIfNeeded(); + + if (mHearingDevice != null) { + // reset to its original status + setMicrophonePreferredForCalls(mHearingDevice.isMicrophonePreferredForCalls()); + } + mHearingDevice = null; + } + if (state == TelephonyManager.CALL_STATE_OFFHOOK) { + mHearingDevice = getSupportedInputHearingDeviceInfo( + mAudioManager.getAvailableCommunicationDevices()); + if (mHearingDevice != null) { + showNotificationIfNeeded(); + } + } + } + + private void showNotificationIfNeeded() { + if (mIsNotificationShown) { + return; + } + + showNotification(mHearingDevice.isMicrophonePreferredForCalls()); + mIsNotificationShown = true; + } + + private void dismissNotificationIfNeeded() { + if (!mIsNotificationShown) { + return; + } + + dismissNotification(); + mIsNotificationShown = false; + } + + private void showNotification(boolean useRemoteMicrophone) { + mNotificationManager.notify( + SystemMessageProto.SystemMessage.NOTE_HEARING_DEVICE_INPUT_SWITCH, + createSwitchInputNotification(useRemoteMicrophone)); + registerReceiverIfNeeded(); + } + + private void dismissNotification() { + unregisterReceiverIfNeeded(); + mNotificationManager.cancel( + SystemMessageProto.SystemMessage.NOTE_HEARING_DEVICE_INPUT_SWITCH); + } + + private BluetoothDevice getSupportedInputHearingDeviceInfo(List<AudioDeviceInfo> infoList) { + final BluetoothAdapter bluetoothAdapter = mContext.getSystemService( + BluetoothManager.class).getAdapter(); + if (bluetoothAdapter == null) { + return null; + } + if (!isHapClientSupported()) { + return null; + } + + final Set<String> inputDeviceAddress = Arrays.stream( + mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).map( + AudioDeviceInfo::getAddress).collect(Collectors.toSet()); + + //TODO: b/370812132 - Need to update if TYPE_LEA_HEARING_AID is added + final AudioDeviceInfo hearingDeviceInfo = infoList.stream() + .filter(info -> info.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET) + .filter(info -> inputDeviceAddress.contains(info.getAddress())) + .filter(info -> isHapClientDevice(bluetoothAdapter, info)) + .findAny() + .orElse(null); + + return (hearingDeviceInfo != null) ? bluetoothAdapter.getRemoteDevice( + hearingDeviceInfo.getAddress()) : null; + } + + @VisibleForTesting + boolean isHapClientDevice(BluetoothAdapter bluetoothAdapter, AudioDeviceInfo info) { + BluetoothDevice device = bluetoothAdapter.getRemoteDevice(info.getAddress()); + return ArrayUtils.contains(device.getUuids(), BluetoothUuid.HAS); + } + + @VisibleForTesting + boolean isHapClientSupported() { + return BluetoothAdapter.getDefaultAdapter().getSupportedProfiles().contains( + BluetoothProfile.HAP_CLIENT); + } + + private Notification createSwitchInputNotification(boolean useRemoteMicrophone) { + return new Notification.Builder(mContext, + SystemNotificationChannels.ACCESSIBILITY_HEARING_DEVICE) + .setContentTitle(getSwitchInputTitle(useRemoteMicrophone)) + .setContentText(getSwitchInputMessage(useRemoteMicrophone)) + .setSmallIcon(R.drawable.ic_settings_24dp) + .setColor(mContext.getResources().getColor( + com.android.internal.R.color.system_notification_accent_color)) + .setLocalOnly(true) + .setCategory(Notification.CATEGORY_SYSTEM) + .setContentIntent(createPendingIntent(ACTION_BLUETOOTH_DEVICE_DETAILS)) + .setActions(buildSwitchInputAction(useRemoteMicrophone), + buildOpenSettingsAction()) + .build(); + } + + private Notification.Action buildSwitchInputAction(boolean useRemoteMicrophone) { + return useRemoteMicrophone + ? new Notification.Action.Builder(null, + mContext.getString(R.string.hearing_device_notification_switch_button), + createPendingIntent(ACTION_SWITCH_TO_BUILTIN_MIC)).build() + : new Notification.Action.Builder(null, + mContext.getString(R.string.hearing_device_notification_switch_button), + createPendingIntent(ACTION_SWITCH_TO_HEARING_MIC)).build(); + } + + private Notification.Action buildOpenSettingsAction() { + return new Notification.Action.Builder(null, + mContext.getString(R.string.hearing_device_notification_settings_button), + createPendingIntent(ACTION_BLUETOOTH_DEVICE_DETAILS)).build(); + } + + private PendingIntent createPendingIntent(String action) { + final Intent intent = new Intent(action); + + switch (action) { + case ACTION_SWITCH_TO_BUILTIN_MIC, ACTION_SWITCH_TO_HEARING_MIC -> { + intent.setPackage(mContext.getPackageName()); + return PendingIntent.getBroadcast(mContext, /* requestCode = */ 0, intent, + PendingIntent.FLAG_IMMUTABLE); + } + case ACTION_BLUETOOTH_DEVICE_DETAILS -> { + Bundle bundle = new Bundle(); + bundle.putString(KEY_BLUETOOTH_ADDRESS, mHearingDevice.getAddress()); + intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle); + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + return PendingIntent.getActivity(mContext, /* requestCode = */ 0, intent, + PendingIntent.FLAG_IMMUTABLE); + } + } + return null; + } + + private void setMicrophonePreferredForCalls(boolean useRemoteMicrophone) { + if (useRemoteMicrophone) { + switchToHearingMic(); + } else { + switchToBuiltinMic(); + } + } + + @SuppressLint("AndroidFrameworkRequiresPermission") + private void switchToBuiltinMic() { + mAudioManager.clearPreferredDevicesForCapturePreset( + MICROPHONE_SOURCE_VOICE_COMMUNICATION); + mAudioManager.setPreferredDeviceForCapturePreset(MICROPHONE_SOURCE_VOICE_COMMUNICATION, + BUILTIN_MIC); + } + + @SuppressLint("AndroidFrameworkRequiresPermission") + private void switchToHearingMic() { + // clear config to let audio manager to determine next priority device. We can assume + // user connects to hearing device here, so next priority device should be hearing + // device. + mAudioManager.clearPreferredDevicesForCapturePreset( + MICROPHONE_SOURCE_VOICE_COMMUNICATION); + } + + private void registerReceiverIfNeeded() { + if (mHearingDeviceActionReceiver != null) { + return; + } + mHearingDeviceActionReceiver = new HearingDeviceActionReceiver(); + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_SWITCH_TO_BUILTIN_MIC); + intentFilter.addAction(ACTION_SWITCH_TO_HEARING_MIC); + mContext.registerReceiver(mHearingDeviceActionReceiver, intentFilter, + Manifest.permission.MANAGE_ACCESSIBILITY, null, Context.RECEIVER_NOT_EXPORTED); + } + + private void unregisterReceiverIfNeeded() { + if (mHearingDeviceActionReceiver == null) { + return; + } + mContext.unregisterReceiver(mHearingDeviceActionReceiver); + mHearingDeviceActionReceiver = null; + } + + private CharSequence getSwitchInputTitle(boolean useRemoteMicrophone) { + return useRemoteMicrophone + ? mContext.getString( + R.string.hearing_device_switch_phone_mic_notification_title) + : mContext.getString( + R.string.hearing_device_switch_hearing_mic_notification_title); + } + + private CharSequence getSwitchInputMessage(boolean useRemoteMicrophone) { + return useRemoteMicrophone + ? mContext.getString( + R.string.hearing_device_switch_phone_mic_notification_text) + : mContext.getString( + R.string.hearing_device_switch_hearing_mic_notification_text); + } + + private class HearingDeviceActionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (TextUtils.isEmpty(action)) { + return; + } + + if (ACTION_SWITCH_TO_BUILTIN_MIC.equals(action)) { + switchToBuiltinMic(); + showNotification(/* useRemoteMicrophone= */ false); + } else if (ACTION_SWITCH_TO_HEARING_MIC.equals(action)) { + switchToHearingMic(); + showNotification(/* useRemoteMicrophone= */ true); + } + } + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/accessibility/HearingDevicePhoneCallNotificationControllerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/HearingDevicePhoneCallNotificationControllerTest.java new file mode 100644 index 000000000000..efea21428937 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/HearingDevicePhoneCallNotificationControllerTest.java @@ -0,0 +1,187 @@ +/* + * 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.server.accessibility; + +import static android.Manifest.permission.BLUETOOTH_PRIVILEGED; + +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.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Application; +import android.app.Instrumentation; +import android.app.NotificationManager; +import android.bluetooth.BluetoothAdapter; +import android.content.Context; +import android.media.AudioDeviceInfo; +import android.media.AudioDevicePort; +import android.media.AudioManager; +import android.telephony.TelephonyCallback; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import com.android.internal.messages.nano.SystemMessageProto; + +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.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.List; +import java.util.concurrent.Executor; + +/** + * Tests for the {@link HearingDevicePhoneCallNotificationController}. + */ +@RunWith(AndroidJUnit4.class) +public class HearingDevicePhoneCallNotificationControllerTest { + @Rule + public MockitoRule mockito = MockitoJUnit.rule(); + + private static final String TEST_ADDRESS = "55:66:77:88:99:AA"; + + private final Application mApplication = ApplicationProvider.getApplicationContext(); + @Spy + private final Context mContext = mApplication.getApplicationContext(); + private final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation(); + + @Mock + private TelephonyManager mTelephonyManager; + @Mock + private NotificationManager mNotificationManager; + @Mock + private AudioManager mAudioManager; + private HearingDevicePhoneCallNotificationController mController; + private TestCallStateListener mTestCallStateListener; + + @Before + public void setUp() { + mInstrumentation.getUiAutomation().adoptShellPermissionIdentity(BLUETOOTH_PRIVILEGED); + when(mContext.getSystemService(TelephonyManager.class)).thenReturn(mTelephonyManager); + when(mContext.getSystemService(NotificationManager.class)).thenReturn(mNotificationManager); + when(mContext.getSystemService(AudioManager.class)).thenReturn(mAudioManager); + + mTestCallStateListener = new TestCallStateListener(mContext); + mController = new HearingDevicePhoneCallNotificationController(mContext, + mTestCallStateListener); + mController.startListenForCallState(); + } + + @Test + public void startListenForCallState_callbackNotNull() { + Mockito.reset(mTelephonyManager); + mController = new HearingDevicePhoneCallNotificationController(mContext); + ArgumentCaptor<TelephonyCallback> listenerCaptor = ArgumentCaptor.forClass( + TelephonyCallback.class); + + mController.startListenForCallState(); + + verify(mTelephonyManager).registerTelephonyCallback(any(Executor.class), + listenerCaptor.capture()); + TelephonyCallback callback = listenerCaptor.getValue(); + assertThat(callback).isNotNull(); + } + + @Test + public void onCallStateChanged_stateOffHook_hapDevice_showNotification() { + AudioDeviceInfo hapDeviceInfo = createAudioDeviceInfo(TEST_ADDRESS, + AudioManager.DEVICE_OUT_BLE_HEADSET); + when(mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).thenReturn( + new AudioDeviceInfo[]{hapDeviceInfo}); + when(mAudioManager.getAvailableCommunicationDevices()).thenReturn(List.of(hapDeviceInfo)); + + mTestCallStateListener.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK); + + verify(mNotificationManager).notify( + eq(SystemMessageProto.SystemMessage.NOTE_HEARING_DEVICE_INPUT_SWITCH), any()); + } + + @Test + public void onCallStateChanged_stateOffHook_a2dpDevice_noNotification() { + AudioDeviceInfo a2dpDeviceInfo = createAudioDeviceInfo(TEST_ADDRESS, + AudioManager.DEVICE_OUT_BLUETOOTH_A2DP); + when(mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).thenReturn( + new AudioDeviceInfo[]{a2dpDeviceInfo}); + when(mAudioManager.getAvailableCommunicationDevices()).thenReturn(List.of(a2dpDeviceInfo)); + + mTestCallStateListener.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK); + + verify(mNotificationManager, never()).notify( + eq(SystemMessageProto.SystemMessage.NOTE_HEARING_DEVICE_INPUT_SWITCH), any()); + } + + @Test + public void onCallStateChanged_stateOffHookThenIdle_hapDeviceInfo_cancelNotification() { + AudioDeviceInfo hapDeviceInfo = createAudioDeviceInfo(TEST_ADDRESS, + AudioManager.DEVICE_OUT_BLE_HEADSET); + when(mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).thenReturn( + new AudioDeviceInfo[]{hapDeviceInfo}); + when(mAudioManager.getAvailableCommunicationDevices()).thenReturn(List.of(hapDeviceInfo)); + + mTestCallStateListener.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK); + mTestCallStateListener.onCallStateChanged(TelephonyManager.CALL_STATE_IDLE); + + verify(mNotificationManager).cancel( + eq(SystemMessageProto.SystemMessage.NOTE_HEARING_DEVICE_INPUT_SWITCH)); + } + + private AudioDeviceInfo createAudioDeviceInfo(String address, int type) { + AudioDevicePort audioDevicePort = mock(AudioDevicePort.class); + doReturn(type).when(audioDevicePort).type(); + doReturn(address).when(audioDevicePort).address(); + doReturn("testDevice").when(audioDevicePort).name(); + + return new AudioDeviceInfo(audioDevicePort); + } + + /** + * For easier testing for CallStateListener, override methods that contain final object. + */ + private static class TestCallStateListener extends + HearingDevicePhoneCallNotificationController.CallStateListener { + + TestCallStateListener(@NonNull Context context) { + super(context); + } + + @Override + boolean isHapClientSupported() { + return true; + } + + @Override + boolean isHapClientDevice(BluetoothAdapter bluetoothAdapter, AudioDeviceInfo info) { + return TEST_ADDRESS.equals(info.getAddress()); + } + } +} |