Merge "Adding support for IAudioControl@2.0" into rvc-dev
diff --git a/service/Android.bp b/service/Android.bp
index ab6e4ce..50f0f41 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -34,6 +34,7 @@
     "android.car.watchdoglib",
     "android.hidl.base-V1.0-java",
     "android.hardware.automotive.audiocontrol-V1.0-java",
+    "android.hardware.automotive.audiocontrol-V2.0-java",
     "android.hardware.automotive.vehicle-V2.0-java",
     "android.hardware.health-V1.0-java",
     "android.hardware.health-V2.0-java",
diff --git a/service/src/com/android/car/audio/AudioControlWrapper.java b/service/src/com/android/car/audio/AudioControlWrapper.java
new file mode 100644
index 0000000..e23140b
--- /dev/null
+++ b/service/src/com/android/car/audio/AudioControlWrapper.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2020 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.car.audio;
+
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.car.audio.CarAudioContext.AudioContext;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.Preconditions;
+
+import java.util.NoSuchElementException;
+
+/**
+ * AudioControlWrapper wraps IAudioControl HAL interface, handling version specific support so that
+ * the rest of CarAudioService doesn't need to know about it.
+ */
+final class AudioControlWrapper {
+    private static final String TAG = AudioControlWrapper.class.getSimpleName();
+    @Nullable
+    private final android.hardware.automotive.audiocontrol.V1_0.IAudioControl mAudioControlV1;
+    @Nullable
+    private final android.hardware.automotive.audiocontrol.V2_0.IAudioControl mAudioControlV2;
+
+    static AudioControlWrapper newAudioControl() {
+        android.hardware.automotive.audiocontrol.V1_0.IAudioControl audioControlV1 = null;
+        android.hardware.automotive.audiocontrol.V2_0.IAudioControl audioControlV2 = null;
+        try {
+            audioControlV2 = android.hardware.automotive.audiocontrol.V2_0.IAudioControl
+                    .getService(true);
+        } catch (RemoteException e) {
+            throw new IllegalStateException("Failed to get IAudioControl V2 service", e);
+        } catch (NoSuchElementException e) {
+            Log.d(TAG, "IAudioControl@V2.0 not in the manifest");
+        }
+
+        try {
+            audioControlV1 = android.hardware.automotive.audiocontrol.V1_0.IAudioControl
+                    .getService(true);
+        } catch (RemoteException e) {
+            throw new IllegalStateException("Failed to get IAudioControl V1 service", e);
+        } catch (NoSuchElementException e) {
+            Log.d(TAG, "IAudioControl@V1.0 not in the manifest");
+        }
+
+        return new AudioControlWrapper(audioControlV1, audioControlV2);
+    }
+
+    @VisibleForTesting
+    AudioControlWrapper(
+            @Nullable android.hardware.automotive.audiocontrol.V1_0.IAudioControl audioControlV1,
+            @Nullable android.hardware.automotive.audiocontrol.V2_0.IAudioControl audioControlV2) {
+        mAudioControlV1 = audioControlV1;
+        mAudioControlV2 = audioControlV2;
+        checkAudioControl();
+    }
+
+    private void checkAudioControl() {
+        if (mAudioControlV2 != null && mAudioControlV1 != null) {
+            Log.w(TAG, "Both versions of IAudioControl are present, defaulting to V2");
+        } else if (mAudioControlV2 == null && mAudioControlV1 == null) {
+            throw new IllegalStateException("No version of AudioControl HAL in the manifest");
+        } else if (mAudioControlV1 != null) {
+            Log.w(TAG, "IAudioControl@V1.0 is deprecated. Consider upgrading to V2.0");
+        }
+    }
+
+    void setFadeTowardFront(float value) {
+        try {
+            if (mAudioControlV2 != null) {
+                mAudioControlV2.setFadeTowardFront(value);
+            } else {
+                mAudioControlV1.setFadeTowardFront(value);
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG, "setFadeTowardFront failed", e);
+        }
+    }
+
+    void setBalanceTowardRight(float value) {
+        try {
+            if (mAudioControlV2 != null) {
+                mAudioControlV2.setBalanceTowardRight(value);
+            } else {
+                mAudioControlV1.setBalanceTowardRight(value);
+            }
+        } catch (RemoteException e) {
+            Log.e(TAG, "setBalanceTowardRight failed", e);
+        }
+    }
+
+    /**
+     * Gets the bus associated with CarAudioContext
+     *
+     * <p>This API is used along with car_volume_groups.xml to configure volume groups and routing.
+     *
+     * @param audioContext CarAudioContext to get a context for
+     * @return int bus number. Should be part of the prefix for the device's address. For example,
+     * bus001_media would be bus 1.
+     * @deprecated Volume and routing configuration has been replaced by
+     * car_audio_configuration.xml. Starting with IAudioControl@V2.0, getBusForContext is no longer
+     * supported.
+     */
+    @Deprecated
+    int getBusForContext(@AudioContext int audioContext) {
+        Preconditions.checkState(mAudioControlV2 == null,
+                "IAudioControl#getBusForContext no longer supported beyond V1.0");
+
+        try {
+            return mAudioControlV1.getBusForContext(audioContext);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to query IAudioControl HAL to get bus for context", e);
+            throw new IllegalStateException("Failed to query IAudioControl#getBusForContext", e);
+        }
+    }
+}
diff --git a/service/src/com/android/car/audio/CarAudioService.java b/service/src/com/android/car/audio/CarAudioService.java
index 807d711..5733f6d 100644
--- a/service/src/com/android/car/audio/CarAudioService.java
+++ b/service/src/com/android/car/audio/CarAudioService.java
@@ -30,8 +30,6 @@
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.pm.PackageManager;
-import android.hardware.automotive.audiocontrol.V1_0.ContextNumber;
-import android.hardware.automotive.audiocontrol.V1_0.IAudioControl;
 import android.media.AudioAttributes;
 import android.media.AudioAttributes.AttributeSystemUsage;
 import android.media.AudioAttributes.AttributeUsage;
@@ -50,7 +48,6 @@
 import android.media.audiopolicy.AudioPolicy;
 import android.os.IBinder;
 import android.os.Looper;
-import android.os.RemoteException;
 import android.os.UserHandle;
 import android.telephony.TelephonyManager;
 import android.text.TextUtils;
@@ -79,7 +76,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.NoSuchElementException;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -123,6 +119,7 @@
     private final boolean mUseDynamicRouting;
     private final boolean mPersistMasterMuteState;
     private final CarVolumeSettings mCarVolumeSettings;
+    private AudioControlWrapper mAudioControlWrapper;
 
     private CarOccupantZoneService mOccupantZoneService;
 
@@ -233,7 +230,7 @@
             Car car = new Car(mContext, /* service= */null, /* handler= */ null);
             mOccupantZoneManager = new CarOccupantZoneManager(car, mOccupantZoneService);
             if (mUseDynamicRouting) {
-                setupDynamicRouting();
+                setupDynamicRoutingLocked();
             } else {
                 Log.i(CarLog.TAG_AUDIO, "Audio dynamic routing not enabled, run in legacy mode");
                 setupLegacyVolumeChangedListener();
@@ -438,7 +435,8 @@
                 AudioManager.GET_DEVICES_INPUTS);
     }
 
-    private CarAudioZone[] loadCarAudioConfiguration(List<CarAudioDeviceInfo> carAudioDeviceInfos) {
+    private CarAudioZone[] loadCarAudioConfigurationLocked(
+            List<CarAudioDeviceInfo> carAudioDeviceInfos) {
         AudioDeviceInfo[] inputDevices = getAllInputDevices();
         try (InputStream inputStream = new FileInputStream(mCarAudioConfigurationPath)) {
             CarAudioZonesHelper zonesHelper = new CarAudioZonesHelper(mContext, inputStream,
@@ -451,37 +449,33 @@
         }
     }
 
-    private CarAudioZone[] loadVolumeGroupConfigurationWithAudioControl(
+    private CarAudioZone[] loadVolumeGroupConfigurationWithAudioControlLocked(
             List<CarAudioDeviceInfo> carAudioDeviceInfos) {
-        // In legacy mode, context -> bus mapping is done by querying IAudioControl HAL.
-        final IAudioControl audioControl = getAudioControl();
-        if (audioControl == null) {
-            throw new RuntimeException(
-                    "Dynamic routing requested but audioControl HAL not available");
-        }
+        AudioControlWrapper audioControlWrapper = getAudioControlWrapperLocked();
         CarAudioZonesHelperLegacy legacyHelper = new CarAudioZonesHelperLegacy(mContext,
-                R.xml.car_volume_groups, carAudioDeviceInfos, audioControl);
+                R.xml.car_volume_groups, carAudioDeviceInfos, audioControlWrapper);
         return legacyHelper.loadAudioZones();
     }
 
-    private void loadCarAudioZones() {
+    private void loadCarAudioZonesLocked() {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = generateCarAudioDeviceInfos();
 
         mCarAudioConfigurationPath = getAudioConfigurationPath();
         if (mCarAudioConfigurationPath != null) {
-            mCarAudioZones = loadCarAudioConfiguration(carAudioDeviceInfos);
+            mCarAudioZones = loadCarAudioConfigurationLocked(carAudioDeviceInfos);
         } else {
-            mCarAudioZones = loadVolumeGroupConfigurationWithAudioControl(carAudioDeviceInfos);
+            mCarAudioZones = loadVolumeGroupConfigurationWithAudioControlLocked(
+                    carAudioDeviceInfos);
         }
 
         CarAudioZonesValidator.validate(mCarAudioZones);
     }
 
-    private void setupDynamicRouting() {
+    private void setupDynamicRoutingLocked() {
         final AudioPolicy.Builder builder = new AudioPolicy.Builder(mContext);
         builder.setLooper(Looper.getMainLooper());
 
-        loadCarAudioZones();
+        loadCarAudioZonesLocked();
 
         for (CarAudioZone zone : mCarAudioZones) {
             // Ensure HAL gets our initial value
@@ -555,14 +549,7 @@
     public void setFadeTowardFront(float value) {
         synchronized (mImplLock) {
             enforcePermission(Car.PERMISSION_CAR_CONTROL_AUDIO_VOLUME);
-            final IAudioControl audioControlHal = getAudioControl();
-            if (audioControlHal != null) {
-                try {
-                    audioControlHal.setFadeTowardFront(value);
-                } catch (RemoteException e) {
-                    Log.e(CarLog.TAG_AUDIO, "setFadeTowardFront failed", e);
-                }
-            }
+            getAudioControlWrapperLocked().setFadeTowardFront(value);
         }
     }
 
@@ -570,14 +557,7 @@
     public void setBalanceTowardRight(float value) {
         synchronized (mImplLock) {
             enforcePermission(Car.PERMISSION_CAR_CONTROL_AUDIO_VOLUME);
-            final IAudioControl audioControlHal = getAudioControl();
-            if (audioControlHal != null) {
-                try {
-                    audioControlHal.setBalanceTowardRight(value);
-                } catch (RemoteException e) {
-                    Log.e(CarLog.TAG_AUDIO, "setBalanceTowardRight failed", e);
-                }
-            }
+            getAudioControlWrapperLocked().setBalanceTowardRight(value);
         }
     }
 
@@ -904,7 +884,7 @@
         Preconditions.checkArgumentInRange(zoneId, 0, mCarAudioZones.length - 1,
                 "zoneId (" + zoneId + ")");
         int contextForUsage = CarAudioContext.getContextForUsage(usage);
-        Preconditions.checkArgument(contextForUsage != ContextNumber.INVALID,
+        Preconditions.checkArgument(contextForUsage != CarAudioContext.INVALID,
                 "Invalid audio attribute usage %d", usage);
         return mCarAudioZones[zoneId].getAddressForContext(contextForUsage);
     }
@@ -1172,16 +1152,11 @@
         return mAudioZoneIdToUserIdMapping.get(audioZoneId, UserHandle.USER_NULL);
     }
 
-    @Nullable
-    private static IAudioControl getAudioControl() {
-        try {
-            return IAudioControl.getService();
-        } catch (RemoteException e) {
-            Log.e(CarLog.TAG_AUDIO, "Failed to get IAudioControl service", e);
-        } catch (NoSuchElementException e) {
-            Log.e(CarLog.TAG_AUDIO, "IAudioControl service not registered yet");
+    private AudioControlWrapper getAudioControlWrapperLocked() {
+        if (mAudioControlWrapper == null) {
+            mAudioControlWrapper = AudioControlWrapper.newAudioControl();
         }
-        return null;
+        return mAudioControlWrapper;
     }
 
     boolean isAudioZoneIdValid(int zoneId) {
diff --git a/service/src/com/android/car/audio/CarAudioZonesHelperLegacy.java b/service/src/com/android/car/audio/CarAudioZonesHelperLegacy.java
index 947a886..23c11bf 100644
--- a/service/src/com/android/car/audio/CarAudioZonesHelperLegacy.java
+++ b/service/src/com/android/car/audio/CarAudioZonesHelperLegacy.java
@@ -23,8 +23,6 @@
 import android.content.Context;
 import android.content.res.TypedArray;
 import android.content.res.XmlResourceParser;
-import android.hardware.automotive.audiocontrol.V1_0.IAudioControl;
-import android.os.RemoteException;
 import android.util.AttributeSet;
 import android.util.Log;
 import android.util.SparseArray;
@@ -60,12 +58,12 @@
 
     CarAudioZonesHelperLegacy(Context context, @XmlRes int xmlConfiguration,
             @NonNull List<CarAudioDeviceInfo> carAudioDeviceInfos,
-            @NonNull IAudioControl audioControl) {
+            @NonNull AudioControlWrapper audioControlWrapper) {
         mContext = context;
         mXmlConfiguration = xmlConfiguration;
         mBusToCarAudioDeviceInfo = generateBusToCarAudioDeviceInfo(carAudioDeviceInfos);
 
-        mLegacyAudioContextToBus = loadBusesForLegacyContexts(audioControl);
+        mLegacyAudioContextToBus = loadBusesForLegacyContexts(audioControlWrapper);
     }
 
     /* Loads mapping from {@link CarAudioContext} values to bus numbers
@@ -74,23 +72,18 @@
      * contexts are those defined as part of
      * {@code android.hardware.automotive.audiocontrol.V1_0.ContextNumber}
      *
-     * @param audioControl handle for IAudioControl HAL to fetch bus numbers from
+     * @param audioControl wrapper for IAudioControl HAL interface.
      * @return SparseIntArray mapping from {@link CarAudioContext} to bus number.
      */
-    private static SparseIntArray loadBusesForLegacyContexts(@NonNull IAudioControl audioControl) {
+    private static SparseIntArray loadBusesForLegacyContexts(
+            @NonNull AudioControlWrapper audioControlWrapper) {
         SparseIntArray contextToBus = new SparseIntArray();
 
-        try {
-            for (int legacyContext : LEGACY_CONTEXTS) {
-                int bus = audioControl.getBusForContext(legacyContext);
-                validateBusNumber(legacyContext, bus);
-                contextToBus.put(legacyContext, bus);
-            }
-        } catch (RemoteException e) {
-            Log.e(CarLog.TAG_AUDIO, "Failed to query IAudioControl HAL", e);
-            e.rethrowAsRuntimeException();
+        for (int legacyContext : LEGACY_CONTEXTS) {
+            int bus = audioControlWrapper.getBusForContext(legacyContext);
+            validateBusNumber(legacyContext, bus);
+            contextToBus.put(legacyContext, bus);
         }
-
         return contextToBus;
     }
 
diff --git a/tests/carservice_test/src/com/android/car/audio/CarAudioZonesHelperLegacyTest.java b/tests/carservice_test/src/com/android/car/audio/CarAudioZonesHelperLegacyTest.java
index 4ff4c4b..9428681 100644
--- a/tests/carservice_test/src/com/android/car/audio/CarAudioZonesHelperLegacyTest.java
+++ b/tests/carservice_test/src/com/android/car/audio/CarAudioZonesHelperLegacyTest.java
@@ -23,7 +23,6 @@
 
 import android.annotation.XmlRes;
 import android.content.Context;
-import android.hardware.automotive.audiocontrol.V1_0.IAudioControl;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -50,7 +49,7 @@
     public final MockitoRule rule = MockitoJUnit.rule();
 
     @Mock
-    private IAudioControl mMockAudioControl;
+    private AudioControlWrapper mMockAudioControlWrapper;
 
     private static final int INVALID_BUS = -1;
     private final Context mContext = ApplicationProvider.getApplicationContext();
@@ -62,7 +61,7 @@
 
         RuntimeException exception = expectThrows(RuntimeException.class,
                 () -> new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                        carAudioDeviceInfos, mMockAudioControl));
+                        carAudioDeviceInfos, mMockAudioControlWrapper));
 
         assertThat(exception.getMessage()).contains("Two addresses map to same bus number:");
     }
@@ -71,11 +70,11 @@
     public void constructor_throwsIfLegacyContextNotAssignedToBus() throws Exception {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = getValidCarAudioDeviceInfos();
 
-        when(mMockAudioControl.getBusForContext(anyInt())).thenReturn(INVALID_BUS);
+        when(mMockAudioControlWrapper.getBusForContext(anyInt())).thenReturn(INVALID_BUS);
 
         RuntimeException exception = expectThrows(RuntimeException.class,
                 () -> new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                carAudioDeviceInfos, mMockAudioControl));
+                carAudioDeviceInfos, mMockAudioControlWrapper));
 
         assertThat(exception.getMessage()).contains("Invalid bus -1 was associated with context");
     }
@@ -83,10 +82,10 @@
     @Test
     public void loadAudioZones_succeeds() throws Exception {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = getValidCarAudioDeviceInfos();
-        when(mMockAudioControl.getBusForContext(anyInt())).thenReturn(1);
+        when(mMockAudioControlWrapper.getBusForContext(anyInt())).thenReturn(1);
 
         CarAudioZonesHelperLegacy helper = new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                carAudioDeviceInfos, mMockAudioControl);
+                carAudioDeviceInfos, mMockAudioControlWrapper);
 
         CarAudioZone[] zones = helper.loadAudioZones();
 
@@ -97,10 +96,10 @@
     public void loadAudioZones_parsesAllVolumeGroups() throws Exception {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = getValidCarAudioDeviceInfos();
 
-        when(mMockAudioControl.getBusForContext(anyInt())).thenReturn(1);
+        when(mMockAudioControlWrapper.getBusForContext(anyInt())).thenReturn(1);
 
         CarAudioZonesHelperLegacy helper = new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                carAudioDeviceInfos, mMockAudioControl);
+                carAudioDeviceInfos, mMockAudioControlWrapper);
 
         CarAudioZone[] zones = helper.loadAudioZones();
         CarVolumeGroup[] volumeGroups = zones[0].getVolumeGroups();
@@ -111,11 +110,11 @@
     public void loadAudioZones_associatesLegacyContextsWithCorrectBuses() throws Exception {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = getValidCarAudioDeviceInfos();
 
-        when(mMockAudioControl.getBusForContext(anyInt())).thenReturn(2);
-        when(mMockAudioControl.getBusForContext(CarAudioContext.MUSIC)).thenReturn(1);
+        when(mMockAudioControlWrapper.getBusForContext(anyInt())).thenReturn(2);
+        when(mMockAudioControlWrapper.getBusForContext(CarAudioContext.MUSIC)).thenReturn(1);
 
         CarAudioZonesHelperLegacy helper = new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                carAudioDeviceInfos, mMockAudioControl);
+                carAudioDeviceInfos, mMockAudioControlWrapper);
 
         CarAudioZone[] zones = helper.loadAudioZones();
 
@@ -137,12 +136,12 @@
     @Test
     public void loadAudioZones_associatesNonLegacyContextsWithMediaBus() throws Exception {
         List<CarAudioDeviceInfo> carAudioDeviceInfos = getValidCarAudioDeviceInfos();
-        when(mMockAudioControl.getBusForContext(anyInt())).thenReturn(2);
-        when(mMockAudioControl.getBusForContext(CarAudioService.DEFAULT_AUDIO_CONTEXT))
+        when(mMockAudioControlWrapper.getBusForContext(anyInt())).thenReturn(2);
+        when(mMockAudioControlWrapper.getBusForContext(CarAudioService.DEFAULT_AUDIO_CONTEXT))
                 .thenReturn(1);
 
         CarAudioZonesHelperLegacy helper = new CarAudioZonesHelperLegacy(mContext, mCarVolumeGroups,
-                carAudioDeviceInfos, mMockAudioControl);
+                carAudioDeviceInfos, mMockAudioControlWrapper);
 
         CarAudioZone[] zones = helper.loadAudioZones();
 
diff --git a/tests/carservice_unit_test/src/com/android/car/audio/AudioControlWrapperTest.java b/tests/carservice_unit_test/src/com/android/car/audio/AudioControlWrapperTest.java
new file mode 100644
index 0000000..b54f002
--- /dev/null
+++ b/tests/carservice_unit_test/src/com/android/car/audio/AudioControlWrapperTest.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2020 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.car.audio;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public class AudioControlWrapperTest {
+    private static final float FADE_VALUE = 5;
+    private static final float BALANCE_VALUE = 6;
+    private static final int CONTEXT_NUMBER = 3;
+
+    @Rule
+    public MockitoRule rule = MockitoJUnit.rule();
+
+    @Mock
+    android.hardware.automotive.audiocontrol.V1_0.IAudioControl mAudioControlV1;
+    @Mock
+    android.hardware.automotive.audiocontrol.V2_0.IAudioControl mAudioControlV2;
+
+    @Test
+    public void constructor_throwsIfBothVersionsAreNull() throws Exception {
+        assertThrows(IllegalStateException.class, () -> new AudioControlWrapper(null, null));
+    }
+
+    @Test
+    public void constructor_succeedsWithOneVersion() throws Exception {
+        new AudioControlWrapper(null, mAudioControlV2);
+    }
+
+    @Test
+    public void setFadeTowardFront_withBothVersions_defaultsToV2() throws Exception {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1,
+                mAudioControlV2);
+        audioControlWrapper.setFadeTowardFront(FADE_VALUE);
+
+        verify(mAudioControlV2).setFadeTowardFront(FADE_VALUE);
+        verify(mAudioControlV1, never()).setFadeTowardFront(anyFloat());
+    }
+
+    @Test
+    public void setFadeTowardFront_withJustV1_succeeds() throws Exception {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1, null);
+        audioControlWrapper.setFadeTowardFront(FADE_VALUE);
+
+        verify(mAudioControlV1).setFadeTowardFront(FADE_VALUE);
+    }
+
+    @Test
+    public void setBalanceTowardRight_withBothVersions_defaultsToV2() throws Exception {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1,
+                mAudioControlV2);
+        audioControlWrapper.setBalanceTowardRight(BALANCE_VALUE);
+
+        verify(mAudioControlV2).setBalanceTowardRight(BALANCE_VALUE);
+        verify(mAudioControlV1, never()).setBalanceTowardRight(anyFloat());
+    }
+
+    @Test
+    public void setBalanceTowardRight_withJustV1_succeeds() throws Exception {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1, null);
+        audioControlWrapper.setBalanceTowardRight(BALANCE_VALUE);
+
+        verify(mAudioControlV1).setBalanceTowardRight(BALANCE_VALUE);
+    }
+
+    @Test
+    public void getBusForContext_withV2Present_throws() {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1,
+                mAudioControlV2);
+        assertThrows(IllegalStateException.class,
+                () -> audioControlWrapper.getBusForContext(CONTEXT_NUMBER));
+    }
+
+    @Test
+    public void getBusForContext_withJustV1_returnsBusNumber() throws Exception {
+        AudioControlWrapper audioControlWrapper = new AudioControlWrapper(mAudioControlV1, null);
+        int busNumber = 1;
+        when(mAudioControlV1.getBusForContext(CONTEXT_NUMBER)).thenReturn(busNumber);
+
+        int actualBus = audioControlWrapper.getBusForContext(CONTEXT_NUMBER);
+        assertThat(actualBus).isEqualTo(busNumber);
+    }
+}