diff options
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 7a025f16b63a..972c2ea403e0 100644 --- a/core/java/com/android/internal/notification/SystemNotificationChannels.java +++ b/core/java/com/android/internal/notification/SystemNotificationChannels.java @@ -68,6 +68,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"; @@ -210,6 +211,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 f6dd3f2ed029..debc5e9a0dce 100644 --- a/core/res/res/values/strings.xml +++ b/core/res/res/values/strings.xml @@ -884,6 +884,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> @@ -4994,6 +4998,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 c5b764c6578d..f89ca44cce30 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -3843,6 +3843,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"/> @@ -4030,6 +4037,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="time_zone_change_notification_title" /> 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()); +        } +    } +} |