Merge "Implement VirtualAudioDevice API"
This commit is contained in:
committed by
Android (Google) Code Review
commit
391e76398d
@@ -2750,6 +2750,7 @@ package android.companion.virtual {
|
||||
method public void addActivityListener(@NonNull android.companion.virtual.VirtualDeviceManager.ActivityListener);
|
||||
method public void addActivityListener(@NonNull android.companion.virtual.VirtualDeviceManager.ActivityListener, @NonNull java.util.concurrent.Executor);
|
||||
method @RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE) public void close();
|
||||
method @NonNull @RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE) public android.companion.virtual.audio.VirtualAudioDevice createVirtualAudioDevice(@NonNull android.hardware.display.VirtualDisplay, @Nullable java.util.concurrent.Executor, @Nullable android.companion.virtual.audio.VirtualAudioDevice.AudioConfigurationChangeCallback);
|
||||
method @Nullable public android.hardware.display.VirtualDisplay createVirtualDisplay(int, int, int, @Nullable android.view.Surface, int, @Nullable android.os.Handler, @Nullable android.hardware.display.VirtualDisplay.Callback);
|
||||
method @NonNull @RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE) public android.hardware.input.VirtualKeyboard createVirtualKeyboard(@NonNull android.hardware.display.VirtualDisplay, @NonNull String, int, int);
|
||||
method @NonNull @RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE) public android.hardware.input.VirtualMouse createVirtualMouse(@NonNull android.hardware.display.VirtualDisplay, @NonNull String, int, int);
|
||||
@@ -2782,6 +2783,39 @@ package android.companion.virtual {
|
||||
|
||||
}
|
||||
|
||||
package android.companion.virtual.audio {
|
||||
|
||||
public final class AudioCapture {
|
||||
ctor public AudioCapture();
|
||||
method public int getRecordingState();
|
||||
method public int read(@NonNull java.nio.ByteBuffer, int);
|
||||
method public void startRecording();
|
||||
method public void stop();
|
||||
}
|
||||
|
||||
public final class AudioInjection {
|
||||
ctor public AudioInjection();
|
||||
method public int getPlayState();
|
||||
method public void play();
|
||||
method public void stop();
|
||||
method public int write(@NonNull java.nio.ByteBuffer, int, int);
|
||||
}
|
||||
|
||||
public final class VirtualAudioDevice implements java.io.Closeable {
|
||||
method @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING) public void close();
|
||||
method @Nullable public android.companion.virtual.audio.AudioCapture getAudioCapture();
|
||||
method @Nullable public android.companion.virtual.audio.AudioInjection getAudioInjection();
|
||||
method @NonNull @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING) public android.companion.virtual.audio.AudioCapture startAudioCapture(@NonNull android.media.AudioFormat);
|
||||
method @NonNull @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING) public android.companion.virtual.audio.AudioInjection startAudioInjection(@NonNull android.media.AudioFormat);
|
||||
}
|
||||
|
||||
public static interface VirtualAudioDevice.AudioConfigurationChangeCallback {
|
||||
method public void onPlaybackConfigChanged(@NonNull java.util.List<android.media.AudioPlaybackConfiguration>);
|
||||
method public void onRecordingConfigChanged(@NonNull java.util.List<android.media.AudioRecordingConfiguration>);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package android.content {
|
||||
|
||||
public class ApexEnvironment {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package android.companion.virtual;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.companion.virtual.audio.IAudioSessionCallback;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.hardware.input.VirtualKeyEvent;
|
||||
@@ -45,6 +46,15 @@ interface IVirtualDevice {
|
||||
*/
|
||||
void close();
|
||||
|
||||
/**
|
||||
* Notifies of an audio session being started.
|
||||
*/
|
||||
void onAudioSessionStarting(
|
||||
int displayId,
|
||||
IAudioSessionCallback callback);
|
||||
|
||||
void onAudioSessionEnded();
|
||||
|
||||
void createVirtualKeyboard(
|
||||
int displayId,
|
||||
String inputDeviceName,
|
||||
|
||||
@@ -25,6 +25,8 @@ import android.annotation.SystemService;
|
||||
import android.app.Activity;
|
||||
import android.app.PendingIntent;
|
||||
import android.companion.AssociationInfo;
|
||||
import android.companion.virtual.audio.VirtualAudioDevice;
|
||||
import android.companion.virtual.audio.VirtualAudioDevice.AudioConfigurationChangeCallback;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.graphics.Point;
|
||||
@@ -338,6 +340,30 @@ public final class VirtualDeviceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VirtualAudioDevice, capable of recording audio emanating from this device,
|
||||
* or injecting audio from another device.
|
||||
*
|
||||
* <p>Note: This object does not support capturing privileged playback, such as voice call
|
||||
* audio.
|
||||
*
|
||||
* @param display The target virtual display to capture from and inject into.
|
||||
* @param executor The {@link Executor} object for the thread on which to execute
|
||||
* the callback. If <code>null</code>, the {@link Executor} associated with
|
||||
* the main {@link Looper} will be used.
|
||||
* @param callback Interface to be notified when playback or recording configuration of
|
||||
* applications running on virtual display is changed.
|
||||
* @return A {@link VirtualAudioDevice} instance.
|
||||
*/
|
||||
@RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE)
|
||||
@NonNull
|
||||
public VirtualAudioDevice createVirtualAudioDevice(
|
||||
@NonNull VirtualDisplay display,
|
||||
@Nullable Executor executor,
|
||||
@Nullable AudioConfigurationChangeCallback callback) {
|
||||
return new VirtualAudioDevice(mContext, mVirtualDevice, display, executor, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visibility of the pointer icon for this VirtualDevice's associated displays.
|
||||
*
|
||||
|
||||
124
core/java/android/companion/virtual/audio/AudioCapture.java
Normal file
124
core/java/android/companion/virtual/audio/AudioCapture.java
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 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 android.companion.virtual.audio;
|
||||
|
||||
import static android.media.AudioRecord.RECORDSTATE_RECORDING;
|
||||
import static android.media.AudioRecord.RECORDSTATE_STOPPED;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.SystemApi;
|
||||
import android.media.AudioRecord;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* Wrapper around {@link AudioRecord} that allows for the underlying {@link AudioRecord} to
|
||||
* be swapped out while recording is ongoing.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
// The stop() actually doesn't release resources, so should not force implementing Closeable.
|
||||
@SuppressLint("NotCloseable")
|
||||
@SystemApi
|
||||
public final class AudioCapture {
|
||||
private static final String TAG = "AudioCapture";
|
||||
|
||||
private final Object mLock = new Object();
|
||||
|
||||
@GuardedBy("mLock")
|
||||
@Nullable
|
||||
private AudioRecord mAudioRecord;
|
||||
|
||||
@GuardedBy("mLock")
|
||||
private int mRecordingState = RECORDSTATE_STOPPED;
|
||||
|
||||
/**
|
||||
* Sets the {@link AudioRecord} to handle audio capturing.
|
||||
* Callers may call this multiple times with different audio records to change
|
||||
* the underlying {@link AudioRecord} without stopping and re-starting recording.
|
||||
*
|
||||
* @param audioRecord The underlying {@link AudioRecord} to use for capture,
|
||||
* or null if no audio (i.e. silence) should be captured while still keeping the
|
||||
* record in a recording state.
|
||||
*/
|
||||
void setAudioRecord(@Nullable AudioRecord audioRecord) {
|
||||
Log.d(TAG, "set AudioRecord with " + audioRecord);
|
||||
synchronized (mLock) {
|
||||
// Release old reference.
|
||||
if (mAudioRecord != null) {
|
||||
mAudioRecord.release();
|
||||
}
|
||||
// Sync recording state for new reference.
|
||||
if (audioRecord != null) {
|
||||
if (mRecordingState == RECORDSTATE_RECORDING
|
||||
&& audioRecord.getRecordingState() != RECORDSTATE_RECORDING) {
|
||||
audioRecord.startRecording();
|
||||
}
|
||||
if (mRecordingState == RECORDSTATE_STOPPED
|
||||
&& audioRecord.getRecordingState() != RECORDSTATE_STOPPED) {
|
||||
audioRecord.stop();
|
||||
}
|
||||
}
|
||||
mAudioRecord = audioRecord;
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioRecord#read(ByteBuffer, int)}. */
|
||||
public int read(@NonNull ByteBuffer audioBuffer, int sizeInBytes) {
|
||||
final int sizeRead;
|
||||
synchronized (mLock) {
|
||||
if (mAudioRecord != null) {
|
||||
sizeRead = mAudioRecord.read(audioBuffer, sizeInBytes);
|
||||
} else {
|
||||
sizeRead = 0;
|
||||
}
|
||||
}
|
||||
return sizeRead;
|
||||
}
|
||||
|
||||
/** See {@link AudioRecord#startRecording()}. */
|
||||
public void startRecording() {
|
||||
synchronized (mLock) {
|
||||
mRecordingState = RECORDSTATE_RECORDING;
|
||||
if (mAudioRecord != null && mAudioRecord.getRecordingState() != RECORDSTATE_RECORDING) {
|
||||
mAudioRecord.startRecording();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioRecord#stop()}. */
|
||||
public void stop() {
|
||||
synchronized (mLock) {
|
||||
mRecordingState = RECORDSTATE_STOPPED;
|
||||
if (mAudioRecord != null && mAudioRecord.getRecordingState() != RECORDSTATE_STOPPED) {
|
||||
mAudioRecord.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioRecord#getRecordingState()}. */
|
||||
public int getRecordingState() {
|
||||
synchronized (mLock) {
|
||||
return mRecordingState;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
core/java/android/companion/virtual/audio/AudioInjection.java
Normal file
124
core/java/android/companion/virtual/audio/AudioInjection.java
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (C) 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 android.companion.virtual.audio;
|
||||
|
||||
import static android.media.AudioTrack.PLAYSTATE_PLAYING;
|
||||
import static android.media.AudioTrack.PLAYSTATE_STOPPED;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.SystemApi;
|
||||
import android.media.AudioTrack;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* Wrapper around {@link AudioTrack} that allows for the underlying {@link AudioTrack} to
|
||||
* be swapped out while playout is ongoing.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
// The stop() actually doesn't release resources, so should not force implementing Closeable.
|
||||
@SuppressLint("NotCloseable")
|
||||
@SystemApi
|
||||
public final class AudioInjection {
|
||||
private static final String TAG = "AudioInjection";
|
||||
|
||||
private final Object mLock = new Object();
|
||||
|
||||
@GuardedBy("mLock")
|
||||
@Nullable
|
||||
private AudioTrack mAudioTrack;
|
||||
|
||||
@GuardedBy("mLock")
|
||||
private int mPlayState = PLAYSTATE_STOPPED;
|
||||
|
||||
/**
|
||||
* Sets the {@link AudioTrack} to handle audio injection.
|
||||
* Callers may call this multiple times with different audio tracks to change
|
||||
* the underlying {@link AudioTrack} without stopping and re-starting injection.
|
||||
*
|
||||
* @param audioTrack The underlying {@link AudioTrack} to use for injection,
|
||||
* or null if no audio (i.e. silence) should be injected while still keeping the
|
||||
* record in a playing state.
|
||||
*/
|
||||
void setAudioTrack(@Nullable AudioTrack audioTrack) {
|
||||
Log.d(TAG, "set AudioTrack with " + audioTrack);
|
||||
synchronized (mLock) {
|
||||
// Release old reference.
|
||||
if (mAudioTrack != null) {
|
||||
mAudioTrack.release();
|
||||
}
|
||||
// Sync play state for new reference.
|
||||
if (audioTrack != null) {
|
||||
if (mPlayState == PLAYSTATE_PLAYING
|
||||
&& audioTrack.getPlayState() != PLAYSTATE_PLAYING) {
|
||||
audioTrack.play();
|
||||
}
|
||||
if (mPlayState == PLAYSTATE_STOPPED
|
||||
&& audioTrack.getPlayState() != PLAYSTATE_STOPPED) {
|
||||
audioTrack.stop();
|
||||
}
|
||||
}
|
||||
mAudioTrack = audioTrack;
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioTrack#write(ByteBuffer, int, int)}. */
|
||||
public int write(@NonNull ByteBuffer audioBuffer, int sizeInBytes, int writeMode) {
|
||||
final int sizeWrite;
|
||||
synchronized (mLock) {
|
||||
if (mAudioTrack != null) {
|
||||
sizeWrite = mAudioTrack.write(audioBuffer, sizeInBytes, writeMode);
|
||||
} else {
|
||||
sizeWrite = 0;
|
||||
}
|
||||
}
|
||||
return sizeWrite;
|
||||
}
|
||||
|
||||
/** See {@link AudioTrack#play()}. */
|
||||
public void play() {
|
||||
synchronized (mLock) {
|
||||
mPlayState = PLAYSTATE_PLAYING;
|
||||
if (mAudioTrack != null && mAudioTrack.getPlayState() != PLAYSTATE_PLAYING) {
|
||||
mAudioTrack.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioTrack#stop()}. */
|
||||
public void stop() {
|
||||
synchronized (mLock) {
|
||||
mPlayState = PLAYSTATE_STOPPED;
|
||||
if (mAudioTrack != null && mAudioTrack.getPlayState() != PLAYSTATE_STOPPED) {
|
||||
mAudioTrack.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** See {@link AudioTrack#getPlayState()}. */
|
||||
public int getPlayState() {
|
||||
synchronized (mLock) {
|
||||
return mPlayState;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2021 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 android.companion.virtual.audio;
|
||||
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
|
||||
/**
|
||||
* Callback to control audio rerouting, notify playback and recording state of applications running
|
||||
* on virtual device.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
oneway interface IAudioSessionCallback {
|
||||
|
||||
/** Updates the set of applications that need to have their audio rerouted. */
|
||||
void onAppsNeedingAudioRoutingChanged(in int[] appUids);
|
||||
|
||||
/**
|
||||
* Called whenever the playback configuration of applications running on virtual device has
|
||||
* changed.
|
||||
*/
|
||||
void onPlaybackConfigChanged(in List<AudioPlaybackConfiguration> configs);
|
||||
|
||||
/**
|
||||
* Called whenever the recording configuration of applications running on virtual device has
|
||||
* changed.
|
||||
*/
|
||||
void onRecordingConfigChanged(in List<AudioRecordingConfiguration> configs);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 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 android.companion.virtual.audio;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.annotation.RequiresPermission;
|
||||
import android.annotation.SystemApi;
|
||||
import android.companion.virtual.IVirtualDevice;
|
||||
import android.content.Context;
|
||||
import android.hardware.display.VirtualDisplay;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
import android.os.RemoteException;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* The class stores an {@link AudioCapture} for audio capturing and an {@link AudioInjection} for
|
||||
* audio injection.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
@SystemApi
|
||||
public final class VirtualAudioDevice implements Closeable {
|
||||
|
||||
/**
|
||||
* Interface to be notified when playback or recording configuration of applications running on
|
||||
* virtual display was changed.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
@SystemApi
|
||||
public interface AudioConfigurationChangeCallback {
|
||||
/**
|
||||
* Notifies when playback configuration of applications running on virtual display was
|
||||
* changed.
|
||||
*/
|
||||
void onPlaybackConfigChanged(@NonNull List<AudioPlaybackConfiguration> configs);
|
||||
|
||||
/**
|
||||
* Notifies when recording configuration of applications running on virtual display was
|
||||
* changed.
|
||||
*/
|
||||
void onRecordingConfigChanged(@NonNull List<AudioRecordingConfiguration> configs);
|
||||
}
|
||||
|
||||
private final Context mContext;
|
||||
private final IVirtualDevice mVirtualDevice;
|
||||
private final VirtualDisplay mVirtualDisplay;
|
||||
private final AudioConfigurationChangeCallback mCallback;
|
||||
private final Executor mExecutor;
|
||||
@Nullable
|
||||
private VirtualAudioSession mOngoingSession;
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
public VirtualAudioDevice(Context context, IVirtualDevice virtualDevice,
|
||||
VirtualDisplay virtualDisplay, Executor executor,
|
||||
AudioConfigurationChangeCallback callback) {
|
||||
mContext = context;
|
||||
mVirtualDevice = virtualDevice;
|
||||
mVirtualDisplay = virtualDisplay;
|
||||
mExecutor = executor;
|
||||
mCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins injecting audio from a remote device into this device.
|
||||
*
|
||||
* @return An {@link AudioInjection} containing the injected audio.
|
||||
*/
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@NonNull
|
||||
public AudioInjection startAudioInjection(@NonNull AudioFormat injectionFormat) {
|
||||
Objects.requireNonNull(injectionFormat, "injectionFormat must not be null");
|
||||
|
||||
if (mOngoingSession != null && mOngoingSession.getAudioInjection() != null) {
|
||||
throw new IllegalStateException("Cannot start an audio injection while a session is "
|
||||
+ "ongoing. Call close() on this device first to end the previous injection.");
|
||||
}
|
||||
if (mOngoingSession == null) {
|
||||
mOngoingSession = new VirtualAudioSession(mContext, mCallback, mExecutor);
|
||||
}
|
||||
|
||||
try {
|
||||
mVirtualDevice.onAudioSessionStarting(mVirtualDisplay.getDisplay().getDisplayId(),
|
||||
/* callback= */ mOngoingSession);
|
||||
} catch (RemoteException e) {
|
||||
throw e.rethrowFromSystemServer();
|
||||
}
|
||||
return mOngoingSession.startAudioInjection(injectionFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins recording audio emanating from this device.
|
||||
*
|
||||
* @return An {@link AudioCapture} containing the recorded audio.
|
||||
*/
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@NonNull
|
||||
public AudioCapture startAudioCapture(@NonNull AudioFormat captureFormat) {
|
||||
Objects.requireNonNull(captureFormat, "captureFormat must not be null");
|
||||
|
||||
if (mOngoingSession != null && mOngoingSession.getAudioCapture() != null) {
|
||||
throw new IllegalStateException("Cannot start an audio capture while a session is "
|
||||
+ "ongoing. Call close() on this device first to end the previous session.");
|
||||
}
|
||||
if (mOngoingSession == null) {
|
||||
mOngoingSession = new VirtualAudioSession(mContext, mCallback, mExecutor);
|
||||
}
|
||||
|
||||
try {
|
||||
mVirtualDevice.onAudioSessionStarting(mVirtualDisplay.getDisplay().getDisplayId(),
|
||||
/* callback= */ mOngoingSession);
|
||||
} catch (RemoteException e) {
|
||||
throw e.rethrowFromSystemServer();
|
||||
}
|
||||
return mOngoingSession.startAudioCapture(captureFormat);
|
||||
}
|
||||
|
||||
/** Returns the {@link AudioCapture} instance. */
|
||||
@Nullable
|
||||
public AudioCapture getAudioCapture() {
|
||||
return mOngoingSession != null ? mOngoingSession.getAudioCapture() : null;
|
||||
}
|
||||
|
||||
/** Returns the {@link AudioInjection} instance. */
|
||||
@Nullable
|
||||
public AudioInjection getAudioInjection() {
|
||||
return mOngoingSession != null ? mOngoingSession.getAudioInjection() : null;
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@Override
|
||||
public void close() {
|
||||
if (mOngoingSession != null) {
|
||||
mOngoingSession.close();
|
||||
mOngoingSession = null;
|
||||
|
||||
try {
|
||||
mVirtualDevice.onAudioSessionEnded();
|
||||
} catch (RemoteException e) {
|
||||
throw e.rethrowFromSystemServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 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 android.companion.virtual.audio;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.annotation.RequiresPermission;
|
||||
import android.companion.virtual.audio.VirtualAudioDevice.AudioConfigurationChangeCallback;
|
||||
import android.content.Context;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
import android.media.AudioTrack;
|
||||
import android.media.audiopolicy.AudioMix;
|
||||
import android.media.audiopolicy.AudioMixingRule;
|
||||
import android.media.audiopolicy.AudioPolicy;
|
||||
import android.util.IntArray;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Manages an ongiong audio session in which audio can be captured (recorded) and/or
|
||||
* injected from a remote device.
|
||||
*
|
||||
* @hide
|
||||
*/
|
||||
@VisibleForTesting
|
||||
public final class VirtualAudioSession extends IAudioSessionCallback.Stub implements
|
||||
Closeable {
|
||||
private static final String TAG = "VirtualAudioSession";
|
||||
|
||||
private final Context mContext;
|
||||
/** The {@link Executor} for sending {@link AudioConfigurationChangeCallback} to the caller */
|
||||
private final Executor mExecutor;
|
||||
@Nullable
|
||||
private final AudioConfigurationChangeCallback mCallback;
|
||||
private final Object mLock = new Object();
|
||||
@GuardedBy("mLock")
|
||||
private final IntArray mReroutedAppUids = new IntArray();
|
||||
@Nullable
|
||||
@GuardedBy("mLock")
|
||||
private AudioPolicy mAudioPolicy;
|
||||
@Nullable
|
||||
@GuardedBy("mLock")
|
||||
private AudioFormat mCaptureFormat;
|
||||
@Nullable
|
||||
@GuardedBy("mLock")
|
||||
private AudioFormat mInjectionFormat;
|
||||
@Nullable
|
||||
@GuardedBy("mLock")
|
||||
private AudioCapture mAudioCapture;
|
||||
@Nullable
|
||||
@GuardedBy("mLock")
|
||||
private AudioInjection mAudioInjection;
|
||||
|
||||
@VisibleForTesting
|
||||
public VirtualAudioSession(Context context,
|
||||
@Nullable AudioConfigurationChangeCallback callback, @Nullable Executor executor) {
|
||||
mContext = context;
|
||||
mCallback = callback;
|
||||
mExecutor = executor != null ? executor : context.getMainExecutor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins recording audio emanating from this device.
|
||||
*
|
||||
* @return An {@link AudioCapture} containing the recorded audio.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
@NonNull
|
||||
public AudioCapture startAudioCapture(@NonNull AudioFormat captureFormat) {
|
||||
Objects.requireNonNull(captureFormat, "captureFormat must not be null");
|
||||
|
||||
synchronized (mLock) {
|
||||
if (mAudioCapture != null) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot start capture while another capture is ongoing.");
|
||||
}
|
||||
|
||||
mCaptureFormat = captureFormat;
|
||||
mAudioCapture = new AudioCapture();
|
||||
mAudioCapture.startRecording();
|
||||
return mAudioCapture;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins injecting audio from a remote device into this device.
|
||||
*
|
||||
* @return An {@link AudioInjection} containing the injected audio.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
@NonNull
|
||||
public AudioInjection startAudioInjection(@NonNull AudioFormat injectionFormat) {
|
||||
Objects.requireNonNull(injectionFormat, "injectionFormat must not be null");
|
||||
|
||||
synchronized (mLock) {
|
||||
if (mAudioInjection != null) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot start injection while injection is already ongoing.");
|
||||
}
|
||||
|
||||
mInjectionFormat = injectionFormat;
|
||||
mAudioInjection = new AudioInjection();
|
||||
mAudioInjection.play();
|
||||
return mAudioInjection;
|
||||
}
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
@VisibleForTesting
|
||||
@Nullable
|
||||
public AudioCapture getAudioCapture() {
|
||||
synchronized (mLock) {
|
||||
return mAudioCapture;
|
||||
}
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
@VisibleForTesting
|
||||
@Nullable
|
||||
public AudioInjection getAudioInjection() {
|
||||
synchronized (mLock) {
|
||||
return mAudioInjection;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@Override
|
||||
public void onAppsNeedingAudioRoutingChanged(int[] appUids) {
|
||||
synchronized (mLock) {
|
||||
if (Arrays.equals(mReroutedAppUids.toArray(), appUids)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
releaseAudioStreams();
|
||||
|
||||
if (appUids.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
createAudioStreams(appUids);
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@Override
|
||||
public void close() {
|
||||
releaseAudioStreams();
|
||||
synchronized (mLock) {
|
||||
mAudioCapture = null;
|
||||
mAudioInjection = null;
|
||||
mCaptureFormat = null;
|
||||
mInjectionFormat = null;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
private void createAudioStreams(int[] appUids) {
|
||||
synchronized (mLock) {
|
||||
if (mCaptureFormat == null && mInjectionFormat == null) {
|
||||
throw new IllegalStateException(
|
||||
"At least one of captureFormat and injectionFormat must be specified.");
|
||||
}
|
||||
if (mAudioPolicy != null) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot create audio streams while the audio policy is registered. Call "
|
||||
+ "releaseAudioStreams() first to unregister the previous audio "
|
||||
+ "policy."
|
||||
);
|
||||
}
|
||||
|
||||
mReroutedAppUids.clear();
|
||||
for (int appUid : appUids) {
|
||||
mReroutedAppUids.add(appUid);
|
||||
}
|
||||
|
||||
AudioMix audioRecordMix = null;
|
||||
AudioMix audioTrackMix = null;
|
||||
AudioPolicy.Builder builder = new AudioPolicy.Builder(mContext);
|
||||
if (mCaptureFormat != null) {
|
||||
audioRecordMix = createAudioRecordMix(mCaptureFormat, appUids);
|
||||
builder.addMix(audioRecordMix);
|
||||
}
|
||||
if (mInjectionFormat != null) {
|
||||
audioTrackMix = createAudioTrackMix(mInjectionFormat, appUids);
|
||||
builder.addMix(audioTrackMix);
|
||||
}
|
||||
mAudioPolicy = builder.build();
|
||||
AudioManager audioManager = mContext.getSystemService(AudioManager.class);
|
||||
if (audioManager.registerAudioPolicy(mAudioPolicy) == AudioManager.ERROR) {
|
||||
Log.e(TAG, "Failed to register audio policy!");
|
||||
}
|
||||
|
||||
AudioRecord audioRecord =
|
||||
audioRecordMix != null ? mAudioPolicy.createAudioRecordSink(audioRecordMix)
|
||||
: null;
|
||||
AudioTrack audioTrack =
|
||||
audioTrackMix != null ? mAudioPolicy.createAudioTrackSource(audioTrackMix)
|
||||
: null;
|
||||
|
||||
if (mAudioCapture != null) {
|
||||
mAudioCapture.setAudioRecord(audioRecord);
|
||||
}
|
||||
if (mAudioInjection != null) {
|
||||
mAudioInjection.setAudioTrack(audioTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
private void releaseAudioStreams() {
|
||||
synchronized (mLock) {
|
||||
if (mAudioCapture != null) {
|
||||
mAudioCapture.setAudioRecord(null);
|
||||
}
|
||||
if (mAudioInjection != null) {
|
||||
mAudioInjection.setAudioTrack(null);
|
||||
}
|
||||
mReroutedAppUids.clear();
|
||||
if (mAudioPolicy != null) {
|
||||
AudioManager audioManager = mContext.getSystemService(AudioManager.class);
|
||||
audioManager.unregisterAudioPolicy(mAudioPolicy);
|
||||
mAudioPolicy = null;
|
||||
Log.i(TAG, "AudioPolicy unregistered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackConfigChanged(List<AudioPlaybackConfiguration> configs) {
|
||||
if (mCallback != null) {
|
||||
mExecutor.execute(() -> mCallback.onPlaybackConfigChanged(configs));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) {
|
||||
if (mCallback != null) {
|
||||
mExecutor.execute(() -> mCallback.onRecordingConfigChanged(configs));
|
||||
}
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
@VisibleForTesting
|
||||
public IntArray getReroutedAppUids() {
|
||||
synchronized (mLock) {
|
||||
return mReroutedAppUids;
|
||||
}
|
||||
}
|
||||
|
||||
private static AudioMix createAudioRecordMix(@NonNull AudioFormat audioFormat, int[] appUids) {
|
||||
AudioMixingRule.Builder builder = new AudioMixingRule.Builder();
|
||||
builder.setTargetMixRole(AudioMixingRule.MIX_ROLE_PLAYERS);
|
||||
for (int uid : appUids) {
|
||||
builder.addMixRule(AudioMixingRule.RULE_MATCH_UID, uid);
|
||||
}
|
||||
AudioMixingRule audioMixingRule = builder.allowPrivilegedPlaybackCapture(false).build();
|
||||
AudioMix audioMix =
|
||||
new AudioMix.Builder(audioMixingRule)
|
||||
.setFormat(audioFormat)
|
||||
.setRouteFlags(AudioMix.ROUTE_FLAG_LOOP_BACK)
|
||||
.build();
|
||||
return audioMix;
|
||||
}
|
||||
|
||||
private static AudioMix createAudioTrackMix(@NonNull AudioFormat audioFormat, int[] appUids) {
|
||||
AudioMixingRule.Builder builder = new AudioMixingRule.Builder();
|
||||
builder.setTargetMixRole(AudioMixingRule.MIX_ROLE_INJECTOR);
|
||||
for (int uid : appUids) {
|
||||
builder.addMixRule(AudioMixingRule.RULE_MATCH_UID, uid);
|
||||
}
|
||||
AudioMixingRule audioMixingRule = builder.build();
|
||||
AudioMix audioMix =
|
||||
new AudioMix.Builder(audioMixingRule)
|
||||
.setFormat(audioFormat)
|
||||
.setRouteFlags(AudioMix.ROUTE_FLAG_LOOP_BACK)
|
||||
.build();
|
||||
return audioMix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright (C) 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 android.companion.virtual.audio;
|
||||
|
||||
import static android.media.AudioFormat.CHANNEL_IN_MONO;
|
||||
import static android.media.AudioFormat.CHANNEL_OUT_MONO;
|
||||
import static android.media.AudioFormat.ENCODING_PCM_16BIT;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.testng.Assert.assertThrows;
|
||||
|
||||
import android.companion.virtual.audio.VirtualAudioDevice.AudioConfigurationChangeCallback;
|
||||
import android.content.Context;
|
||||
import android.content.ContextWrapper;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
import android.platform.test.annotations.Presubmit;
|
||||
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.mockito.junit.MockitoJUnit;
|
||||
import org.mockito.junit.MockitoRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Presubmit
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class VirtualAudioSessionTest {
|
||||
|
||||
@Rule
|
||||
public final MockitoRule mockito = MockitoJUnit.rule();
|
||||
@Mock
|
||||
private AudioConfigurationChangeCallback mCallback;
|
||||
private static final int APP_UID = 100;
|
||||
private static final int APP_UID2 = 200;
|
||||
private static final AudioFormat AUDIO_CAPTURE_FORMAT =
|
||||
new AudioFormat.Builder()
|
||||
.setSampleRate(48000)
|
||||
.setEncoding(ENCODING_PCM_16BIT)
|
||||
.setChannelMask(CHANNEL_IN_MONO)
|
||||
.build();
|
||||
private static final AudioFormat AUDIO_INJECT_FORMAT =
|
||||
new AudioFormat.Builder()
|
||||
.setSampleRate(48000)
|
||||
.setEncoding(ENCODING_PCM_16BIT)
|
||||
.setChannelMask(CHANNEL_OUT_MONO)
|
||||
.build();
|
||||
private Context mContext;
|
||||
private VirtualAudioSession mVirtualAudioSession;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
mContext = Mockito.spy(new ContextWrapper(InstrumentationRegistry.getTargetContext()));
|
||||
mVirtualAudioSession = new VirtualAudioSession(
|
||||
mContext, mCallback, /* executor= */ null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startAudioCapture_isSuccessful() {
|
||||
AudioCapture audioCapture = mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT);
|
||||
|
||||
assertThat(audioCapture).isNotNull();
|
||||
assertThat(mVirtualAudioSession.getAudioCapture()).isEqualTo(audioCapture);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startAudioCapture_audioCaptureAlreadyStarted_throws() {
|
||||
mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startAudioInjection_isSuccessful() {
|
||||
AudioInjection audioInjection = mVirtualAudioSession.startAudioInjection(
|
||||
AUDIO_INJECT_FORMAT);
|
||||
|
||||
assertThat(audioInjection).isNotNull();
|
||||
assertThat(mVirtualAudioSession.getAudioInjection()).isEqualTo(audioInjection);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startAudioInjection_audioInjectionAlreadyStarted_throws() {
|
||||
mVirtualAudioSession.startAudioInjection(AUDIO_INJECT_FORMAT);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> mVirtualAudioSession.startAudioInjection(AUDIO_INJECT_FORMAT));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAppsNeedingAudioRoutingChanged_neverStartAudioCaptureOrInjection_throws() {
|
||||
int[] uids = new int[]{APP_UID};
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> mVirtualAudioSession.onAppsNeedingAudioRoutingChanged(uids));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAppsNeedingAudioRoutingChanged_cachesReroutedApps() {
|
||||
mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT);
|
||||
mVirtualAudioSession.startAudioInjection(AUDIO_INJECT_FORMAT);
|
||||
int[] appUids = new int[]{APP_UID};
|
||||
|
||||
mVirtualAudioSession.onAppsNeedingAudioRoutingChanged(appUids);
|
||||
|
||||
assertThat(Arrays.equals(mVirtualAudioSession.getReroutedAppUids().toArray(),
|
||||
appUids)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAppsNeedingAudioRoutingChanged_receiveManyTimes_reroutedAppsSizeIsCorrect() {
|
||||
mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT);
|
||||
mVirtualAudioSession.startAudioInjection(AUDIO_INJECT_FORMAT);
|
||||
int[] appUids = new int[]{APP_UID, APP_UID2};
|
||||
|
||||
mVirtualAudioSession.onAppsNeedingAudioRoutingChanged(new int[]{1234});
|
||||
mVirtualAudioSession.onAppsNeedingAudioRoutingChanged(new int[]{5678});
|
||||
mVirtualAudioSession.onAppsNeedingAudioRoutingChanged(appUids);
|
||||
|
||||
assertThat(Arrays.equals(mVirtualAudioSession.getReroutedAppUids().toArray(),
|
||||
appUids)).isTrue();
|
||||
assertThat(mVirtualAudioSession.getReroutedAppUids().size()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void close_releasesCaptureAndInjection() {
|
||||
mVirtualAudioSession.startAudioCapture(AUDIO_CAPTURE_FORMAT);
|
||||
mVirtualAudioSession.startAudioInjection(AUDIO_INJECT_FORMAT);
|
||||
|
||||
mVirtualAudioSession.close();
|
||||
|
||||
assertThat(mVirtualAudioSession.getAudioCapture()).isNull();
|
||||
assertThat(mVirtualAudioSession.getAudioInjection()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onPlaybackConfigChanged_sendsCallback() {
|
||||
List<AudioPlaybackConfiguration> configs = new ArrayList<>();
|
||||
|
||||
mVirtualAudioSession.onPlaybackConfigChanged(configs);
|
||||
|
||||
verify(mCallback).onPlaybackConfigChanged(configs);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onRecordingConfigChanged_sendCallback() {
|
||||
List<AudioRecordingConfiguration> configs = new ArrayList<>();
|
||||
|
||||
mVirtualAudioSession.onRecordingConfigChanged(configs);
|
||||
|
||||
verify(mCallback).onRecordingConfigChanged(configs);
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,17 @@ import java.util.function.Consumer;
|
||||
/**
|
||||
* A controller to control the policies of the windows that can be displayed on the virtual display.
|
||||
*/
|
||||
class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
public class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
|
||||
private static final String TAG = "VirtualDeviceManager";
|
||||
private static final String TAG = "GenericWindowPolicyController";
|
||||
|
||||
/** Interface to listen running applications change on virtual display. */
|
||||
public interface RunningAppsChangedListener {
|
||||
/**
|
||||
* Notifies the running applications change.
|
||||
*/
|
||||
void onRunningAppsChanged(ArraySet<Integer> runningUids);
|
||||
}
|
||||
|
||||
private static final ComponentName BLOCKED_APP_STREAMING_COMPONENT =
|
||||
new ComponentName("android", BlockedAppStreamingActivity.class.getName());
|
||||
@@ -74,6 +82,9 @@ class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
@Nullable private final ActivityListener mActivityListener;
|
||||
private final Handler mHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@Nullable
|
||||
private RunningAppsChangedListener mRunningAppsChangedListener;
|
||||
|
||||
/**
|
||||
* Creates a window policy controller that is generic to the different use cases of virtual
|
||||
* device.
|
||||
@@ -84,7 +95,7 @@ class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
* @param activityListener Activity listener to listen for activity changes. The display ID
|
||||
* is not populated in this callback and is always {@link Display#INVALID_DISPLAY}.
|
||||
*/
|
||||
GenericWindowPolicyController(int windowFlags, int systemWindowFlags,
|
||||
public GenericWindowPolicyController(int windowFlags, int systemWindowFlags,
|
||||
@NonNull ArraySet<UserHandle> allowedUsers,
|
||||
@Nullable Set<ComponentName> allowedActivities,
|
||||
@Nullable Set<ComponentName> blockedActivities,
|
||||
@@ -98,6 +109,11 @@ class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
mActivityListener = activityListener;
|
||||
}
|
||||
|
||||
/** Sets listener for running applications change. */
|
||||
public void setRunningAppsChangedListener(@Nullable RunningAppsChangedListener listener) {
|
||||
mRunningAppsChangedListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canContainActivities(@NonNull List<ActivityInfo> activities) {
|
||||
// Can't display all the activities if any of them don't want to be displayed.
|
||||
@@ -139,6 +155,9 @@ class GenericWindowPolicyController extends DisplayWindowPolicyController {
|
||||
// Post callback on the main thread so it doesn't block activity launching
|
||||
mHandler.post(() -> mActivityListener.onDisplayEmpty(Display.INVALID_DISPLAY));
|
||||
}
|
||||
if (mRunningAppsChangedListener != null) {
|
||||
mRunningAppsChangedListener.onRunningAppsChanged(runningUids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import static android.view.WindowManager.LayoutParams.FLAG_SECURE;
|
||||
import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.RequiresPermission;
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityOptions;
|
||||
import android.app.PendingIntent;
|
||||
@@ -32,6 +33,7 @@ import android.companion.virtual.IVirtualDevice;
|
||||
import android.companion.virtual.IVirtualDeviceActivityListener;
|
||||
import android.companion.virtual.VirtualDeviceManager.ActivityListener;
|
||||
import android.companion.virtual.VirtualDeviceParams;
|
||||
import android.companion.virtual.audio.IAudioSessionCallback;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -60,6 +62,7 @@ import android.window.DisplayWindowPolicyController;
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
import com.android.internal.app.BlockedAppStreamingActivity;
|
||||
import com.android.server.companion.virtual.audio.VirtualAudioController;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.PrintWriter;
|
||||
@@ -78,6 +81,7 @@ final class VirtualDeviceImpl extends IVirtualDevice.Stub
|
||||
private final PendingTrampolineCallback mPendingTrampolineCallback;
|
||||
private final int mOwnerUid;
|
||||
private final InputController mInputController;
|
||||
private VirtualAudioController mVirtualAudioController;
|
||||
@VisibleForTesting
|
||||
final Set<Integer> mVirtualDisplayIds = new ArraySet<>();
|
||||
private final OnDeviceCloseListener mListener;
|
||||
@@ -245,6 +249,46 @@ final class VirtualDeviceImpl extends IVirtualDevice.Stub
|
||||
close();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
VirtualAudioController getVirtualAudioControllerForTesting() {
|
||||
return mVirtualAudioController;
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE)
|
||||
@Override // Binder call
|
||||
public void onAudioSessionStarting(int displayId, IAudioSessionCallback callback) {
|
||||
mContext.enforceCallingOrSelfPermission(
|
||||
android.Manifest.permission.CREATE_VIRTUAL_DEVICE,
|
||||
"Permission required to start audio session");
|
||||
synchronized (mVirtualDeviceLock) {
|
||||
if (!mVirtualDisplayIds.contains(displayId)) {
|
||||
throw new SecurityException(
|
||||
"Cannot start audio session for a display not associated with this virtual "
|
||||
+ "device");
|
||||
}
|
||||
|
||||
if (mVirtualAudioController == null) {
|
||||
mVirtualAudioController = new VirtualAudioController(mContext);
|
||||
GenericWindowPolicyController gwpc = mWindowPolicyControllers.get(displayId);
|
||||
mVirtualAudioController.startListening(gwpc, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.CREATE_VIRTUAL_DEVICE)
|
||||
@Override // Binder call
|
||||
public void onAudioSessionEnded() {
|
||||
mContext.enforceCallingOrSelfPermission(
|
||||
android.Manifest.permission.CREATE_VIRTUAL_DEVICE,
|
||||
"Permission required to stop audio session");
|
||||
synchronized (mVirtualDeviceLock) {
|
||||
if (mVirtualAudioController != null) {
|
||||
mVirtualAudioController.stopListening();
|
||||
mVirtualAudioController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override // Binder call
|
||||
public void createVirtualKeyboard(
|
||||
int displayId,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 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.companion.virtual.audio;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wrapper class for other classes to listen {@link #onPlaybackConfigChanged(List)} by implementing
|
||||
* {@link AudioPlaybackCallback} instead of inheriting the
|
||||
* {@link AudioManager.AudioPlaybackCallback}.
|
||||
*/
|
||||
final class AudioPlaybackDetector extends AudioManager.AudioPlaybackCallback {
|
||||
|
||||
/**
|
||||
* Interface to listen {@link #onPlaybackConfigChanged(List)} from
|
||||
* {@link AudioManager.AudioPlaybackCallback}.
|
||||
*/
|
||||
interface AudioPlaybackCallback {
|
||||
void onPlaybackConfigChanged(List<AudioPlaybackConfiguration> configs);
|
||||
}
|
||||
|
||||
private final AudioManager mAudioManager;
|
||||
private AudioPlaybackCallback mAudioPlaybackCallback;
|
||||
|
||||
AudioPlaybackDetector(Context context) {
|
||||
mAudioManager = context.getSystemService(AudioManager.class);
|
||||
}
|
||||
|
||||
void register(@NonNull AudioPlaybackCallback callback) {
|
||||
mAudioPlaybackCallback = callback;
|
||||
mAudioManager.registerAudioPlaybackCallback(/* cb= */ this, /* handler= */ null);
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
mAudioPlaybackCallback = null;
|
||||
mAudioManager.unregisterAudioPlaybackCallback(/* cb= */ this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackConfigChanged(List<AudioPlaybackConfiguration> configs) {
|
||||
super.onPlaybackConfigChanged(configs);
|
||||
if (mAudioPlaybackCallback != null) {
|
||||
mAudioPlaybackCallback.onPlaybackConfigChanged(configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 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.companion.virtual.audio;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wrapper class for other classes to listen {@link #onRecordingConfigChanged(List)} by implementing
|
||||
* {@link AudioRecordingCallback} instead of inheriting the
|
||||
* {@link AudioManager.AudioRecordingCallback}.
|
||||
*/
|
||||
final class AudioRecordingDetector extends AudioManager.AudioRecordingCallback {
|
||||
|
||||
/**
|
||||
* Interface to listen {@link #onRecordingConfigChanged(List)} from
|
||||
* {@link AudioManager.AudioRecordingCallback}.
|
||||
*/
|
||||
interface AudioRecordingCallback {
|
||||
void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs);
|
||||
}
|
||||
|
||||
private final AudioManager mAudioManager;
|
||||
private AudioRecordingCallback mAudioRecordingCallback;
|
||||
|
||||
AudioRecordingDetector(Context context) {
|
||||
mAudioManager = context.getSystemService(AudioManager.class);
|
||||
}
|
||||
|
||||
void register(@NonNull AudioRecordingCallback callback) {
|
||||
mAudioRecordingCallback = callback;
|
||||
mAudioManager.registerAudioRecordingCallback(/* cb= */ this, /* handler= */ null);
|
||||
}
|
||||
|
||||
void unregister() {
|
||||
mAudioRecordingCallback = null;
|
||||
mAudioManager.unregisterAudioRecordingCallback(/* cb= */ this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) {
|
||||
super.onRecordingConfigChanged(configs);
|
||||
if (mAudioRecordingCallback != null) {
|
||||
mAudioRecordingCallback.onRecordingConfigChanged(configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 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.companion.virtual.audio;
|
||||
|
||||
import static android.media.AudioPlaybackConfiguration.PLAYER_STATE_STARTED;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.annotation.RequiresPermission;
|
||||
import android.companion.virtual.audio.IAudioSessionCallback;
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.RemoteException;
|
||||
import android.util.ArraySet;
|
||||
import android.util.Slog;
|
||||
|
||||
import com.android.internal.annotations.GuardedBy;
|
||||
import com.android.internal.annotations.VisibleForTesting;
|
||||
import com.android.server.companion.virtual.GenericWindowPolicyController;
|
||||
import com.android.server.companion.virtual.GenericWindowPolicyController.RunningAppsChangedListener;
|
||||
import com.android.server.companion.virtual.audio.AudioPlaybackDetector.AudioPlaybackCallback;
|
||||
import com.android.server.companion.virtual.audio.AudioRecordingDetector.AudioRecordingCallback;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Manages audio streams associated with a {@link VirtualAudioDevice}. Responsible for monitoring
|
||||
* running applications and playback configuration changes in order to correctly re-route audio and
|
||||
* then notify clients of these changes.
|
||||
*/
|
||||
public final class VirtualAudioController implements AudioPlaybackCallback,
|
||||
AudioRecordingCallback, RunningAppsChangedListener {
|
||||
private static final String TAG = "VirtualAudioController";
|
||||
private static final int UPDATE_REROUTING_APPS_DELAY_MS = 2000;
|
||||
|
||||
private final Context mContext;
|
||||
private final Handler mHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable mUpdateAudioRoutingRunnable = this::notifyAppsNeedingAudioRoutingChanged;
|
||||
private final AudioPlaybackDetector mAudioPlaybackDetector;
|
||||
private final AudioRecordingDetector mAudioRecordingDetector;
|
||||
private final Object mLock = new Object();
|
||||
@GuardedBy("mLock")
|
||||
private final ArraySet<Integer> mRunningAppUids = new ArraySet<>();
|
||||
@GuardedBy("mLock")
|
||||
private ArraySet<Integer> mPlayingAppUids = new ArraySet<>();
|
||||
private GenericWindowPolicyController mGenericWindowPolicyController;
|
||||
private final Object mCallbackLock = new Object();
|
||||
@Nullable
|
||||
@GuardedBy("mCallbackLock")
|
||||
private IAudioSessionCallback mCallback;
|
||||
|
||||
public VirtualAudioController(Context context) {
|
||||
mContext = context;
|
||||
mAudioPlaybackDetector = new AudioPlaybackDetector(context);
|
||||
mAudioRecordingDetector = new AudioRecordingDetector(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to listen to running applications and audio configuration changes on virtual display
|
||||
* for audio capture and injection.
|
||||
*/
|
||||
public void startListening(
|
||||
@NonNull GenericWindowPolicyController genericWindowPolicyController,
|
||||
@Nullable IAudioSessionCallback callback) {
|
||||
mGenericWindowPolicyController = genericWindowPolicyController;
|
||||
mGenericWindowPolicyController.setRunningAppsChangedListener(/* listener= */ this);
|
||||
synchronized (mCallbackLock) {
|
||||
mCallback = callback;
|
||||
}
|
||||
synchronized (mLock) {
|
||||
mRunningAppUids.clear();
|
||||
mPlayingAppUids.clear();
|
||||
}
|
||||
mAudioPlaybackDetector.register(/* callback= */ this);
|
||||
mAudioRecordingDetector.register(/* callback= */ this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listening to running applications and audio configuration changes on virtual display
|
||||
* for audio capture and injection.
|
||||
*/
|
||||
public void stopListening() {
|
||||
if (mHandler.hasCallbacks(mUpdateAudioRoutingRunnable)) {
|
||||
mHandler.removeCallbacks(mUpdateAudioRoutingRunnable);
|
||||
}
|
||||
mAudioPlaybackDetector.unregister();
|
||||
mAudioRecordingDetector.unregister();
|
||||
if (mGenericWindowPolicyController != null) {
|
||||
mGenericWindowPolicyController.setRunningAppsChangedListener(/* listener= */ null);
|
||||
mGenericWindowPolicyController = null;
|
||||
}
|
||||
synchronized (mCallbackLock) {
|
||||
mCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRunningAppsChanged(ArraySet<Integer> runningUids) {
|
||||
synchronized (mLock) {
|
||||
if (mRunningAppUids.equals(runningUids)) {
|
||||
// Ignore no-op events.
|
||||
return;
|
||||
}
|
||||
mRunningAppUids.clear();
|
||||
mRunningAppUids.addAll(runningUids);
|
||||
|
||||
ArraySet<Integer> oldPlayingAppUids = mPlayingAppUids;
|
||||
|
||||
// Update the list of playing apps after caching the old list, and before checking if
|
||||
// the list of playing apps is empty. This is a subset of the running apps, so we need
|
||||
// to update this here as well.
|
||||
AudioManager audioManager = mContext.getSystemService(AudioManager.class);
|
||||
List<AudioPlaybackConfiguration> configs =
|
||||
audioManager.getActivePlaybackConfigurations();
|
||||
mPlayingAppUids = findPlayingAppUids(configs, mRunningAppUids);
|
||||
|
||||
// Do not change rerouted applications while any application is playing, or the sound
|
||||
// will be leaked from phone during the transition. Delay the change until we detect
|
||||
// there is no application is playing in onPlaybackConfigChanged().
|
||||
if (!mPlayingAppUids.isEmpty()) {
|
||||
Slog.i(TAG, "Audio is playing, do not change rerouted apps");
|
||||
return;
|
||||
}
|
||||
|
||||
// An application previously playing audio was removed from the display.
|
||||
if (!oldPlayingAppUids.isEmpty()) {
|
||||
// Delay changing the rerouted application when the last application playing audio
|
||||
// was removed from virtual device, or the sound will be leaked from phone side
|
||||
// during the transition.
|
||||
Slog.i(TAG, "The last playing app removed, delay change rerouted apps");
|
||||
if (mHandler.hasCallbacks(mUpdateAudioRoutingRunnable)) {
|
||||
mHandler.removeCallbacks(mUpdateAudioRoutingRunnable);
|
||||
}
|
||||
mHandler.postDelayed(mUpdateAudioRoutingRunnable, UPDATE_REROUTING_APPS_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal case with no application playing, just update routing.
|
||||
notifyAppsNeedingAudioRoutingChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackConfigChanged(List<AudioPlaybackConfiguration> configs) {
|
||||
updatePlayingApplications(configs);
|
||||
|
||||
List<AudioPlaybackConfiguration> audioPlaybackConfigurations;
|
||||
synchronized (mLock) {
|
||||
// Filter configurations of applications running on virtual device.
|
||||
audioPlaybackConfigurations = findPlaybackConfigurations(configs, mRunningAppUids);
|
||||
}
|
||||
synchronized (mCallbackLock) {
|
||||
if (mCallback != null) {
|
||||
try {
|
||||
mCallback.onPlaybackConfigChanged(audioPlaybackConfigurations);
|
||||
} catch (RemoteException e) {
|
||||
Slog.e(TAG, "RemoteException when calling onPlaybackConfigChanged", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
@Override
|
||||
public void onRecordingConfigChanged(List<AudioRecordingConfiguration> configs) {
|
||||
List<AudioRecordingConfiguration> audioRecordingConfigurations;
|
||||
synchronized (mLock) {
|
||||
// Filter configurations of applications running on virtual device.
|
||||
audioRecordingConfigurations = findRecordingConfigurations(configs, mRunningAppUids);
|
||||
}
|
||||
synchronized (mCallbackLock) {
|
||||
if (mCallback != null) {
|
||||
try {
|
||||
mCallback.onRecordingConfigChanged(audioRecordingConfigurations);
|
||||
} catch (RemoteException e) {
|
||||
Slog.e(TAG, "RemoteException when calling onRecordingConfigChanged", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePlayingApplications(List<AudioPlaybackConfiguration> configs) {
|
||||
synchronized (mLock) {
|
||||
ArraySet<Integer> playingAppUids = findPlayingAppUids(configs, mRunningAppUids);
|
||||
if (mPlayingAppUids.equals(playingAppUids)) {
|
||||
return;
|
||||
}
|
||||
mPlayingAppUids = playingAppUids;
|
||||
}
|
||||
|
||||
// Updated rerouted apps, even if the app is already playing. It originally should be done
|
||||
// when onRunningAppsChanged() is called, but we don't want to interrupt the audio
|
||||
// streaming and cause the sound leak from phone when it's playing, so delay until here.
|
||||
notifyAppsNeedingAudioRoutingChanged();
|
||||
}
|
||||
|
||||
private void notifyAppsNeedingAudioRoutingChanged() {
|
||||
if (mHandler.hasCallbacks(mUpdateAudioRoutingRunnable)) {
|
||||
mHandler.removeCallbacks(mUpdateAudioRoutingRunnable);
|
||||
}
|
||||
|
||||
int[] runningUids;
|
||||
synchronized (mLock) {
|
||||
runningUids = new int[mRunningAppUids.size()];
|
||||
for (int i = 0; i < mRunningAppUids.size(); i++) {
|
||||
runningUids[i] = mRunningAppUids.valueAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized (mCallbackLock) {
|
||||
if (mCallback != null) {
|
||||
try {
|
||||
mCallback.onAppsNeedingAudioRoutingChanged(runningUids);
|
||||
} catch (RemoteException e) {
|
||||
Slog.e(TAG, "RemoteException when calling updateReroutingApps", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds uid of playing applications from the given running applications.
|
||||
*
|
||||
* @param configs a list of playback configs which get from {@link AudioManager}
|
||||
*/
|
||||
private static ArraySet<Integer> findPlayingAppUids(List<AudioPlaybackConfiguration> configs,
|
||||
ArraySet<Integer> runningAppUids) {
|
||||
ArraySet<Integer> playingAppUids = new ArraySet<>();
|
||||
for (AudioPlaybackConfiguration config : configs) {
|
||||
if (runningAppUids.contains(config.getClientUid())
|
||||
&& config.getPlayerState() == PLAYER_STATE_STARTED) {
|
||||
playingAppUids.add(config.getClientUid());
|
||||
}
|
||||
}
|
||||
return playingAppUids;
|
||||
}
|
||||
|
||||
/** Finds a list of {@link AudioPlaybackConfiguration} for the given running applications. */
|
||||
private static List<AudioPlaybackConfiguration> findPlaybackConfigurations(
|
||||
List<AudioPlaybackConfiguration> configs,
|
||||
ArraySet<Integer> runningAppUids) {
|
||||
List<AudioPlaybackConfiguration> runningConfigs = new ArrayList<>();
|
||||
for (AudioPlaybackConfiguration config : configs) {
|
||||
if (runningAppUids.contains(config.getClientUid())) {
|
||||
runningConfigs.add(config);
|
||||
}
|
||||
}
|
||||
return runningConfigs;
|
||||
}
|
||||
|
||||
/** Finds a list of {@link AudioRecordingConfiguration} for the given running applications. */
|
||||
@RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_ROUTING)
|
||||
private static List<AudioRecordingConfiguration> findRecordingConfigurations(
|
||||
List<AudioRecordingConfiguration> configs, ArraySet<Integer> runningAppUids) {
|
||||
List<AudioRecordingConfiguration> runningConfigs = new ArrayList<>();
|
||||
for (AudioRecordingConfiguration config : configs) {
|
||||
if (runningAppUids.contains(config.getClientUid())) {
|
||||
runningConfigs.add(config);
|
||||
}
|
||||
}
|
||||
return runningConfigs;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasPendingRunnable() {
|
||||
return mHandler.hasCallbacks(mUpdateAudioRoutingRunnable);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void addPlayingAppsForTesting(int appUid) {
|
||||
synchronized (mLock) {
|
||||
mPlayingAppUids.add(appUid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.android.server.companion.virtual;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static com.google.common.truth.Truth.assertWithMessage;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -39,6 +40,7 @@ import android.app.admin.DevicePolicyManager;
|
||||
import android.companion.AssociationInfo;
|
||||
import android.companion.virtual.IVirtualDeviceActivityListener;
|
||||
import android.companion.virtual.VirtualDeviceParams;
|
||||
import android.companion.virtual.audio.IAudioSessionCallback;
|
||||
import android.content.Context;
|
||||
import android.content.ContextWrapper;
|
||||
import android.graphics.Point;
|
||||
@@ -111,6 +113,8 @@ public class VirtualDeviceManagerServiceTest {
|
||||
@Mock
|
||||
IThermalService mIThermalServiceMock;
|
||||
private PowerManager mPowerManager;
|
||||
@Mock
|
||||
private IAudioSessionCallback mCallback;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
@@ -249,6 +253,12 @@ public class VirtualDeviceManagerServiceTest {
|
||||
VENDOR_ID, PRODUCT_ID, BINDER, new Point(WIDTH, HEIGHT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioSessionStarting_noDisplay_failsSecurityException() {
|
||||
assertThrows(SecurityException.class,
|
||||
() -> mDeviceImpl.onAudioSessionStarting(DISPLAY_ID, mCallback));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createVirtualKeyboard_noPermission_failsSecurityException() {
|
||||
mDeviceImpl.mVirtualDisplayIds.add(DISPLAY_ID);
|
||||
@@ -282,6 +292,22 @@ public class VirtualDeviceManagerServiceTest {
|
||||
VENDOR_ID, PRODUCT_ID, BINDER, new Point(WIDTH, HEIGHT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioSessionStarting_noPermission_failsSecurityException() {
|
||||
mDeviceImpl.mVirtualDisplayIds.add(DISPLAY_ID);
|
||||
doCallRealMethod().when(mContext).enforceCallingOrSelfPermission(
|
||||
eq(Manifest.permission.CREATE_VIRTUAL_DEVICE), anyString());
|
||||
assertThrows(SecurityException.class,
|
||||
() -> mDeviceImpl.onAudioSessionStarting(DISPLAY_ID, mCallback));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioSessionEnded_noPermission_failsSecurityException() {
|
||||
doCallRealMethod().when(mContext).enforceCallingOrSelfPermission(
|
||||
eq(Manifest.permission.CREATE_VIRTUAL_DEVICE), anyString());
|
||||
assertThrows(SecurityException.class, () -> mDeviceImpl.onAudioSessionEnded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createVirtualKeyboard_hasDisplay_obtainFileDescriptor() {
|
||||
mDeviceImpl.mVirtualDisplayIds.add(DISPLAY_ID);
|
||||
@@ -315,6 +341,25 @@ public class VirtualDeviceManagerServiceTest {
|
||||
eq(PRODUCT_ID), anyString(), eq(HEIGHT), eq(WIDTH));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioSessionStarting_hasVirtualAudioController() {
|
||||
mDeviceImpl.onVirtualDisplayCreatedLocked(DISPLAY_ID);
|
||||
|
||||
mDeviceImpl.onAudioSessionStarting(DISPLAY_ID, mCallback);
|
||||
|
||||
assertThat(mDeviceImpl.getVirtualAudioControllerForTesting()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onAudioSessionEnded_noVirtualAudioController() {
|
||||
mDeviceImpl.onVirtualDisplayCreatedLocked(DISPLAY_ID);
|
||||
mDeviceImpl.onAudioSessionStarting(DISPLAY_ID, mCallback);
|
||||
|
||||
mDeviceImpl.onAudioSessionEnded();
|
||||
|
||||
assertThat(mDeviceImpl.getVirtualAudioControllerForTesting()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendKeyEvent_noFd() {
|
||||
assertThrows(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (C) 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.companion.virtual.audio;
|
||||
|
||||
import static android.media.AudioAttributes.FLAG_SECURE;
|
||||
import static android.media.AudioPlaybackConfiguration.PLAYER_STATE_STARTED;
|
||||
import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import android.companion.virtual.audio.IAudioSessionCallback;
|
||||
import android.content.Context;
|
||||
import android.content.ContextWrapper;
|
||||
import android.media.AudioPlaybackConfiguration;
|
||||
import android.media.AudioRecordingConfiguration;
|
||||
import android.media.MediaRecorder;
|
||||
import android.media.PlayerBase;
|
||||
import android.os.Parcel;
|
||||
import android.os.RemoteException;
|
||||
import android.util.ArraySet;
|
||||
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import com.android.server.companion.virtual.GenericWindowPolicyController;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class VirtualAudioControllerTest {
|
||||
private static final int APP1_UID = 100;
|
||||
private static final int APP2_UID = 200;
|
||||
|
||||
private Context mContext;
|
||||
private VirtualAudioController mVirtualAudioController;
|
||||
private GenericWindowPolicyController mGenericWindowPolicyController;
|
||||
@Mock IAudioSessionCallback mCallback;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
mContext = Mockito.spy(new ContextWrapper(InstrumentationRegistry.getTargetContext()));
|
||||
mVirtualAudioController = new VirtualAudioController(mContext);
|
||||
mGenericWindowPolicyController = new GenericWindowPolicyController(
|
||||
FLAG_SECURE,
|
||||
SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS,
|
||||
/* allowedUsers= */ new ArraySet<>(),
|
||||
/* allowedActivities= */ new ArraySet<>(),
|
||||
/* blockedActivities= */ new ArraySet<>(),
|
||||
/* activityListener= */null,
|
||||
/* activityBlockedCallback= */ null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startListening_receivesCallback() throws RemoteException {
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
int[] appUids = new int[] {APP1_UID};
|
||||
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
|
||||
mGenericWindowPolicyController.onRunningAppsChanged(runningUids);
|
||||
verify(mCallback).onAppsNeedingAudioRoutingChanged(appUids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stopListening_removesCallback() throws RemoteException {
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
int[] appUids = new int[] {APP1_UID};
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
|
||||
mVirtualAudioController.stopListening();
|
||||
|
||||
mGenericWindowPolicyController.onRunningAppsChanged(runningUids);
|
||||
verify(mCallback, never()).onAppsNeedingAudioRoutingChanged(appUids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onRunningAppsChanged_notifiesAudioRoutingModified() throws RemoteException {
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(runningUids);
|
||||
|
||||
int[] appUids = new int[] {APP1_UID};
|
||||
verify(mCallback).onAppsNeedingAudioRoutingChanged(appUids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onRunningAppsChanged_audioIsPlaying_doesNothing() throws RemoteException {
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
mVirtualAudioController.addPlayingAppsForTesting(APP2_UID);
|
||||
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(runningUids);
|
||||
|
||||
int[] appUids = new int[]{APP1_UID};
|
||||
verify(mCallback, never()).onAppsNeedingAudioRoutingChanged(appUids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onRunningAppsChanged_lastPlayingAppRemoved_delaysReroutingAudio() {
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
runningUids.add(APP2_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(runningUids);
|
||||
mVirtualAudioController.addPlayingAppsForTesting(APP2_UID);
|
||||
|
||||
ArraySet<Integer> appUids = new ArraySet<>();
|
||||
appUids.add(APP1_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(appUids);
|
||||
|
||||
assertThat(mVirtualAudioController.hasPendingRunnable()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onPlaybackConfigChanged_sendsCallback() throws RemoteException {
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(runningUids);
|
||||
List<AudioPlaybackConfiguration> configs = createPlaybackConfigurations(runningUids);
|
||||
|
||||
mVirtualAudioController.onPlaybackConfigChanged(configs);
|
||||
|
||||
verify(mCallback).onPlaybackConfigChanged(configs);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onRecordingConfigChanged_sendsCallback() throws RemoteException {
|
||||
mVirtualAudioController.startListening(mGenericWindowPolicyController, mCallback);
|
||||
ArraySet<Integer> runningUids = new ArraySet<>();
|
||||
runningUids.add(APP1_UID);
|
||||
mVirtualAudioController.onRunningAppsChanged(runningUids);
|
||||
List<AudioRecordingConfiguration> configs = createRecordingConfigurations(runningUids);
|
||||
|
||||
mVirtualAudioController.onRecordingConfigChanged(configs);
|
||||
|
||||
verify(mCallback).onRecordingConfigChanged(configs);
|
||||
}
|
||||
|
||||
private List<AudioPlaybackConfiguration> createPlaybackConfigurations(
|
||||
ArraySet<Integer> appUids) {
|
||||
List<AudioPlaybackConfiguration> configs = new ArrayList<>();
|
||||
for (int appUid : appUids) {
|
||||
PlayerBase.PlayerIdCard playerIdCard =
|
||||
PlayerBase.PlayerIdCard.CREATOR.createFromParcel(Parcel.obtain());
|
||||
AudioPlaybackConfiguration audioPlaybackConfiguration =
|
||||
new AudioPlaybackConfiguration(
|
||||
playerIdCard, /* piid= */ 1000, appUid, /* pid= */ 1000);
|
||||
audioPlaybackConfiguration.handleStateEvent(PLAYER_STATE_STARTED, /* deviceId= */1);
|
||||
configs.add(audioPlaybackConfiguration);
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
|
||||
private List<AudioRecordingConfiguration> createRecordingConfigurations(
|
||||
ArraySet<Integer> appUids) {
|
||||
List<AudioRecordingConfiguration> configs = new ArrayList<>();
|
||||
for (int appUid : appUids) {
|
||||
AudioRecordingConfiguration audioRecordingConfiguration =
|
||||
new AudioRecordingConfiguration(
|
||||
/* uid= */ appUid,
|
||||
/* session= */ 1000,
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
/* clientFormat= */ null,
|
||||
/* devFormat= */ null,
|
||||
/* patchHandle= */ 1000,
|
||||
"com.android.example");
|
||||
configs.add(audioRecordingConfiguration);
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user