1 /* 2 * Copyright 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.settingslib.media; 17 18 import static com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_SELECTED; 19 20 import android.content.Context; 21 import android.media.AudioAttributes; 22 import android.media.AudioDeviceAttributes; 23 import android.media.AudioDeviceCallback; 24 import android.media.AudioDeviceInfo; 25 import android.media.AudioDeviceInfo.AudioDeviceType; 26 import android.media.AudioManager; 27 import android.media.MediaRecorder; 28 import android.os.Handler; 29 import android.os.HandlerExecutor; 30 import android.util.Slog; 31 32 import androidx.annotation.NonNull; 33 import androidx.annotation.Nullable; 34 35 import com.android.internal.annotations.VisibleForTesting; 36 37 import java.util.ArrayList; 38 import java.util.Collection; 39 import java.util.List; 40 import java.util.concurrent.CopyOnWriteArrayList; 41 42 /** Provides functionalities to get/observe input routes, control input routing and volume gain. */ 43 public final class InputRouteManager { 44 45 private static final String TAG = "InputRouteManager"; 46 47 @VisibleForTesting 48 static final AudioAttributes INPUT_ATTRIBUTES = 49 new AudioAttributes.Builder().setCapturePreset(MediaRecorder.AudioSource.MIC).build(); 50 51 @VisibleForTesting 52 static final int[] PRESETS = { 53 MediaRecorder.AudioSource.MIC, 54 MediaRecorder.AudioSource.CAMCORDER, 55 MediaRecorder.AudioSource.VOICE_RECOGNITION, 56 MediaRecorder.AudioSource.VOICE_COMMUNICATION, 57 MediaRecorder.AudioSource.UNPROCESSED, 58 MediaRecorder.AudioSource.VOICE_PERFORMANCE 59 }; 60 61 private final Context mContext; 62 63 private final AudioManager mAudioManager; 64 65 @VisibleForTesting final List<MediaDevice> mInputMediaDevices = new CopyOnWriteArrayList<>(); 66 67 private @AudioDeviceType int mSelectedInputDeviceType; 68 69 private final Collection<InputDeviceCallback> mCallbacks = new CopyOnWriteArrayList<>(); 70 private final Object mCallbackLock = new Object(); 71 72 @VisibleForTesting 73 final AudioDeviceCallback mAudioDeviceCallback = 74 new AudioDeviceCallback() { 75 @Override 76 public void onAudioDevicesAdded(@NonNull AudioDeviceInfo[] addedDevices) { 77 applyDefaultSelectedTypeToAllPresets(); 78 79 // Activate the last hot plugged valid input device, to match the output device 80 // behavior. 81 @AudioDeviceType int deviceTypeToActivate = mSelectedInputDeviceType; 82 for (AudioDeviceInfo info : addedDevices) { 83 @AudioDeviceType int type = info.getType(); 84 // Since onAudioDevicesAdded is called not only when new device is hot 85 // plugged, but also when the switcher dialog is opened, make sure to check 86 // against existing device list and only activate if the device does not 87 // exist previously. 88 if (InputMediaDevice.isSupportedInputDevice(type) 89 && findDeviceByType(type) == null) { 90 deviceTypeToActivate = type; 91 } 92 } 93 94 // Only activate if we find a different valid input device. e.g. if none of the 95 // addedDevices is supported input device, we don't need to activate anything. 96 if (mSelectedInputDeviceType != deviceTypeToActivate) { 97 mSelectedInputDeviceType = deviceTypeToActivate; 98 AudioDeviceAttributes deviceAttributes = 99 createInputDeviceAttributes(mSelectedInputDeviceType); 100 setPreferredDeviceForAllPresets(deviceAttributes); 101 } 102 } 103 104 @Override 105 public void onAudioDevicesRemoved(@NonNull AudioDeviceInfo[] removedDevices) { 106 applyDefaultSelectedTypeToAllPresets(); 107 } 108 }; 109 InputRouteManager(@onNull Context context, @NonNull AudioManager audioManager)110 public InputRouteManager(@NonNull Context context, @NonNull AudioManager audioManager) { 111 mContext = context; 112 mAudioManager = audioManager; 113 Handler handler = new Handler(context.getMainLooper()); 114 115 mAudioManager.registerAudioDeviceCallback(mAudioDeviceCallback, handler); 116 117 mAudioManager.addOnPreferredDevicesForCapturePresetChangedListener( 118 new HandlerExecutor(handler), 119 this::onPreferredDevicesForCapturePresetChangedListener); 120 121 applyDefaultSelectedTypeToAllPresets(); 122 } 123 124 @VisibleForTesting onPreferredDevicesForCapturePresetChangedListener( @ediaRecorder.SystemSource int capturePreset, @NonNull List<AudioDeviceAttributes> devices)125 void onPreferredDevicesForCapturePresetChangedListener( 126 @MediaRecorder.SystemSource int capturePreset, 127 @NonNull List<AudioDeviceAttributes> devices) { 128 if (capturePreset == MediaRecorder.AudioSource.MIC) { 129 dispatchInputDeviceListUpdate(); 130 } 131 } 132 registerCallback(@onNull InputDeviceCallback callback)133 public void registerCallback(@NonNull InputDeviceCallback callback) { 134 synchronized (mCallbackLock) { 135 if (!mCallbacks.contains(callback)) { 136 mCallbacks.add(callback); 137 dispatchInputDeviceListUpdate(); 138 } 139 } 140 } 141 unregisterCallback(@onNull InputDeviceCallback callback)142 public void unregisterCallback(@NonNull InputDeviceCallback callback) { 143 synchronized (mCallbackLock) { 144 mCallbacks.remove(callback); 145 } 146 } 147 148 // TODO(b/355684672): handle edge case where there are two devices with the same type. Only 149 // using a single type might not be enough to recognize the correct device. 150 @Nullable findDeviceByType(@udioDeviceType int type)151 private MediaDevice findDeviceByType(@AudioDeviceType int type) { 152 for (MediaDevice device : mInputMediaDevices) { 153 if (((InputMediaDevice) device).getAudioDeviceInfoType() == type) { 154 return device; 155 } 156 } 157 return null; 158 } 159 160 @Nullable getSelectedInputDevice()161 public MediaDevice getSelectedInputDevice() { 162 return findDeviceByType(mSelectedInputDeviceType); 163 } 164 applyDefaultSelectedTypeToAllPresets()165 private void applyDefaultSelectedTypeToAllPresets() { 166 mSelectedInputDeviceType = retrieveDefaultSelectedDeviceType(); 167 AudioDeviceAttributes deviceAttributes = 168 createInputDeviceAttributes(mSelectedInputDeviceType); 169 setPreferredDeviceForAllPresets(deviceAttributes); 170 } 171 createInputDeviceAttributes(@udioDeviceType int type)172 private AudioDeviceAttributes createInputDeviceAttributes(@AudioDeviceType int type) { 173 // Address is not used. 174 return new AudioDeviceAttributes(AudioDeviceAttributes.ROLE_INPUT, type, /* address= */ ""); 175 } 176 retrieveDefaultSelectedDeviceType()177 private @AudioDeviceType int retrieveDefaultSelectedDeviceType() { 178 List<AudioDeviceAttributes> attributesOfSelectedInputDevices = 179 mAudioManager.getDevicesForAttributes(INPUT_ATTRIBUTES); 180 int selectedInputDeviceAttributesType; 181 if (attributesOfSelectedInputDevices.isEmpty()) { 182 Slog.e(TAG, "Unexpected empty list of input devices. Using built-in mic."); 183 selectedInputDeviceAttributesType = AudioDeviceInfo.TYPE_BUILTIN_MIC; 184 } else { 185 if (attributesOfSelectedInputDevices.size() > 1) { 186 Slog.w( 187 TAG, 188 "AudioManager.getDevicesForAttributes returned more than one element." 189 + " Using the first one."); 190 } 191 selectedInputDeviceAttributesType = attributesOfSelectedInputDevices.get(0).getType(); 192 } 193 return selectedInputDeviceAttributesType; 194 } 195 dispatchInputDeviceListUpdate()196 private void dispatchInputDeviceListUpdate() { 197 // Get all input devices. 198 AudioDeviceInfo[] audioDeviceInfos = 199 mAudioManager.getDevices(AudioManager.GET_DEVICES_INPUTS); 200 mInputMediaDevices.clear(); 201 for (AudioDeviceInfo info : audioDeviceInfos) { 202 MediaDevice mediaDevice = 203 InputMediaDevice.create( 204 mContext, 205 String.valueOf(info.getId()), 206 info.getType(), 207 getMaxInputGain(), 208 getCurrentInputGain(), 209 isInputGainFixed(), 210 getProductNameFromAudioDeviceInfo(info)); 211 if (mediaDevice != null) { 212 if (info.getType() == mSelectedInputDeviceType) { 213 mediaDevice.setState(STATE_SELECTED); 214 } 215 mInputMediaDevices.add(mediaDevice); 216 } 217 } 218 219 final List<MediaDevice> inputMediaDevices = new ArrayList<>(mInputMediaDevices); 220 synchronized (mCallbackLock) { 221 for (InputDeviceCallback callback : mCallbacks) { 222 callback.onInputDeviceListUpdated(inputMediaDevices); 223 } 224 } 225 } 226 227 /** 228 * Gets the product name for the given {@link AudioDeviceInfo}. 229 * 230 * @return The product name for the given {@link AudioDeviceInfo}, or null if a suitable name 231 * cannot be found. 232 */ 233 @Nullable getProductNameFromAudioDeviceInfo(AudioDeviceInfo deviceInfo)234 private String getProductNameFromAudioDeviceInfo(AudioDeviceInfo deviceInfo) { 235 CharSequence productName = deviceInfo.getProductName(); 236 if (productName == null) { 237 return null; 238 } 239 String productNameString = productName.toString(); 240 if (productNameString.isBlank()) { 241 return null; 242 } 243 return productNameString; 244 } 245 selectDevice(@onNull MediaDevice device)246 public void selectDevice(@NonNull MediaDevice device) { 247 if (!(device instanceof InputMediaDevice inputMediaDevice)) { 248 Slog.w(TAG, "This device is not an InputMediaDevice: " + device.getName()); 249 return; 250 } 251 252 if (inputMediaDevice.getAudioDeviceInfoType() == mSelectedInputDeviceType) { 253 Slog.w(TAG, "This device is already selected: " + device.getName()); 254 return; 255 } 256 257 // Handle edge case where the targeting device is not available, e.g. disconnected. 258 if (!mInputMediaDevices.contains(device)) { 259 Slog.w(TAG, "This device is not available: " + device.getName()); 260 return; 261 } 262 263 // Update mSelectedInputDeviceType directly based on user action. 264 mSelectedInputDeviceType = inputMediaDevice.getAudioDeviceInfoType(); 265 266 AudioDeviceAttributes deviceAttributes = 267 createInputDeviceAttributes(inputMediaDevice.getAudioDeviceInfoType()); 268 try { 269 setPreferredDeviceForAllPresets(deviceAttributes); 270 } catch (IllegalArgumentException e) { 271 Slog.e( 272 TAG, 273 "Illegal argument exception while setPreferredDeviceForAllPreset: " 274 + device.getName(), 275 e); 276 } 277 } 278 setPreferredDeviceForAllPresets(@onNull AudioDeviceAttributes deviceAttributes)279 private void setPreferredDeviceForAllPresets(@NonNull AudioDeviceAttributes deviceAttributes) { 280 // The input routing via system setting takes effect on all capture presets. 281 for (@MediaRecorder.Source int preset : PRESETS) { 282 mAudioManager.setPreferredDeviceForCapturePreset(preset, deviceAttributes); 283 } 284 } 285 getMaxInputGain()286 public int getMaxInputGain() { 287 // TODO (b/357123335): use real input gain implementation. 288 // Using 100 for now since it matches the maximum input gain index in classic ChromeOS. 289 return 100; 290 } 291 getCurrentInputGain()292 public int getCurrentInputGain() { 293 // TODO (b/357123335): use real input gain implementation. 294 // Show a fixed full gain in UI before it really works per UX requirement. 295 return 100; 296 } 297 isInputGainFixed()298 public boolean isInputGainFixed() { 299 // TODO (b/357123335): use real input gain implementation. 300 return true; 301 } 302 303 /** Callback for listening to input device changes. */ 304 public interface InputDeviceCallback { onInputDeviceListUpdated(@onNull List<MediaDevice> devices)305 void onInputDeviceListUpdated(@NonNull List<MediaDevice> devices); 306 } 307 } 308