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

Commit 25c69949 authored by Jean-Michel Trivi's avatar Jean-Michel Trivi
Browse files

AudioService: persist/restore audio device spatial audio settings

In SpatializerHelper, add support for (un)marshalling SADeviceState
(class that stores per device spatial audio settings) to/from a
String.
Restore device settings right before SpatializerHelper is
initialized. Save device settings whenever they change.
Add new test class for testing the SADeviceState marshalling
functionality, as well as compatible device list management.

Bug: 210803914
Test: atest SpatializerHelperTest
Change-Id: I94e5a32920e26dc561ede1fec4372b4a3da7f4b7
parent eccd6122
Loading
Loading
Loading
Loading
+45 −4
Original line number Diff line number Diff line
@@ -341,8 +341,7 @@ public class AudioService extends IAudioService.Stub
    private static final int MSG_DISPATCH_AUDIO_MODE = 40;
    private static final int MSG_ROUTING_UPDATED = 41;
    private static final int MSG_INIT_HEADTRACKING_SENSORS = 42;
    // commented out for now, will be reused for other SA persisting
    //private static final int MSG_PERSIST_SPATIAL_AUDIO_ENABLED = 43;
    private static final int MSG_PERSIST_SPATIAL_AUDIO_DEVICE_SETTINGS = 43;
    private static final int MSG_ADD_ASSISTANT_SERVICE_UID = 44;
    private static final int MSG_REMOVE_ASSISTANT_SERVICE_UID = 45;
    private static final int MSG_UPDATE_ACTIVE_ASSISTANT_SERVICE_UID = 46;
@@ -8122,8 +8121,7 @@ public class AudioService extends IAudioService.Stub
                    break;

                case MSG_INIT_SPATIALIZER:
                    mSpatializerHelper.init(/*effectExpected*/ mHasSpatializerEffect);
                    mSpatializerHelper.setFeatureEnabled(mHasSpatializerEffect);
                    onInitSpatializer();
                    mAudioEventWakeLock.release();
                    break;

@@ -8131,6 +8129,10 @@ public class AudioService extends IAudioService.Stub
                    mSpatializerHelper.onInitSensors();
                    break;

                case MSG_PERSIST_SPATIAL_AUDIO_DEVICE_SETTINGS:
                    onPersistSpatialAudioDeviceSettings();
                    break;

                case MSG_CHECK_MUSIC_ACTIVE:
                    onCheckMusicActive((String) msg.obj);
                    break;
@@ -9099,6 +9101,45 @@ public class AudioService extends IAudioService.Stub
                /*arg1*/ 0, /*arg2*/ 0, TAG, /*delay*/ 0);
    }

    void onInitSpatializer() {
        final String settings = mSettings.getSecureStringForUser(mContentResolver,
                Settings.Secure.SPATIAL_AUDIO_ENABLED, UserHandle.USER_CURRENT);
        if (settings == null) {
            Log.e(TAG, "error reading spatial audio device settings");
        } else {
            Log.v(TAG, "restoring spatial audio device settings: " + settings);
            mSpatializerHelper.setSADeviceSettings(settings);
        }
        mSpatializerHelper.init(/*effectExpected*/ mHasSpatializerEffect);
        mSpatializerHelper.setFeatureEnabled(mHasSpatializerEffect);
    }

    /**
     * post a message to persist the spatial audio device settings.
     * Message is delayed by 1s on purpose in case of successive changes in quick succession (at
     * init time for instance)
     * Note this method is made public to work around a Mockito bug where it needs to be public
     * in order to be mocked by a test a the same package
     * (see https://code.google.com/archive/p/mockito/issues/127)
     */
    public void persistSpatialAudioDeviceSettings() {
        sendMsg(mAudioHandler,
                MSG_PERSIST_SPATIAL_AUDIO_DEVICE_SETTINGS,
                SENDMSG_REPLACE, /*arg1*/ 0, /*arg2*/ 0, TAG,
                /*delay*/ 1000);
    }

    void onPersistSpatialAudioDeviceSettings() {
        final String settings = mSpatializerHelper.getSADeviceSettings();
        Log.v(TAG, "saving spatial audio device settings: " + settings);
        boolean res = mSettings.putSecureStringForUser(mContentResolver,
                Settings.Secure.SPATIAL_AUDIO_ENABLED,
                settings, UserHandle.USER_CURRENT);
        if (!res) {
            Log.e(TAG, "error saving spatial audio device settings: " + settings);
        }
    }

    //==========================================================================================
    private boolean readCameraSoundForced() {
        return SystemProperties.getBoolean("audio.camerasound.force", false) ||
+111 −10
Original line number Diff line number Diff line
@@ -40,6 +40,7 @@ import android.media.Spatializer;
import android.media.SpatializerHeadTrackingMode;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import android.util.SparseIntArray;
@@ -48,6 +49,7 @@ import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;

/**
@@ -275,18 +277,17 @@ public class SpatializerHelper {
            // for both transaural / binaural, we are not forcing enablement as the init() method
            // could have been called another time after boot in case of audioserver restart
            if (mTransauralSupported) {
                // TODO deal with persisted values
                // not force-enabling as this device might already be in the device list
                addCompatibleAudioDevice(
                        new AudioDeviceAttributes(AudioSystem.DEVICE_OUT_SPEAKER, ""),
                                false /*forceEnable*/);
            }
            if (mBinauralSupported) {
                // TODO deal with persisted values
                // not force-enabling as this device might already be in the device list
                addCompatibleAudioDevice(
                        new AudioDeviceAttributes(AudioSystem.DEVICE_OUT_WIRED_HEADPHONE, ""),
                                false /*forceEnable*/);
            }
            // TODO read persisted states
        } catch (RemoteException e) {
            resetCapabilities();
        } finally {
@@ -533,11 +534,12 @@ public class SpatializerHelper {
        }
        if (!isInList) {
            final SADeviceState dev = new SADeviceState(deviceType,
                    wireless ? ada.getAddress() : null);
                    wireless ? ada.getAddress() : "");
            dev.mEnabled = true;
            mSADevices.add(dev);
        }
        onRoutingUpdated();
        mAudioService.persistSpatialAudioDeviceSettings();
    }

    synchronized void removeCompatibleAudioDevice(@NonNull AudioDeviceAttributes ada) {
@@ -553,6 +555,7 @@ public class SpatializerHelper {
            }
        }
        onRoutingUpdated();
        mAudioService.persistSpatialAudioDeviceSettings();
    }

    /**
@@ -625,7 +628,7 @@ public class SpatializerHelper {
        }
        if (!knownDevice) {
            mSADevices.add(new SADeviceState(ada.getType(), ada.getAddress()));
            //### TODO persist list
            mAudioService.persistSpatialAudioDeviceSettings();
        }
    }

@@ -1059,6 +1062,7 @@ public class SpatializerHelper {
                }
                Log.i(TAG, "setHeadTrackerEnabled enabled:" + enabled + " device:" + ada);
                deviceState.mHeadTrackerEnabled = enabled;
                mAudioService.persistSpatialAudioDeviceSettings();
                break;
            }
        }
@@ -1097,7 +1101,10 @@ public class SpatializerHelper {
            if (deviceType == deviceState.mDeviceType
                    && (wireless && ada.getAddress().equals(deviceState.mDeviceAddress))
                    || !wireless) {
                if (!deviceState.mHasHeadTracker) {
                    deviceState.mHasHeadTracker = true;
                    mAudioService.persistSpatialAudioDeviceSettings();
                }
                return deviceState.mHeadTrackerEnabled;
            }
        }
@@ -1457,16 +1464,45 @@ public class SpatializerHelper {
        }
    }

    private static final class SADeviceState {
    /*package*/ static final class SADeviceState {
        final @AudioDeviceInfo.AudioDeviceType int mDeviceType;
        final @Nullable String mDeviceAddress; // non-null for wireless devices
        final @NonNull String mDeviceAddress;
        boolean mEnabled = true;               // by default, SA is enabled on any device
        boolean mHasHeadTracker = false;
        boolean mHeadTrackerEnabled = true;    // by default, if head tracker is present, use it
        static final String SETTING_FIELD_SEPARATOR = ",";
        static final String SETTING_DEVICE_SEPARATOR_CHAR = "|";
        static final String SETTING_DEVICE_SEPARATOR = "\\|";

        SADeviceState(@AudioDeviceInfo.AudioDeviceType int deviceType, @Nullable String address) {
        SADeviceState(@AudioDeviceInfo.AudioDeviceType int deviceType, @NonNull String address) {
            mDeviceType = deviceType;
            mDeviceAddress = address;
            mDeviceAddress = Objects.requireNonNull(address);
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null) {
                return false;
            }
            // type check and cast
            if (getClass() != obj.getClass()) {
                return false;
            }
            final SADeviceState sads = (SADeviceState) obj;
            return mDeviceType == sads.mDeviceType
                    && mDeviceAddress.equals(sads.mDeviceAddress)
                    && mEnabled == sads.mEnabled
                    && mHasHeadTracker == sads.mHasHeadTracker
                    && mHeadTrackerEnabled == sads.mHeadTrackerEnabled;
        }

        @Override
        public int hashCode() {
            return Objects.hash(mDeviceType, mDeviceAddress, mEnabled, mHasHeadTracker,
                    mHeadTrackerEnabled);
        }

        @Override
@@ -1474,6 +1510,64 @@ public class SpatializerHelper {
            return "type:" + mDeviceType + " addr:" + mDeviceAddress + " enabled:" + mEnabled
                    + " HT:" + mHasHeadTracker + " HTenabled:" + mHeadTrackerEnabled;
        }

        String toPersistableString() {
            return (new StringBuilder().append(mDeviceType)
                    .append(SETTING_FIELD_SEPARATOR).append(mDeviceAddress)
                    .append(SETTING_FIELD_SEPARATOR).append(mEnabled ? "1" : "0")
                    .append(SETTING_FIELD_SEPARATOR).append(mHasHeadTracker ? "1" : "0")
                    .append(SETTING_FIELD_SEPARATOR).append(mHeadTrackerEnabled ? "1" : "0")
                    .toString());
        }

        static @Nullable SADeviceState fromPersistedString(@Nullable String persistedString) {
            if (persistedString == null) {
                return null;
            }
            if (persistedString.isEmpty()) {
                return null;
            }
            String[] fields = TextUtils.split(persistedString, SETTING_FIELD_SEPARATOR);
            if (fields.length != 5) {
                // expecting all fields, fewer may mean corruption, ignore those settings
                return null;
            }
            try {
                final int deviceType = Integer.parseInt(fields[0]);
                final SADeviceState deviceState = new SADeviceState(deviceType, fields[1]);
                deviceState.mEnabled = Integer.parseInt(fields[2]) == 1;
                deviceState.mHasHeadTracker = Integer.parseInt(fields[3]) == 1;
                deviceState.mHeadTrackerEnabled = Integer.parseInt(fields[4]) == 1;
                return deviceState;
            } catch (NumberFormatException e) {
                Log.e(TAG, "unable to parse setting for SADeviceState: " + persistedString, e);
                return null;
            }
        }
    }

    /*package*/ synchronized String getSADeviceSettings() {
        // expected max size of each String for each SADeviceState is 25 (accounting for separator)
        final StringBuilder settingsBuilder = new StringBuilder(mSADevices.size() * 25);
        for (int i = 0; i < mSADevices.size(); i++) {
            settingsBuilder.append(mSADevices.get(i).toPersistableString());
            if (i != mSADevices.size() - 1) {
                settingsBuilder.append(SADeviceState.SETTING_DEVICE_SEPARATOR_CHAR);
            }
        }
        return settingsBuilder.toString();
    }

    /*package*/ synchronized void setSADeviceSettings(@NonNull String persistedSettings) {
        String[] devSettings = TextUtils.split(Objects.requireNonNull(persistedSettings),
                SADeviceState.SETTING_DEVICE_SEPARATOR);
        // small list, not worth overhead of Arrays.stream(devSettings)
        for (String setting : devSettings) {
            SADeviceState devState = SADeviceState.fromPersistedString(setting);
            if (devState != null) {
                mSADevices.add(devState);
            }
        }
    }

    private static String spatStateString(int state) {
@@ -1557,4 +1651,11 @@ public class SpatializerHelper {
        AudioService.sSpatialLogger.loglog(msg, AudioEventLogger.Event.ALOGE, TAG);
        return msg;
    }

    //------------------------------------------------
    // for testing purposes only

    /*package*/ void clearSADevices() {
        mSADevices.clear();
    }
}
+137 −0
Original line number Diff line number Diff line
/*
 * Copyright 2022 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.audio;

import com.android.server.audio.SpatializerHelper.SADeviceState;

import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;

import android.media.AudioDeviceAttributes;
import android.media.AudioDeviceInfo;
import android.media.AudioSystem;
import android.util.Log;

import androidx.test.filters.MediumTest;
import androidx.test.runner.AndroidJUnit4;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Spy;

import java.util.List;

@MediumTest
@RunWith(AndroidJUnit4.class)
public class SpatializerHelperTest {

    private static final String TAG = "SpatializerHelperTest";

    // the actual class under test
    private SpatializerHelper mSpatHelper;

    @Mock private AudioService mMockAudioService;
    @Spy private AudioSystemAdapter mSpyAudioSystem;

    @Before
    public void setUp() throws Exception {
        mMockAudioService = mock(AudioService.class);
        mSpyAudioSystem = spy(new NoOpAudioSystemAdapter());

        mSpatHelper = new SpatializerHelper(mMockAudioService, mSpyAudioSystem);
    }

    @Test
    public void testSADeviceStateNullAddressCtor() throws Exception {
        try {
            SADeviceState devState = new SADeviceState(
                    AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, null);
            Assert.fail();
        } catch (NullPointerException e) { }
    }

    @Test
    public void testSADeviceStateStringSerialization() throws Exception {
        Log.i(TAG, "starting testSADeviceStateStringSerialization");
        final SADeviceState devState = new SADeviceState(
                AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, "bla");
        devState.mHasHeadTracker = false;
        devState.mHeadTrackerEnabled = false;
        devState.mEnabled = true;
        final String persistString = devState.toPersistableString();
        final SADeviceState result = SADeviceState.fromPersistedString(persistString);
        Log.i(TAG, "original:" + devState);
        Log.i(TAG, "result  :" + result);
        Assert.assertEquals(devState, result);
    }

    @Test
    public void testSADeviceSettings() throws Exception {
        Log.i(TAG, "starting testSADeviceSettings");
        final AudioDeviceAttributes dev1 =
                new AudioDeviceAttributes(AudioSystem.DEVICE_OUT_SPEAKER, "");
        final AudioDeviceAttributes dev2 =
                new AudioDeviceAttributes(AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP, "C3:P0:beep");
        final AudioDeviceAttributes dev3 =
                new AudioDeviceAttributes(AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP, "R2:D2:bloop");

        doNothing().when(mMockAudioService).persistSpatialAudioDeviceSettings();

        // test with single device
        mSpatHelper.addCompatibleAudioDevice(dev1);
        checkAddSettings();

        // test with 2+ devices so separator character is used in list
        mSpatHelper.addCompatibleAudioDevice(dev2);
        Assert.assertTrue(mSpatHelper.isAvailableForDevice(dev2));
        checkAddSettings();
        Assert.assertTrue(mSpatHelper.isAvailableForDevice(dev2));
        mSpatHelper.addCompatibleAudioDevice(dev3);
        checkAddSettings();

        // test adding a device twice in the list
        mSpatHelper.addCompatibleAudioDevice(dev1);
        checkAddSettings();

        // test removing a device
        mSpatHelper.removeCompatibleAudioDevice(dev2);
        // spatializer could still be run for dev2 (is available) but spatial audio
        // is disabled for dev2 by removeCompatibleAudioDevice
        Assert.assertTrue(mSpatHelper.isAvailableForDevice(dev2));
        List<AudioDeviceAttributes> compatDevices = mSpatHelper.getCompatibleAudioDevices();
        Assert.assertFalse(compatDevices.stream().anyMatch(dev -> dev.equalTypeAddress(dev2)));
        checkAddSettings();
    }

    /**
     * Gets the string representing the current configuration of the devices, then clears it
     * and restores the configuration. Verify the new string from the restored settings matches
     * the original one.
     */
    private void checkAddSettings() throws Exception {
        String settings = mSpatHelper.getSADeviceSettings();
        Log.i(TAG, "device settings: " + settings);
        mSpatHelper.clearSADevices();
        mSpatHelper.setSADeviceSettings(settings);
        String settingsRestored = mSpatHelper.getSADeviceSettings();
        Log.i(TAG, "device settingsRestored: " + settingsRestored);
        Assert.assertEquals(settings, settingsRestored);
    }
}