AudioService: add test APIs for audio focus and ducking
Note: all new test methods end with "ForTest" so there is
no ambiguity about the purpose of those methods.
Add test methods to protect focus-based tests from external
audio focus uses (e.g. NotificationManager playing a
notification on the DUT during a test).
Add test methods to read ducking-related timing values, and
the list of ducked UIDs.
Add more debug messages (disabled by default).
Bug: 286495078
Test: atest CtsMediaAudioTestCases:android.media.audio.cts.AudioFocusTest#testDuckedUidsAfterMediaSpeech
Test: atest CtsMediaAudioTestCases:android.media.audio.cts.AudioFocusTest#testDuckedUidsAfterMediaMusic
Change-Id: Ie2fc243eecdc21f3b0718f6392e7345d3fb0ae07
diff --git a/core/api/test-current.txt b/core/api/test-current.txt
index e61c39f..b1c3b4ae 100644
--- a/core/api/test-current.txt
+++ b/core/api/test-current.txt
@@ -1877,6 +1877,8 @@
public class AudioManager {
method @RequiresPermission("android.permission.QUERY_AUDIO_STATE") public int abandonAudioFocusForTest(@NonNull android.media.AudioFocusRequest, @NonNull String);
+ method @RequiresPermission("Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED") public boolean enterAudioFocusFreezeForTest(@NonNull java.util.List<java.lang.Integer>);
+ method @RequiresPermission("Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED") public boolean exitAudioFocusFreezeForTest();
method @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED) public void forceComputeCsdOnAllDevices(boolean);
method @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED) public void forceUseFrameworkMel(boolean);
method @NonNull @RequiresPermission(android.Manifest.permission.CALL_AUDIO_INTERCEPTION) public android.media.AudioRecord getCallDownlinkExtractionAudioRecord(@NonNull android.media.AudioFormat);
@@ -1884,6 +1886,9 @@
method @RequiresPermission(android.Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED) public float getCsd();
method @Nullable public static android.media.AudioDeviceInfo getDeviceInfoFromType(int);
method @IntRange(from=0) @RequiresPermission("android.permission.QUERY_AUDIO_STATE") public long getFadeOutDurationOnFocusLossMillis(@NonNull android.media.AudioAttributes);
+ method @NonNull @RequiresPermission("android.permission.QUERY_AUDIO_STATE") public java.util.List<java.lang.Integer> getFocusDuckedUidsForTest();
+ method @RequiresPermission("android.permission.QUERY_AUDIO_STATE") public long getFocusFadeOutDurationForTest();
+ method @RequiresPermission("android.permission.QUERY_AUDIO_STATE") public long getFocusUnmuteDelayAfterFadeOutForTest();
method @Nullable public static android.media.AudioHalVersionInfo getHalVersion();
method public static final int[] getPublicStreamTypes();
method @NonNull public java.util.List<java.lang.Integer> getReportedSurroundFormats();
diff --git a/media/java/android/media/AudioManager.java b/media/java/android/media/AudioManager.java
index c3087bc..e2f4072 100644
--- a/media/java/android/media/AudioManager.java
+++ b/media/java/android/media/AudioManager.java
@@ -4739,6 +4739,97 @@
/**
* @hide
+ * Test method to return the list of UIDs currently marked as ducked because of their
+ * audio focus status
+ * @return the list of UIDs, can be empty when no app is being ducked.
+ */
+ @TestApi
+ @RequiresPermission("android.permission.QUERY_AUDIO_STATE")
+ public @NonNull List<Integer> getFocusDuckedUidsForTest() {
+ try {
+ return getService().getFocusDuckedUidsForTest();
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * @hide
+ * Test method to return the duration of the fade out applied on the players of a focus loser
+ * @return the fade out duration in ms
+ */
+ @TestApi
+ @RequiresPermission("android.permission.QUERY_AUDIO_STATE")
+ public long getFocusFadeOutDurationForTest() {
+ try {
+ return getService().getFocusFadeOutDurationForTest();
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * @hide
+ * Test method to return the length of time after a fade-out before the focus loser is unmuted
+ * (and is faded back in).
+ * @return the time gap after a fade-out completion on focus loss, and fade-in start in ms.
+ */
+ @TestApi
+ @RequiresPermission("android.permission.QUERY_AUDIO_STATE")
+ public long getFocusUnmuteDelayAfterFadeOutForTest() {
+ try {
+ return getService().getFocusUnmuteDelayAfterFadeOutForTest();
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * @hide
+ * Test method to start preventing applications from requesting audio focus during a test,
+ * which could interfere with the functionality/behavior under test.
+ * Calling this method needs to be paired with a call to {@link #exitAudioFocusFreezeForTest}
+ * when the testing is done. If this is not the case (e.g. in case of a test crash),
+ * a death observer mechanism will ensure the system is not left in a bad state, but this should
+ * not be relied on when implementing tests.
+ * @param exemptedUids a list of UIDs that are exempt from the freeze. This would for instance
+ * be those of the test runner and other players used in the test, or the "fake" UIDs used
+ * for testing with {@link #requestAudioFocusForTest(AudioFocusRequest, String, int, int)}.
+ * @return true if the focus freeze mode is successfully entered, false if there was an issue,
+ * such as another freeze in place at the time of invocation.
+ * A false result should result in a test failure as this would indicate the system is not
+ * in a proper state with a predictable behavior for audio focus management.
+ */
+ @TestApi
+ @RequiresPermission("Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ public boolean enterAudioFocusFreezeForTest(@NonNull List<Integer> exemptedUids) {
+ Objects.requireNonNull(exemptedUids);
+ try {
+ final int[] uids = exemptedUids.stream().mapToInt(Integer::intValue).toArray();
+ return getService().enterAudioFocusFreezeForTest(mICallBack, uids);
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * @hide
+ * Test method to end preventing applications from requesting audio focus during a test.
+ * @return true if the focus freeze mode is successfully exited, false if there was an issue,
+ * such as the freeze already having ended, or not started.
+ */
+ @TestApi
+ @RequiresPermission("Manifest.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ public boolean exitAudioFocusFreezeForTest() {
+ try {
+ return getService().exitAudioFocusFreezeForTest(mICallBack);
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * @hide
* Request or lock audio focus.
* This method is to be used by system components that have registered an
* {@link android.media.audiopolicy.AudioPolicy} to request audio focus, but also to "lock" it
diff --git a/media/java/android/media/IAudioService.aidl b/media/java/android/media/IAudioService.aidl
index 5cbb4e5..e45ef40 100644
--- a/media/java/android/media/IAudioService.aidl
+++ b/media/java/android/media/IAudioService.aidl
@@ -529,6 +529,23 @@
long getFadeOutDurationOnFocusLossMillis(in AudioAttributes aa);
+ @EnforcePermission("QUERY_AUDIO_STATE")
+ /* Returns a List<Integer> */
+ @SuppressWarnings(value = {"untyped-collection"})
+ List getFocusDuckedUidsForTest();
+
+ @EnforcePermission("QUERY_AUDIO_STATE")
+ long getFocusFadeOutDurationForTest();
+
+ @EnforcePermission("QUERY_AUDIO_STATE")
+ long getFocusUnmuteDelayAfterFadeOutForTest();
+
+ @EnforcePermission("MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ boolean enterAudioFocusFreezeForTest(IBinder cb, in int[] uids);
+
+ @EnforcePermission("MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ boolean exitAudioFocusFreezeForTest(IBinder cb);
+
void registerModeDispatcher(IAudioModeDispatcher dispatcher);
oneway void unregisterModeDispatcher(IAudioModeDispatcher dispatcher);
diff --git a/services/core/java/com/android/server/audio/AudioService.java b/services/core/java/com/android/server/audio/AudioService.java
index 0bc0472..5f15995 100644
--- a/services/core/java/com/android/server/audio/AudioService.java
+++ b/services/core/java/com/android/server/audio/AudioService.java
@@ -43,6 +43,7 @@
import static com.android.server.utils.EventLogger.Event.ALOGW;
import android.Manifest;
+import android.annotation.EnforcePermission;
import android.annotation.IntDef;
import android.annotation.IntRange;
import android.annotation.NonNull;
@@ -10005,6 +10006,14 @@
return mMediaFocusControl.abandonAudioFocus(fd, clientId, aa, callingPackageName);
}
+ /** see {@link AudioManager#getFocusDuckedUidsForTest()} */
+ @Override
+ @EnforcePermission("android.permission.QUERY_AUDIO_STATE")
+ public @NonNull List<Integer> getFocusDuckedUidsForTest() {
+ super.getFocusDuckedUidsForTest_enforcePermission();
+ return mPlaybackMonitor.getFocusDuckedUids();
+ }
+
public void unregisterAudioFocusClient(String clientId) {
new MediaMetrics.Item(mMetricsId + "focus")
.set(MediaMetrics.Property.CLIENT_NAME, clientId)
@@ -10021,6 +10030,68 @@
return mMediaFocusControl.getFocusRampTimeMs(focusGain, attr);
}
+ /**
+ * Test method to return the duration of the fade out applied on the players of a focus loser
+ * @see AudioManager#getFocusFadeOutDurationForTest()
+ * @return the fade out duration, in ms
+ */
+ @EnforcePermission("android.permission.QUERY_AUDIO_STATE")
+ public long getFocusFadeOutDurationForTest() {
+ super.getFocusFadeOutDurationForTest_enforcePermission();
+ return mMediaFocusControl.getFocusFadeOutDurationForTest();
+ }
+
+ /**
+ * Test method to return the length of time after a fade out before the focus loser is unmuted
+ * (and is faded back in).
+ * @see AudioManager#getFocusUnmuteDelayAfterFadeOutForTest()
+ * @return the time gap after a fade out completion on focus loss, and fade in start, in ms
+ */
+ @Override
+ @EnforcePermission("android.permission.QUERY_AUDIO_STATE")
+ public long getFocusUnmuteDelayAfterFadeOutForTest() {
+ super.getFocusUnmuteDelayAfterFadeOutForTest_enforcePermission();
+ return mMediaFocusControl.getFocusUnmuteDelayAfterFadeOutForTest();
+ }
+
+ /**
+ * Test method to start preventing applications from requesting audio focus during a test,
+ * which could interfere with the testing of the functionality/behavior under test.
+ * Calling this method needs to be paired with a call to {@link #exitAudioFocusFreezeForTest}
+ * when the testing is done. If this is not the case (e.g. in case of a test crash),
+ * a death observer mechanism will ensure the system is not left in a bad state, but this should
+ * not be relied on when implementing tests.
+ * @see AudioManager#enterAudioFocusFreezeForTest(List)
+ * @param cb IBinder to track the death of the client of this method
+ * @param exemptedUids a list of UIDs that are exempt from the freeze. This would for instance
+ * be those of the test runner and other players used in the test
+ * @return true if the focus freeze mode is successfully entered, false if there was an issue,
+ * such as another freeze currently used.
+ */
+ @Override
+ @EnforcePermission("android.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ public boolean enterAudioFocusFreezeForTest(IBinder cb, int[] exemptedUids) {
+ super.enterAudioFocusFreezeForTest_enforcePermission();
+ Objects.requireNonNull(exemptedUids);
+ Objects.requireNonNull(cb);
+ return mMediaFocusControl.enterAudioFocusFreezeForTest(cb, exemptedUids);
+ }
+
+ /**
+ * Test method to end preventing applications from requesting audio focus during a test.
+ * @see AudioManager#exitAudioFocusFreezeForTest()
+ * @param cb IBinder identifying the client of this method
+ * @return true if the focus freeze mode is successfully exited, false if there was an issue,
+ * such as the freeze already having ended, or not started.
+ */
+ @Override
+ @EnforcePermission("android.permission.MODIFY_AUDIO_SETTINGS_PRIVILEGED")
+ public boolean exitAudioFocusFreezeForTest(IBinder cb) {
+ super.exitAudioFocusFreezeForTest_enforcePermission();
+ Objects.requireNonNull(cb);
+ return mMediaFocusControl.exitAudioFocusFreezeForTest(cb);
+ }
+
/** only public for mocking/spying, do not call outside of AudioService */
@VisibleForTesting
public boolean hasAudioFocusUsers() {
@@ -10028,6 +10099,7 @@
}
/** see {@link AudioManager#getFadeOutDurationOnFocusLossMillis(AudioAttributes)} */
+ @Override
public long getFadeOutDurationOnFocusLossMillis(AudioAttributes aa) {
if (!enforceQueryAudioStateForTest("fade out duration")) {
return 0;
diff --git a/services/core/java/com/android/server/audio/FocusRequester.java b/services/core/java/com/android/server/audio/FocusRequester.java
index 88a4b05..010d5f4 100644
--- a/services/core/java/com/android/server/audio/FocusRequester.java
+++ b/services/core/java/com/android/server/audio/FocusRequester.java
@@ -43,7 +43,7 @@
public class FocusRequester {
// on purpose not using this classe's name, as it will only be used from MediaFocusControl
- private static final String TAG = "MediaFocusControl";
+ private static final String TAG = "FocusRequester";
private static final boolean DEBUG = false;
private AudioFocusDeathHandler mDeathHandler; // may be null
@@ -340,6 +340,9 @@
@GuardedBy("MediaFocusControl.mAudioFocusLock")
boolean handleFocusLossFromGain(int focusGain, final FocusRequester frWinner, boolean forceDuck)
{
+ if (DEBUG) {
+ Log.i(TAG, "handleFocusLossFromGain for " + mClientId + " gain:" + focusGain);
+ }
final int focusLoss = focusLossForGainRequest(focusGain);
handleFocusLoss(focusLoss, frWinner, forceDuck);
return (focusLoss == AudioManager.AUDIOFOCUS_LOSS);
@@ -378,6 +381,9 @@
@GuardedBy("MediaFocusControl.mAudioFocusLock")
void handleFocusLoss(int focusLoss, @Nullable final FocusRequester frWinner, boolean forceDuck)
{
+ if (DEBUG) {
+ Log.i(TAG, "handleFocusLoss for " + mClientId + " loss:" + focusLoss);
+ }
try {
if (focusLoss != mFocusLossReceived) {
mFocusLossReceived = focusLoss;
@@ -427,6 +433,9 @@
toAudioFocusInfo(), true /* wasDispatched */);
mFocusLossWasNotified = true;
fd.dispatchAudioFocusChange(mFocusLossReceived, mClientId);
+ } else if (DEBUG) {
+ Log.i(TAG, "NOT dispatching " + focusChangeToString(mFocusLossReceived)
+ + " to " + mClientId + " no IAudioFocusDispatcher");
}
}
} catch (android.os.RemoteException e) {
diff --git a/services/core/java/com/android/server/audio/MediaFocusControl.java b/services/core/java/com/android/server/audio/MediaFocusControl.java
index b218096..65f6c9b 100644
--- a/services/core/java/com/android/server/audio/MediaFocusControl.java
+++ b/services/core/java/com/android/server/audio/MediaFocusControl.java
@@ -45,6 +45,7 @@
import java.io.PrintWriter;
import java.text.DateFormat;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
@@ -122,6 +123,23 @@
dumpMultiAudioFocus(pw);
}
+ /**
+ * Test method to return the duration of the fade out applied on the players of a focus loser
+ * @return the fade out duration in ms
+ */
+ public long getFocusFadeOutDurationForTest() {
+ return FadeOutManager.FADE_OUT_DURATION_MS;
+ }
+
+ /**
+ * Test method to return the length of time after a fade out before the focus loser is unmuted
+ * (and is faded back in).
+ * @return the time gap after a fade out completion on focus loss, and fade in start in ms
+ */
+ public long getFocusUnmuteDelayAfterFadeOutForTest() {
+ return FadeOutManager.DELAY_FADE_IN_OFFENDERS_MS;
+ }
+
//=================================================================
// PlayerFocusEnforcer implementation
@Override
@@ -304,17 +322,26 @@
@GuardedBy("mAudioFocusLock")
private void propagateFocusLossFromGain_syncAf(int focusGain, final FocusRequester fr,
boolean forceDuck) {
+ if (DEBUG) {
+ Log.i(TAG, "propagateFocusLossFromGain_syncAf gain:" + focusGain);
+ }
final List<String> clientsToRemove = new LinkedList<String>();
// going through the audio focus stack to signal new focus, traversing order doesn't
// matter as all entries respond to the same external focus gain
if (!mFocusStack.empty()) {
for (FocusRequester focusLoser : mFocusStack) {
+ if (DEBUG) {
+ Log.i(TAG, "propagateFocusLossFromGain_syncAf checking client:"
+ + focusLoser.getClientId());
+ }
final boolean isDefinitiveLoss =
focusLoser.handleFocusLossFromGain(focusGain, fr, forceDuck);
if (isDefinitiveLoss) {
clientsToRemove.add(focusLoser.getClientId());
}
}
+ } else if (DEBUG) {
+ Log.i(TAG, "propagateFocusLossFromGain_syncAf empty stack");
}
if (mMultiAudioFocusEnabled && !mMultiAudioFocusList.isEmpty()) {
@@ -370,6 +397,9 @@
@GuardedBy("mAudioFocusLock")
private void removeFocusStackEntry(String clientToRemove, boolean signal,
boolean notifyFocusFollowers) {
+ if (DEBUG) {
+ Log.i(TAG, "removeFocusStackEntry client:" + clientToRemove);
+ }
AudioFocusInfo abandonSource = null;
// is the current top of the focus stack abandoning focus? (because of request, not death)
if (!mFocusStack.empty() && mFocusStack.peek().hasSameClient(clientToRemove))
@@ -1000,6 +1030,24 @@
}
synchronized(mAudioFocusLock) {
+ // check whether a focus freeze is in place and filter
+ if (isFocusFrozenForTest()) {
+ int focusRequesterUid;
+ if ((flags & AudioManager.AUDIOFOCUS_FLAG_TEST)
+ == AudioManager.AUDIOFOCUS_FLAG_TEST) {
+ focusRequesterUid = testUid;
+ } else {
+ focusRequesterUid = Binder.getCallingUid();
+ }
+ if (isFocusFrozenForTestForUid(focusRequesterUid)) {
+ Log.i(TAG, "requestAudioFocus: focus frozen for test for uid:"
+ + focusRequesterUid);
+ return AudioManager.AUDIOFOCUS_REQUEST_FAILED;
+ }
+ Log.i(TAG, "requestAudioFocus: focus frozen for test but uid:" + focusRequesterUid
+ + " is exempt");
+ }
+
if (mFocusStack.size() > MAX_STACK_SIZE) {
Log.e(TAG, "Max AudioFocus stack size reached, failing requestAudioFocus()");
return AudioManager.AUDIOFOCUS_REQUEST_FAILED;
@@ -1191,6 +1239,110 @@
return AudioManager.AUDIOFOCUS_REQUEST_GRANTED;
}
+ /**
+ * Reference to the caller of {@link #enterAudioFocusFreezeForTest(IBinder, int[])}
+ * Will be null when there is no focus freeze for test
+ */
+ @GuardedBy("mAudioFocusLock")
+ @Nullable
+ private IBinder mFocusFreezerForTest = null;
+
+ /**
+ * The death handler for {@link #mFocusFreezerForTest}
+ * Will be null when there is no focus freeze for test
+ */
+ @GuardedBy("mAudioFocusLock")
+ @Nullable
+ private IBinder.DeathRecipient mFocusFreezerDeathHandler = null;
+
+ /**
+ * Array of UIDs exempt from focus freeze when focus is frozen for test, null during normal
+ * operations.
+ * Will be null when there is no focus freeze for test
+ */
+ @GuardedBy("mAudioFocusLock")
+ @Nullable
+ private int[] mFocusFreezeExemptUids = null;
+
+ @GuardedBy("mAudioFocusLock")
+ private boolean isFocusFrozenForTest() {
+ return (mFocusFreezerForTest != null);
+ }
+
+ /**
+ * Checks if the given UID can request focus when a focus freeze is in place for a test.
+ * Focus can be requested if focus is not frozen or if it's frozen but the UID is exempt.
+ * @param uidToCheck
+ * @return true if that UID is barred from requesting focus, false if its focus request
+ * can proceed being processed
+ */
+ @GuardedBy("mAudioFocusLock")
+ private boolean isFocusFrozenForTestForUid(int uidToCheck) {
+ if (isFocusFrozenForTest()) {
+ return false;
+ }
+ // check the list of exempts (array is not null because we're in a freeze for test
+ for (int uid : mFocusFreezeExemptUids) {
+ if (uid == uidToCheck) {
+ return false;
+ }
+ }
+ // uid was not found in the exempt list, its focus request is denied
+ return true;
+ }
+
+ protected boolean enterAudioFocusFreezeForTest(
+ @NonNull IBinder cb, @NonNull int[] exemptedUids) {
+ Log.i(TAG, "enterAudioFocusFreezeForTest UIDs exempt:" + Arrays.toString(exemptedUids));
+ synchronized (mAudioFocusLock) {
+ if (mFocusFreezerForTest != null) {
+ Log.e(TAG, "Error enterAudioFocusFreezeForTest: focus already frozen");
+ return false;
+ }
+ // new focus freeze, register death handler
+ try {
+ mFocusFreezerDeathHandler = new IBinder.DeathRecipient() {
+ @Override
+ public void binderDied() {
+ Log.i(TAG, "Audio focus freezer died, exiting focus freeze for test");
+ releaseFocusFreeze();
+ }
+ };
+ cb.linkToDeath(mFocusFreezerDeathHandler, 0);
+ mFocusFreezerForTest = cb;
+ mFocusFreezeExemptUids = exemptedUids.clone();
+ } catch (RemoteException e) {
+ // client has already died!
+ mFocusFreezerForTest = null;
+ mFocusFreezeExemptUids = null;
+ return false;
+ }
+ }
+ return true;
+ }
+
+ protected boolean exitAudioFocusFreezeForTest(@NonNull IBinder cb) {
+ synchronized (mAudioFocusLock) {
+ if (mFocusFreezerForTest != cb) {
+ Log.e(TAG, "Error exitAudioFocusFreezeForTest: "
+ + ((mFocusFreezerForTest == null)
+ ? "call to exit while not frozen"
+ : "call to exit not coming from freeze owner"));
+ return false;
+ }
+ mFocusFreezerForTest.unlinkToDeath(mFocusFreezerDeathHandler, 0);
+ releaseFocusFreeze();
+ }
+ return true;
+ }
+
+ private void releaseFocusFreeze() {
+ synchronized (mAudioFocusLock) {
+ mFocusFreezerDeathHandler = null;
+ mFocusFreezeExemptUids = null;
+ mFocusFreezerForTest = null;
+ }
+ }
protected void unregisterAudioFocusClient(String clientId) {
synchronized(mAudioFocusLock) {
diff --git a/services/core/java/com/android/server/audio/PlaybackActivityMonitor.java b/services/core/java/com/android/server/audio/PlaybackActivityMonitor.java
index 23a0782..54fa6fb 100644
--- a/services/core/java/com/android/server/audio/PlaybackActivityMonitor.java
+++ b/services/core/java/com/android/server/audio/PlaybackActivityMonitor.java
@@ -1208,6 +1208,17 @@
}
}
+ protected @NonNull List<Integer> getFocusDuckedUids() {
+ final ArrayList<Integer> duckedUids;
+ synchronized (mPlayerLock) {
+ duckedUids = new ArrayList(mDuckingManager.mDuckers.keySet());
+ }
+ if (DEBUG) {
+ Log.i(TAG, "current ducked UIDs: " + duckedUids);
+ }
+ return duckedUids;
+ }
+
//=================================================================
// For logging
private static final class PlayerEvent extends EventLogger.Event {