diff --git a/services/core/java/com/android/server/audio/AudioService.java b/services/core/java/com/android/server/audio/AudioService.java index 0123c646c7640..f0fe2ddd03952 100644 --- a/services/core/java/com/android/server/audio/AudioService.java +++ b/services/core/java/com/android/server/audio/AudioService.java @@ -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) || diff --git a/services/core/java/com/android/server/audio/SpatializerHelper.java b/services/core/java/com/android/server/audio/SpatializerHelper.java index 0b9cb19776438..8b8577c8b339a 100644 --- a/services/core/java/com/android/server/audio/SpatializerHelper.java +++ b/services/core/java/com/android/server/audio/SpatializerHelper.java @@ -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) { - deviceState.mHasHeadTracker = true; + 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(); + } } diff --git a/services/tests/servicestests/src/com/android/server/audio/SpatializerHelperTest.java b/services/tests/servicestests/src/com/android/server/audio/SpatializerHelperTest.java new file mode 100644 index 0000000000000..b17c3a18d89e2 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/audio/SpatializerHelperTest.java @@ -0,0 +1,137 @@ +/* + * 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 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); + } +}