Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 63bdfa89 authored by ryanywlin's avatar ryanywlin
Browse files

Add audio switch UI in sound settings

- Build two controller to control list preferences.

- MediaOutputPreferenceController which allows switching
the media output between current device and connected
BT device supporting A2DP. It also controls disabling
media output switching during a call or cast mode.

- HandsFreeProfilePreferenceController which allows
switching between HFP-connected BT devices while in
on-call state.

- Add test cases for controllers.

Bug: 74130772
Test: make RunSettingsRoboTests ROBOTEST_FILTER="MediaOutputPreferenceControllerTest" -j56
Test: make RunSettingsRoboTests ROBOTEST_FILTER="HandsFreeProfileOutputPreferenceControllerTest" -j56
Test: make RunSettingsRoboTests ROBOTEST_FILTER="AudioOutputSwitchPreferenceControllerTest" -j56

Change-Id: I37f5418442ce77e72cdff07f071ea519ab1047f3
parent 751a3953
Loading
Loading
Loading
Loading
+16 −0
Original line number Diff line number Diff line
@@ -30,6 +30,14 @@
        android:order="-180"
        settings:controller="com.android.settings.notification.MediaVolumePreferenceController"/>

    <!-- Media output switcher -->
    <ListPreference
        android:key="media_output"
        android:title="@string/media_output_title"
        android:dialogTitle="@string/media_output_title"
        android:order="-175"
        settings:controller="com.android.settings.sound.MediaOutputPreferenceController"/>

    <!-- Ring volume -->
    <com.android.settings.notification.VolumeSeekBarPreference
        android:key="ring_volume"
@@ -45,6 +53,14 @@
        android:title="@string/vibrate_when_ringing_title"
        android:order="-160"/>

    <!-- Hands free profile output switcher -->
    <ListPreference
        android:key="take_call_on_output"
        android:title="@string/take_call_on_title"
        android:dialogTitle="@string/take_call_on_title"
        android:order="-155"
        settings:controller="com.android.settings.sound.HandsFreeProfileOutputPreferenceController"/>

    <!-- Alarm volume -->
    <com.android.settings.notification.VolumeSeekBarPreference
        android:key="alarm_volume"
+1 −0
Original line number Diff line number Diff line
@@ -25,4 +25,5 @@ public class FeatureFlags {
    public static final String ABOUT_PHONE_V2 = "settings_about_phone_v2";
    public static final String BLUETOOTH_WHILE_DRIVING = "settings_bluetooth_while_driving";
    public static final String DATA_USAGE_SETTINGS_V2 = "settings_data_usage_v2";
    public static final String AUDIO_SWITCHER_SETTINGS = "settings_audio_switcher";
}
+335 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settings.sound;


import static android.media.AudioManager.STREAM_DEVICES_CHANGED_ACTION;
import static android.media.MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY;

import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.media.MediaRouter;
import android.media.MediaRouter.Callback;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.preference.ListPreference;
import android.support.v7.preference.Preference;
import android.support.v7.preference.PreferenceScreen;
import android.text.TextUtils;
import android.util.FeatureFlagUtils;

import com.android.internal.util.ArrayUtils;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.core.FeatureFlags;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.core.lifecycle.LifecycleObserver;
import com.android.settingslib.core.lifecycle.events.OnStart;
import com.android.settingslib.core.lifecycle.events.OnStop;

import java.util.List;

/**
 * Abstract class for audio switcher controller to notify subclass
 * updating the current status of switcher entry. Subclasses must overwrite
 * {@link #setActiveBluetoothDevice(BluetoothDevice)} to set the
 * active device for corresponding profile.
 */
public abstract class AudioSwitchPreferenceController extends BasePreferenceController
        implements Preference.OnPreferenceChangeListener, BluetoothCallback,
        LifecycleObserver, OnStart, OnStop {

    private static final int INVALID_INDEX = -1;

    protected final AudioManager mAudioManager;
    protected final MediaRouter mMediaRouter;
    protected final LocalBluetoothProfileManager mProfileManager;
    protected int mSelectedIndex;
    protected Preference mPreference;
    protected List<BluetoothDevice> mConnectedDevices;

    private final AudioManagerAudioDeviceCallback mAudioManagerAudioDeviceCallback;
    private final LocalBluetoothManager mLocalBluetoothManager;
    private final MediaRouterCallback mMediaRouterCallback;
    private final WiredHeadsetBroadcastReceiver mReceiver;
    private final Handler mHandler;

    public AudioSwitchPreferenceController(Context context, String preferenceKey) {
        super(context, preferenceKey);
        mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
        mMediaRouter = (MediaRouter) context.getSystemService(Context.MEDIA_ROUTER_SERVICE);
        mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
        mLocalBluetoothManager.setForegroundActivity(context);
        mProfileManager = mLocalBluetoothManager.getProfileManager();
        mHandler = new Handler(Looper.getMainLooper());
        mAudioManagerAudioDeviceCallback = new AudioManagerAudioDeviceCallback();
        mReceiver = new WiredHeadsetBroadcastReceiver();
        mMediaRouterCallback = new MediaRouterCallback();
    }

    /**
     * Make this method as final, ensure that subclass will checking
     * the feature flag and they could mistakenly break it via overriding.
     */
    @Override
    public final int getAvailabilityStatus() {
        return FeatureFlagUtils.isEnabled(mContext, FeatureFlags.AUDIO_SWITCHER_SETTINGS)
                ? AVAILABLE : DISABLED_UNSUPPORTED;
    }

    @Override
    public boolean onPreferenceChange(Preference preference, Object newValue) {
        final String address = (String) newValue;
        if (!(preference instanceof ListPreference)) {
            return false;
        }

        final ListPreference listPreference = (ListPreference) preference;
        if (TextUtils.equals(address, mContext.getText(R.string.media_output_default_summary))) {
            // Switch to default device which address is device name
            mSelectedIndex = getDefaultDeviceIndex();
            setActiveBluetoothDevice(null);
            listPreference.setSummary(mContext.getText(R.string.media_output_default_summary));
        } else {
            // Switch to BT device which address is hardware address
            final int connectedDeviceIndex = getConnectedDeviceIndex(address);
            if (connectedDeviceIndex == INVALID_INDEX) {
                return false;
            }
            final BluetoothDevice btDevice = mConnectedDevices.get(connectedDeviceIndex);
            mSelectedIndex = connectedDeviceIndex;
            setActiveBluetoothDevice(btDevice);
            listPreference.setSummary(btDevice.getName());
        }
        return true;
    }

    public abstract void setActiveBluetoothDevice(BluetoothDevice device);

    @Override
    public void displayPreference(PreferenceScreen screen) {
        super.displayPreference(screen);
        mPreference = screen.findPreference(mPreferenceKey);
    }

    @Override
    public void onStart() {
        register();
    }

    @Override
    public void onStop() {
        unregister();
    }

    /**
     * Only concerned about whether the local adapter is connected to any profile of any device and
     * are not really concerned about which profile.
     */
    @Override
    public void onConnectionStateChanged(CachedBluetoothDevice cachedDevice, int state) {
        updateState(mPreference);
    }

    @Override
    public void onActiveDeviceChanged(CachedBluetoothDevice activeDevice, int bluetoothProfile) {
        updateState(mPreference);
    }

    @Override
    public void onAudioModeChanged() {
        updateState(mPreference);
    }

    @Override
    public void onBluetoothStateChanged(int bluetoothState) {
    }

    /**
     * The local Bluetooth adapter has started the remote device discovery process.
     */
    @Override
    public void onScanningStateChanged(boolean started) {
    }

    /**
     * Indicates a change in the bond state of a remote
     * device. For example, if a device is bonded (paired).
     */
    @Override
    public void onDeviceAdded(CachedBluetoothDevice cachedDevice) {
        updateState(mPreference);
    }

    @Override
    public void onDeviceDeleted(CachedBluetoothDevice cachedDevice) {
    }

    @Override
    public void onDeviceBondStateChanged(CachedBluetoothDevice cachedDevice, int bondState) {
    }

    protected boolean isOngoingCallStatus() {
        int audioMode = mAudioManager.getMode();
        return audioMode == AudioManager.MODE_RINGTONE
                || audioMode == AudioManager.MODE_IN_CALL
                || audioMode == AudioManager.MODE_IN_COMMUNICATION;
    }

    int getDefaultDeviceIndex() {
        // Default device is after all connected devices.
        return ArrayUtils.size(mConnectedDevices);
    }

    void setupPreferenceEntries(CharSequence[] mediaOutputs, CharSequence[] mediaValues,
            BluetoothDevice activeDevice) {
        // default to current device
        mSelectedIndex = getDefaultDeviceIndex();
        // default device is after all connected devices.
        mediaOutputs[mSelectedIndex] = mContext.getText(R.string.media_output_default_summary);
        // use default device name as address
        mediaValues[mSelectedIndex] = mContext.getText(R.string.media_output_default_summary);
        for (int i = 0, size = mConnectedDevices.size(); i < size; i++) {
            final BluetoothDevice btDevice = mConnectedDevices.get(i);
            mediaOutputs[i] = btDevice.getName();
            mediaValues[i] = btDevice.getAddress();
            if (btDevice.equals(activeDevice)) {
                // select the active connected device.
                mSelectedIndex = i;
            }
        }
    }

    void setPreference(CharSequence[] mediaOutputs, CharSequence[] mediaValues,
            Preference preference) {
        final ListPreference listPreference = (ListPreference) preference;
        listPreference.setEntries(mediaOutputs);
        listPreference.setEntryValues(mediaValues);
        listPreference.setValueIndex(mSelectedIndex);
        listPreference.setSummary(mediaOutputs[mSelectedIndex]);
    }

    private int getConnectedDeviceIndex(String hardwareAddress) {
        if (mConnectedDevices != null) {
            for (int i = 0, size = mConnectedDevices.size(); i < size; i++) {
                final BluetoothDevice btDevice = mConnectedDevices.get(i);
                if (TextUtils.equals(btDevice.getAddress(), hardwareAddress)) {
                    return i;
                }
            }
        }
        return INVALID_INDEX;
    }

    private void register() {
        mLocalBluetoothManager.getEventManager().registerCallback(this);
        mAudioManager.registerAudioDeviceCallback(mAudioManagerAudioDeviceCallback, mHandler);
        mMediaRouter.addCallback(ROUTE_TYPE_REMOTE_DISPLAY, mMediaRouterCallback);

        // Register for misc other intent broadcasts.
        IntentFilter intentFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG);
        intentFilter.addAction(STREAM_DEVICES_CHANGED_ACTION);
        mContext.registerReceiver(mReceiver, intentFilter);
    }

    private void unregister() {
        mLocalBluetoothManager.getEventManager().unregisterCallback(this);
        mAudioManager.unregisterAudioDeviceCallback(mAudioManagerAudioDeviceCallback);
        mMediaRouter.removeCallback(mMediaRouterCallback);
        mContext.unregisterReceiver(mReceiver);
    }

    /** Callback for headset plugged and unplugged events. */
    private class AudioManagerAudioDeviceCallback extends AudioDeviceCallback {
        @Override
        public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
            updateState(mPreference);
        }

        @Override
        public void onAudioDevicesRemoved(AudioDeviceInfo[] devices) {
            updateState(mPreference);
        }
    }

    /** Receiver for wired headset plugged and unplugged events. */
    private class WiredHeadsetBroadcastReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            final String action = intent.getAction();
            if (AudioManager.ACTION_HEADSET_PLUG.equals(action) ||
                    AudioManager.STREAM_DEVICES_CHANGED_ACTION.equals(action)) {
                updateState(mPreference);
            }
        }
    }

    /** Callback for cast device events. */
    private class MediaRouterCallback extends Callback {
        @Override
        public void onRouteSelected(MediaRouter router, int type, MediaRouter.RouteInfo info) {
        }

        @Override
        public void onRouteUnselected(MediaRouter router, int type, MediaRouter.RouteInfo info) {
        }

        @Override
        public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo info) {
            if (info != null && !info.isDefault()) {
                // cast mode
                updateState(mPreference);
            }
        }

        @Override
        public void onRouteRemoved(MediaRouter router, MediaRouter.RouteInfo info) {
        }

        @Override
        public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo info) {
            if (info != null && !info.isDefault()) {
                // cast mode
                updateState(mPreference);
            }
        }

        @Override
        public void onRouteGrouped(MediaRouter router, MediaRouter.RouteInfo info,
                MediaRouter.RouteGroup group, int index) {
        }

        @Override
        public void onRouteUngrouped(MediaRouter router, MediaRouter.RouteInfo info,
                MediaRouter.RouteGroup group) {
        }

        @Override
        public void onRouteVolumeChanged(MediaRouter router, MediaRouter.RouteInfo info) {
        }
    }
}
+94 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settings.sound;

import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.support.v7.preference.Preference;

import com.android.internal.util.ArrayUtils;
import com.android.settings.R;
import com.android.settingslib.bluetooth.HeadsetProfile;

/**
 * This class allows switching between HFP-connected BT devices
 * while in on-call state.
 */
public class HandsFreeProfileOutputPreferenceController extends
        AudioSwitchPreferenceController {

    public HandsFreeProfileOutputPreferenceController(Context context, String key) {
        super(context, key);
    }

    @Override
    public void updateState(Preference preference) {
        if (preference == null) {
            // In case UI is not ready.
            return;
        }

        if (!isOngoingCallStatus()) {
            // Without phone call, disable the switch entry.
            preference.setEnabled(false);
            preference.setSummary(mContext.getText(R.string.media_output_default_summary));
            return;
        }

        // Ongoing call status, list all the connected devices support hands free profile.
        // Select current active device.
        // Disable switch entry if there is no connected device.
        mConnectedDevices = null;
        BluetoothDevice activeDevice = null;

        final HeadsetProfile headsetProfile = mProfileManager.getHeadsetProfile();
        if (headsetProfile != null) {
            mConnectedDevices = headsetProfile.getConnectedDevices();
            activeDevice = headsetProfile.getActiveDevice();
        }

        final int numDevices = ArrayUtils.size(mConnectedDevices);
        if (numDevices == 0) {
            // No connected devices, disable switch entry.
            preference.setEnabled(false);
            preference.setSummary(mContext.getText(R.string.media_output_default_summary));
            return;
        }

        preference.setEnabled(true);
        CharSequence[] mediaOutputs = new CharSequence[numDevices + 1];
        CharSequence[] mediaValues = new CharSequence[numDevices + 1];

        // Setup devices entries, select active connected device
        setupPreferenceEntries(mediaOutputs, mediaValues, activeDevice);

        if (mAudioManager.isWiredHeadsetOn() && !mAudioManager.isBluetoothScoOn()) {
            // If wired headset is plugged in and active, select to default device.
            mSelectedIndex = getDefaultDeviceIndex();
        }

        // Display connected devices, default device and show the active device
        setPreference(mediaOutputs, mediaValues, preference);
    }

    @Override
    public void setActiveBluetoothDevice(BluetoothDevice device) {
        if (isOngoingCallStatus()) {
            mProfileManager.getHeadsetProfile().setActiveDevice(device);
        }
    }
}
+116 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.settings.sound;

import static android.media.MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY;

import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaRouter;
import android.support.v7.preference.Preference;

import com.android.internal.util.ArrayUtils;
import com.android.settings.R;
import com.android.settingslib.bluetooth.A2dpProfile;


/**
 * This class which allows switching between a2dp-connected BT devices.
 * A few conditions will disable this switcher:
 * - No available BT device(s)
 * - Media stream captured by cast device
 * - During a call.
 */
public class MediaOutputPreferenceController extends AudioSwitchPreferenceController {

    public MediaOutputPreferenceController(Context context, String key) {
        super(context, key);
    }

    @Override
    public void updateState(Preference preference) {
        if (preference == null) {
            // In case UI is not ready.
            return;
        }

        if (mAudioManager.isMusicActiveRemotely() || isCastDevice(mMediaRouter)) {
            // TODO(76455906): Workaround for cast mode, need a solid way to identify cast mode.
            // In cast mode, disable switch entry.
            preference.setEnabled(false);
            preference.setSummary(mContext.getText(R.string.media_output_summary_unavailable));
            return;
        }

        if (isOngoingCallStatus()) {
            // Ongoing call status, switch entry for media will be disabled.
            preference.setEnabled(false);
            preference.setSummary(
                    mContext.getText(R.string.media_out_summary_ongoing_call_state));
            return;
        }

        // Otherwise, list all of the A2DP connected device and display the active device.
        mConnectedDevices = null;
        BluetoothDevice activeDevice = null;
        if (mAudioManager.getMode() == AudioManager.MODE_NORMAL) {
            final A2dpProfile a2dpProfile = mProfileManager.getA2dpProfile();
            if (a2dpProfile != null) {
                mConnectedDevices = a2dpProfile.getConnectedDevices();
                activeDevice = a2dpProfile.getActiveDevice();
            }
        }

        final int numDevices = ArrayUtils.size(mConnectedDevices);
        if (numDevices == 0) {
            // Disable switch entry if there is no connected devices.
            preference.setEnabled(false);
            preference.setSummary(mContext.getText(R.string.media_output_default_summary));
            return;
        }

        preference.setEnabled(true);
        CharSequence[] mediaOutputs = new CharSequence[numDevices + 1];
        CharSequence[] mediaValues = new CharSequence[numDevices + 1];

        // Setup devices entries, select active connected device
        setupPreferenceEntries(mediaOutputs, mediaValues, activeDevice);

        if (mAudioManager.isWiredHeadsetOn() && !mAudioManager.isBluetoothA2dpOn()) {
            // If wired headset is plugged in and active, select to default device.
            mSelectedIndex = getDefaultDeviceIndex();
        }

        // Display connected devices, default device and show the active device
        setPreference(mediaOutputs, mediaValues, preference);
    }

    @Override
    public void setActiveBluetoothDevice(BluetoothDevice device) {
        if (mAudioManager.getMode() == AudioManager.MODE_NORMAL) {
            mProfileManager.getA2dpProfile().setActiveDevice(device);
        }
    }

    private static boolean isCastDevice(MediaRouter mediaRouter) {
        final MediaRouter.RouteInfo selected = mediaRouter.getSelectedRoute(
                ROUTE_TYPE_REMOTE_DISPLAY);
        return selected != null && selected.getPresentationDisplay() != null
                && selected.getPresentationDisplay().isValid();
    }
}
Loading