| // Copyright 2013 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.media; |
| |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.pm.PackageManager; |
| import android.database.ContentObserver; |
| import android.media.AudioDeviceInfo; |
| import android.media.AudioFormat; |
| import android.media.AudioManager; |
| import android.media.AudioRecord; |
| import android.media.AudioTrack; |
| import android.media.audiofx.AcousticEchoCanceler; |
| import android.os.Build; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.provider.Settings; |
| |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.Log; |
| import org.chromium.base.ThreadUtils.ThreadChecker; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.annotations.JNINamespace; |
| import org.chromium.base.annotations.NativeMethods; |
| |
| import java.lang.reflect.Method; |
| import java.util.Optional; |
| |
| @JNINamespace("media") |
| class AudioManagerAndroid { |
| private static final String TAG = "media"; |
| |
| // Set to true to enable debug logs. Avoid in production builds. |
| // NOTE: always check in as false. |
| private static final boolean DEBUG = false; |
| |
| /** Simple container for device information. */ |
| public static class AudioDeviceName { |
| private final int mId; |
| private final String mName; |
| |
| public AudioDeviceName(int id, String name) { |
| mId = id; |
| mName = name; |
| } |
| |
| @CalledByNative("AudioDeviceName") |
| private String id() { |
| return String.valueOf(mId); |
| } |
| |
| @CalledByNative("AudioDeviceName") |
| private String name() { |
| return mName; |
| } |
| } |
| |
| // Use 44.1kHz as the default sampling rate. |
| private static final int DEFAULT_SAMPLING_RATE = 44100; |
| // Randomly picked up frame size which is close to return value on N4. |
| // Return this value when getProperty(PROPERTY_OUTPUT_FRAMES_PER_BUFFER) |
| // fails. |
| private static final int DEFAULT_FRAME_PER_BUFFER = 256; |
| |
| private final AudioManager mAudioManager; |
| private final long mNativeAudioManagerAndroid; |
| |
| // Enabled during initialization if MODIFY_AUDIO_SETTINGS permission is |
| // granted. Required to shift system-wide audio settings. |
| private boolean mHasModifyAudioSettingsPermission; |
| |
| private boolean mIsInitialized; |
| private boolean mSavedIsSpeakerphoneOn; |
| private boolean mSavedIsMicrophoneMute; |
| |
| // This class should be created, initialized and closed on the audio thread |
| // in the audio manager. We use |mThreadChecker| to ensure that this is |
| // the case. |
| private final ThreadChecker mThreadChecker = new ThreadChecker(); |
| |
| private final ContentResolver mContentResolver; |
| private ContentObserver mSettingsObserver; |
| private HandlerThread mSettingsObserverThread; |
| |
| private AudioDeviceSelector mAudioDeviceSelector; |
| |
| /** Construction */ |
| @CalledByNative |
| private static AudioManagerAndroid createAudioManagerAndroid(long nativeAudioManagerAndroid) { |
| return new AudioManagerAndroid(nativeAudioManagerAndroid); |
| } |
| |
| private AudioManagerAndroid(long nativeAudioManagerAndroid) { |
| mNativeAudioManagerAndroid = nativeAudioManagerAndroid; |
| mAudioManager = (AudioManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.AUDIO_SERVICE); |
| mContentResolver = ContextUtils.getApplicationContext().getContentResolver(); |
| |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { |
| mAudioDeviceSelector = new AudioDeviceSelectorPreS(mAudioManager); |
| } else { |
| mAudioDeviceSelector = new AudioDeviceSelectorPostS(mAudioManager); |
| } |
| } |
| |
| /** |
| * Saves the initial speakerphone and microphone state. |
| * Populates the list of available audio devices and registers receivers for broadcasting |
| * intents related to wired headset and Bluetooth devices and USB audio devices. |
| */ |
| @CalledByNative |
| private void init() { |
| mThreadChecker.assertOnValidThread(); |
| if (DEBUG) logd("init"); |
| if (DEBUG) logDeviceInfo(); |
| if (mIsInitialized) return; |
| |
| // Check if process has MODIFY_AUDIO_SETTINGS and RECORD_AUDIO |
| // permissions. Both are required for full functionality. |
| mHasModifyAudioSettingsPermission = |
| hasPermission(android.Manifest.permission.MODIFY_AUDIO_SETTINGS); |
| if (DEBUG && !mHasModifyAudioSettingsPermission) { |
| logd("MODIFY_AUDIO_SETTINGS permission is missing"); |
| } |
| |
| mAudioDeviceSelector.init(); |
| |
| mIsInitialized = true; |
| } |
| |
| /** |
| * Unregister all previously registered intent receivers and restore |
| * the stored state (stored in {@link #init()}). |
| */ |
| @CalledByNative |
| private void close() { |
| mThreadChecker.assertOnValidThread(); |
| if (DEBUG) logd("close"); |
| if (!mIsInitialized) return; |
| |
| stopObservingVolumeChanges(); |
| |
| mAudioDeviceSelector.close(); |
| |
| mIsInitialized = false; |
| } |
| |
| /** |
| * Sets audio mode as COMMUNICATION if input parameter is true. |
| * Restores audio mode to NORMAL if input parameter is false. |
| * Required permission: android.Manifest.permission.MODIFY_AUDIO_SETTINGS. |
| */ |
| @CalledByNative |
| private void setCommunicationAudioModeOn(boolean on) { |
| mThreadChecker.assertOnValidThread(); |
| if (DEBUG) logd("setCommunicationAudioModeOn" + on + ")"); |
| if (!mIsInitialized) return; |
| |
| // The MODIFY_AUDIO_SETTINGS permission is required to allow an |
| // application to modify global audio settings. |
| if (!mHasModifyAudioSettingsPermission) { |
| Log.w(TAG, |
| "MODIFY_AUDIO_SETTINGS is missing => client will run " |
| + "with reduced functionality"); |
| return; |
| } |
| |
| // TODO(crbug.com/1317548): Should we exit early if we are already in/out of |
| // communication mode? |
| if (on) { |
| // Store microphone mute state and speakerphone state so it can |
| // be restored when closing. |
| mSavedIsSpeakerphoneOn = mAudioDeviceSelector.isSpeakerphoneOn(); |
| mSavedIsMicrophoneMute = mAudioManager.isMicrophoneMute(); |
| |
| mAudioDeviceSelector.setCommunicationAudioModeOn(true); |
| |
| // Start observing volume changes to detect when the |
| // voice/communication stream volume is at its lowest level. |
| // It is only possible to pull down the volume slider to about 20% |
| // of the absolute minimum (slider at far left) in communication |
| // mode but we want to be able to mute it completely. |
| startObservingVolumeChanges(); |
| } else { |
| stopObservingVolumeChanges(); |
| |
| mAudioDeviceSelector.setCommunicationAudioModeOn(false); |
| |
| // Restore previously stored audio states. |
| setMicrophoneMute(mSavedIsMicrophoneMute); |
| mAudioDeviceSelector.setSpeakerphoneOn(mSavedIsSpeakerphoneOn); |
| } |
| |
| setCommunicationAudioModeOnInternal(on); |
| } |
| |
| /** |
| * Sets audio mode to MODE_IN_COMMUNICATION if input parameter is true. |
| * Restores audio mode to MODE_NORMAL if input parameter is false. |
| */ |
| private void setCommunicationAudioModeOnInternal(boolean on) { |
| if (DEBUG) logd("setCommunicationAudioModeOn(" + on + ")"); |
| |
| if (on) { |
| try { |
| mAudioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); |
| } catch (SecurityException e) { |
| logDeviceInfo(); |
| throw e; |
| } |
| |
| } else { |
| // Restore the mode that was used before we switched to |
| // communication mode. |
| try { |
| mAudioManager.setMode(AudioManager.MODE_NORMAL); |
| } catch (SecurityException e) { |
| logDeviceInfo(); |
| throw e; |
| } |
| } |
| } |
| |
| /** |
| * Activates, i.e., starts routing audio to, the specified audio device. |
| * |
| * @param deviceId Unique device ID (integer converted to string) |
| * representing the selected device. This string is empty if the so-called |
| * default device is requested. |
| * Required permissions: android.Manifest.permission.MODIFY_AUDIO_SETTINGS |
| * and android.Manifest.permission.RECORD_AUDIO. |
| */ |
| @CalledByNative |
| private boolean setDevice(String deviceId) { |
| if (DEBUG) logd("setDevice: " + deviceId); |
| if (!mIsInitialized) return false; |
| |
| boolean hasRecordAudioPermission = hasPermission(android.Manifest.permission.RECORD_AUDIO); |
| if (!mHasModifyAudioSettingsPermission || !hasRecordAudioPermission) { |
| Log.w(TAG, |
| "Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. " |
| + "Selected device will not be available for recording"); |
| return false; |
| } |
| |
| return mAudioDeviceSelector.selectDevice(deviceId); |
| } |
| |
| /** |
| * @return the current list of available audio devices. |
| * Note that this call does not trigger any update of the list of devices, |
| * it only copies the current state in to the output array. |
| * Required permissions: android.Manifest.permission.MODIFY_AUDIO_SETTINGS |
| * and android.Manifest.permission.RECORD_AUDIO. |
| */ |
| @CalledByNative |
| private AudioDeviceName[] getAudioInputDeviceNames() { |
| if (DEBUG) logd("getAudioInputDeviceNames"); |
| if (!mIsInitialized) return null; |
| |
| boolean hasRecordAudioPermission = hasPermission(android.Manifest.permission.RECORD_AUDIO); |
| if (!mHasModifyAudioSettingsPermission || !hasRecordAudioPermission) { |
| Log.w(TAG, |
| "Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. " |
| + "No audio device will be available for recording"); |
| return null; |
| } |
| |
| return mAudioDeviceSelector.getAudioInputDeviceNames(); |
| } |
| |
| @CalledByNative |
| private int getNativeOutputSampleRate() { |
| String sampleRateString = |
| mAudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE); |
| return sampleRateString == null ? DEFAULT_SAMPLING_RATE |
| : Integer.parseInt(sampleRateString); |
| } |
| |
| /** |
| * Returns the minimum frame size required for audio input. |
| * |
| * @param sampleRate sampling rate |
| * @param channels number of channels |
| */ |
| @CalledByNative |
| private static int getMinInputFrameSize(int sampleRate, int channels) { |
| int channelConfig; |
| if (channels == 1) { |
| channelConfig = AudioFormat.CHANNEL_IN_MONO; |
| } else if (channels == 2) { |
| channelConfig = AudioFormat.CHANNEL_IN_STEREO; |
| } else { |
| return -1; |
| } |
| return AudioRecord.getMinBufferSize( |
| sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) |
| / 2 / channels; |
| } |
| |
| /** |
| * Returns the minimum frame size required for audio output. |
| * |
| * @param sampleRate sampling rate |
| * @param channels number of channels |
| */ |
| @CalledByNative |
| private static int getMinOutputFrameSize(int sampleRate, int channels) { |
| int channelConfig; |
| if (channels == 1) { |
| channelConfig = AudioFormat.CHANNEL_OUT_MONO; |
| } else if (channels == 2) { |
| channelConfig = AudioFormat.CHANNEL_OUT_STEREO; |
| } else { |
| return -1; |
| } |
| return AudioTrack.getMinBufferSize( |
| sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) |
| / 2 / channels; |
| } |
| |
| @CalledByNative |
| private boolean isAudioLowLatencySupported() { |
| return ContextUtils.getApplicationContext().getPackageManager().hasSystemFeature( |
| PackageManager.FEATURE_AUDIO_LOW_LATENCY); |
| } |
| |
| @CalledByNative |
| private int getAudioLowLatencyOutputFrameSize() { |
| String framesPerBuffer = |
| mAudioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); |
| return framesPerBuffer == null ? DEFAULT_FRAME_PER_BUFFER |
| : Integer.parseInt(framesPerBuffer); |
| } |
| |
| @CalledByNative |
| private static boolean acousticEchoCancelerIsAvailable() { |
| return AcousticEchoCanceler.isAvailable(); |
| } |
| |
| // Used for reflection of hidden method getOutputLatency. Will be `null` before reflection, and |
| // a (possibly empty) Optional after. |
| private static Optional<Method> sGetOutputLatency; |
| |
| // Reflect |methodName(int)|, and return it. |
| private static final Method reflectMethod(String methodName) { |
| try { |
| return AudioManager.class.getMethod(methodName, int.class); |
| } catch (NoSuchMethodException e) { |
| return null; |
| } |
| } |
| |
| // Return the output latency, as reported by AudioManager. Do not use this, |
| // since it is (a) a hidden API call, and (b) documented as being |
| // unreliable. It's here only to adjust for some hardware devices that do |
| // not handle latency properly otherwise. |
| // See b/80326798 for more information. |
| @CalledByNative |
| private int getOutputLatency() { |
| mThreadChecker.assertOnValidThread(); |
| |
| if (sGetOutputLatency == null) { |
| // It's okay if this assigns `null`; we won't call it, but we also won't try again to |
| // reflect it. |
| sGetOutputLatency = Optional.ofNullable(reflectMethod("getOutputLatency")); |
| } |
| |
| int result = 0; |
| if (sGetOutputLatency.isPresent()) { |
| try { |
| result = (Integer) sGetOutputLatency.get().invoke( |
| mAudioManager, AudioManager.STREAM_MUSIC); |
| } catch (Exception e) { |
| ; |
| } |
| } |
| |
| return result; |
| } |
| |
| /** Sets the microphone mute state. */ |
| private void setMicrophoneMute(boolean on) { |
| boolean wasMuted = mAudioManager.isMicrophoneMute(); |
| if (wasMuted == on) { |
| return; |
| } |
| mAudioManager.setMicrophoneMute(on); |
| } |
| |
| /** Gets the current microphone mute state. */ |
| private boolean isMicrophoneMute() { |
| return mAudioManager.isMicrophoneMute(); |
| } |
| |
| /** Checks if the process has as specified permission or not. */ |
| private boolean hasPermission(String permission) { |
| return ContextUtils.getApplicationContext().checkSelfPermission(permission) |
| == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| /** Information about the current build, taken from system properties. */ |
| private void logDeviceInfo() { |
| logd("Android SDK: " + Build.VERSION.SDK_INT + ", " |
| + "Release: " + Build.VERSION.RELEASE + ", " |
| + "Brand: " + Build.BRAND + ", " |
| + "Device: " + Build.DEVICE + ", " |
| + "Id: " + Build.ID + ", " |
| + "Hardware: " + Build.HARDWARE + ", " |
| + "Manufacturer: " + Build.MANUFACTURER + ", " |
| + "Model: " + Build.MODEL + ", " |
| + "Product: " + Build.PRODUCT); |
| } |
| |
| /** Trivial helper method for debug logging */ |
| private static void logd(String msg) { |
| Log.d(TAG, msg); |
| } |
| |
| /** Trivial helper method for error logging */ |
| private static void loge(String msg) { |
| Log.e(TAG, msg); |
| } |
| |
| /** Start thread which observes volume changes on the voice stream. */ |
| private void startObservingVolumeChanges() { |
| if (DEBUG) logd("startObservingVolumeChanges"); |
| if (mSettingsObserverThread != null) return; |
| mSettingsObserverThread = new HandlerThread("SettingsObserver"); |
| mSettingsObserverThread.start(); |
| |
| mSettingsObserver = new ContentObserver(new Handler(mSettingsObserverThread.getLooper())) { |
| @Override |
| public void onChange(boolean selfChange) { |
| if (DEBUG) logd("SettingsObserver.onChange: " + selfChange); |
| super.onChange(selfChange); |
| |
| // Get stream volume for the voice stream and deliver callback if |
| // the volume index is zero. It is not possible to move the volume |
| // slider all the way down in communication mode but the callback |
| // implementation can ensure that the volume is completely muted. |
| int volume = mAudioManager.getStreamVolume(AudioManager.STREAM_VOICE_CALL); |
| if (DEBUG) logd("AudioManagerAndroidJni.get().setMute: " + (volume == 0)); |
| AudioManagerAndroidJni.get().setMute( |
| mNativeAudioManagerAndroid, AudioManagerAndroid.this, (volume == 0)); |
| } |
| }; |
| |
| mContentResolver.registerContentObserver( |
| Settings.System.CONTENT_URI, true, mSettingsObserver); |
| } |
| |
| /** Quit observer thread and stop listening for volume changes. */ |
| private void stopObservingVolumeChanges() { |
| if (DEBUG) logd("stopObservingVolumeChanges"); |
| if (mSettingsObserverThread == null) return; |
| |
| mContentResolver.unregisterContentObserver(mSettingsObserver); |
| mSettingsObserver = null; |
| |
| mSettingsObserverThread.quit(); |
| try { |
| mSettingsObserverThread.join(); |
| } catch (InterruptedException e) { |
| Log.e(TAG, "Thread.join() exception: ", e); |
| } |
| mSettingsObserverThread = null; |
| } |
| |
| /** Return the AudioDeviceInfo array as reported by the Android OS. */ |
| private static AudioDeviceInfo[] getAudioDeviceInfo() { |
| AudioManager audioManager = |
| (AudioManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.AUDIO_SERVICE); |
| return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS); |
| } |
| |
| /** Returns whether an audio sink device is connected. */ |
| @CalledByNative |
| private static boolean isAudioSinkConnected() { |
| for (AudioDeviceInfo deviceInfo : getAudioDeviceInfo()) { |
| if (deviceInfo.isSink()) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Returns a bit mask of Audio Formats (C++ AudioParameters::Format enum) |
| * supported by all of the sink devices. |
| */ |
| @CalledByNative |
| private static int getAudioEncodingFormatsSupported() { |
| int intersection_mask = 0; // intersection of multiple device encoding arrays |
| boolean first = true; |
| for (AudioDeviceInfo deviceInfo : getAudioDeviceInfo()) { |
| int[] encodings = deviceInfo.getEncodings(); |
| if (deviceInfo.isSink() && deviceInfo.getType() == AudioDeviceInfo.TYPE_HDMI) { |
| int mask = 0; // bit mask for a single device |
| |
| // Map AudioFormat values to C++ media/base/audio_parameters.h Format enum |
| for (int i : encodings) { |
| switch (i) { |
| case AudioFormat.ENCODING_PCM_16BIT: |
| mask |= AudioEncodingFormat.PCM_LINEAR; |
| break; |
| case AudioFormat.ENCODING_AC3: |
| mask |= AudioEncodingFormat.BITSTREAM_AC3; |
| break; |
| case AudioFormat.ENCODING_E_AC3: |
| mask |= AudioEncodingFormat.BITSTREAM_EAC3; |
| break; |
| case AudioFormat.ENCODING_DTS: |
| mask |= AudioEncodingFormat.BITSTREAM_DTS; |
| break; |
| case AudioFormat.ENCODING_DTS_HD: |
| mask |= AudioEncodingFormat.BITSTREAM_DTS_HD; |
| break; |
| case AudioFormat.ENCODING_IEC61937: |
| mask |= AudioEncodingFormat.BITSTREAM_IEC61937; |
| break; |
| } |
| } |
| |
| // Require all devices to support a format |
| if (first) { |
| first = false; |
| intersection_mask = mask; |
| } else { |
| intersection_mask &= mask; |
| } |
| } |
| } |
| return intersection_mask; |
| } |
| |
| @NativeMethods |
| interface Natives { |
| void setMute(long nativeAudioManagerAndroid, AudioManagerAndroid caller, boolean muted); |
| } |
| } |