Merge "Fix bad parcelable for deviceId" into main
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
index 2128cbc..0d16880 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManager.java
@@ -26,6 +26,7 @@
import android.graphics.Rect;
import android.util.Pair;
import android.view.LayoutInflater;
+import android.view.SurfaceControl;
import android.view.View;
import android.window.DesktopModeFlags;
@@ -70,6 +71,9 @@
private final float mHideScmTolerance;
+ @NonNull
+ private final Rect mLayoutBounds = new Rect();
+
CompatUIWindowManager(@NonNull Context context, @NonNull TaskInfo taskInfo,
@NonNull SyncTransactionQueue syncQueue,
@NonNull Consumer<CompatUIEvent> callback,
@@ -105,6 +109,7 @@
@Override
protected void removeLayout() {
+ mLayoutBounds.setEmpty();
mLayout = null;
}
@@ -171,18 +176,21 @@
@Override
@VisibleForTesting
public void updateSurfacePosition() {
- if (mLayout == null) {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
return;
}
- // Position of the button in the container coordinate.
- final Rect taskBounds = getTaskBounds();
- final Rect taskStableBounds = getTaskStableBounds();
- final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
- ? taskStableBounds.left - taskBounds.left
- : taskStableBounds.right - taskBounds.left - mLayout.getMeasuredWidth();
- final int positionY = taskStableBounds.bottom - taskBounds.top
- - mLayout.getMeasuredHeight();
- updateSurfacePosition(positionX, positionY);
+ updateSurfacePosition(mLayoutBounds.left, mLayoutBounds.top);
+ }
+
+ @Override
+ @VisibleForTesting
+ public void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
+ return;
+ }
+ updateSurfaceBounds(tx, mLayoutBounds);
}
@VisibleForTesting
@@ -219,6 +227,23 @@
return percentageAreaOfLetterboxInTask < mHideScmTolerance;
}
+ private void updateLayoutBounds() {
+ if (mLayout == null) {
+ mLayoutBounds.setEmpty();
+ return;
+ }
+ // Position of the button in the container coordinate.
+ final Rect taskBounds = getTaskBounds();
+ final Rect taskStableBounds = getTaskStableBounds();
+ final int layoutWidth = mLayout.getMeasuredWidth();
+ final int layoutHeight = mLayout.getMeasuredHeight();
+ final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
+ ? taskStableBounds.left - taskBounds.left
+ : taskStableBounds.right - taskBounds.left - layoutWidth;
+ final int positionY = taskStableBounds.bottom - taskBounds.top - layoutHeight;
+ mLayoutBounds.set(positionX, positionY, positionX + layoutWidth, positionY + layoutHeight);
+ }
+
private void updateVisibilityOfViews() {
if (mLayout == null) {
return;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
index d2b4f1a..82acfe5 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/CompatUIWindowManagerAbstract.java
@@ -43,6 +43,7 @@
import android.view.WindowlessWindowManager;
import com.android.internal.annotations.VisibleForTesting;
+import com.android.window.flags.Flags;
import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.SyncTransactionQueue;
@@ -327,8 +328,15 @@
if (mViewHost == null) {
return;
}
- mViewHost.relayout(windowLayoutParams);
- updateSurfacePosition();
+ if (Flags.appCompatAsyncRelayout()) {
+ mViewHost.relayout(windowLayoutParams, tx -> {
+ updateSurfacePosition(tx);
+ tx.apply();
+ });
+ } else {
+ mViewHost.relayout(windowLayoutParams);
+ updateSurfacePosition();
+ }
}
@NonNull
@@ -349,6 +357,10 @@
*/
protected abstract void updateSurfacePosition();
+ protected void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+
+ }
+
/**
* Updates the position of the surface with respect to the given {@code positionX} and {@code
* positionY}.
@@ -366,6 +378,15 @@
});
}
+ protected void updateSurfaceBounds(@NonNull SurfaceControl.Transaction tx,
+ @NonNull Rect bounds) {
+ if (mLeash == null) {
+ return;
+ }
+ tx.setPosition(mLeash, bounds.left, bounds.top)
+ .setWindowCrop(mLeash, bounds.width(), bounds.height());
+ }
+
protected int getLayoutDirection() {
return mContext.getResources().getConfiguration().getLayoutDirection();
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
index 3f67172..650d2170 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/compatui/UserAspectRatioSettingsWindowManager.java
@@ -27,6 +27,7 @@
import android.graphics.Rect;
import android.os.SystemClock;
import android.view.LayoutInflater;
+import android.view.SurfaceControl;
import android.view.View;
import android.view.accessibility.AccessibilityManager;
@@ -69,6 +70,9 @@
@NonNull
final CompatUIHintsState mCompatUIHintsState;
+ @NonNull
+ private final Rect mLayoutBounds = new Rect();
+
@Nullable
private UserAspectRatioSettingsLayout mLayout;
@@ -108,6 +112,7 @@
@Override
protected void removeLayout() {
+ mLayoutBounds.setEmpty();
mLayout = null;
}
@@ -168,18 +173,21 @@
@Override
@VisibleForTesting
public void updateSurfacePosition() {
- if (mLayout == null) {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
return;
}
- // Position of the button in the container coordinate.
- final Rect taskBounds = getTaskBounds();
- final Rect taskStableBounds = getTaskStableBounds();
- final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
- ? taskStableBounds.left - taskBounds.left
- : taskStableBounds.right - taskBounds.left - mLayout.getMeasuredWidth();
- final int positionY = taskStableBounds.bottom - taskBounds.top
- - mLayout.getMeasuredHeight();
- updateSurfacePosition(positionX, positionY);
+ updateSurfacePosition(mLayoutBounds.left, mLayoutBounds.top);
+ }
+
+ @Override
+ @VisibleForTesting
+ public void updateSurfacePosition(@NonNull SurfaceControl.Transaction tx) {
+ updateLayoutBounds();
+ if (mLayoutBounds.isEmpty()) {
+ return;
+ }
+ updateSurfaceBounds(tx, mLayoutBounds);
}
@VisibleForTesting
@@ -202,6 +210,23 @@
&& !isHideDelayReached(mNextButtonHideTimeMs));
}
+ private void updateLayoutBounds() {
+ if (mLayout == null) {
+ mLayoutBounds.setEmpty();
+ return;
+ }
+ // Position of the button in the container coordinate.
+ final Rect taskBounds = getTaskBounds();
+ final Rect taskStableBounds = getTaskStableBounds();
+ final int layoutWidth = mLayout.getMeasuredWidth();
+ final int layoutHeight = mLayout.getMeasuredHeight();
+ final int positionX = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
+ ? taskStableBounds.left - taskBounds.left
+ : taskStableBounds.right - taskBounds.left - layoutWidth;
+ final int positionY = taskStableBounds.bottom - taskBounds.top - layoutHeight;
+ mLayoutBounds.set(positionX, positionY, positionX + layoutWidth, positionY + layoutHeight);
+ }
+
private void showUserAspectRatioButton() {
if (mLayout == null) {
return;
diff --git a/media/java/android/media/AudioFormat.java b/media/java/android/media/AudioFormat.java
index 0da8371b..c72a74e 100644
--- a/media/java/android/media/AudioFormat.java
+++ b/media/java/android/media/AudioFormat.java
@@ -714,7 +714,7 @@
/**
* @hide
* Return a channel mask ready to be used by native code
- * @param mask a combination of the CHANNEL_OUT_* definitions, but not CHANNEL_OUT_DEFAULT
+ * @param javaMask a combination of the CHANNEL_OUT_* definitions, but not CHANNEL_OUT_DEFAULT
* @return a native channel mask
*/
public static int convertChannelOutMaskToNativeMask(int javaMask) {
@@ -724,13 +724,98 @@
/**
* @hide
* Return a java output channel mask
- * @param mask a native channel mask
+ * @param nativeMask a native channel mask
* @return a combination of the CHANNEL_OUT_* definitions
*/
public static int convertNativeChannelMaskToOutMask(int nativeMask) {
return (nativeMask << 2);
}
+ /**
+ * @hide
+ * Return a human-readable string from a java channel mask
+ * @param javaMask a bit field of CHANNEL_OUT_* values
+ * @return a string in the "mono", "stereo", "5.1" style, or the hex version when not a standard
+ * mask.
+ */
+ public static String javaChannelOutMaskToString(int javaMask) {
+ // save haptics info for end of string
+ int haptics = javaMask & (CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B);
+ // continue without looking at haptic channels
+ javaMask &= ~(CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B);
+ StringBuilder result = new StringBuilder("");
+ switch (javaMask) {
+ case CHANNEL_OUT_MONO:
+ result.append("mono");
+ break;
+ case CHANNEL_OUT_STEREO:
+ result.append("stereo");
+ break;
+ case CHANNEL_OUT_QUAD:
+ result.append("quad");
+ break;
+ case CHANNEL_OUT_QUAD_SIDE:
+ result.append("quad side");
+ break;
+ case CHANNEL_OUT_SURROUND:
+ result.append("4.0");
+ break;
+ case CHANNEL_OUT_5POINT1:
+ result.append("5.1");
+ break;
+ case CHANNEL_OUT_6POINT1:
+ result.append("6.1");
+ break;
+ case CHANNEL_OUT_5POINT1_SIDE:
+ result.append("5.1 side");
+ break;
+ case CHANNEL_OUT_7POINT1:
+ result.append("7.1 (5 fronts)");
+ break;
+ case CHANNEL_OUT_7POINT1_SURROUND:
+ result.append("7.1");
+ break;
+ case CHANNEL_OUT_5POINT1POINT2:
+ result.append("5.1.2");
+ break;
+ case CHANNEL_OUT_5POINT1POINT4:
+ result.append("5.1.4");
+ break;
+ case CHANNEL_OUT_7POINT1POINT2:
+ result.append("7.1.2");
+ break;
+ case CHANNEL_OUT_7POINT1POINT4:
+ result.append("7.1.4");
+ break;
+ case CHANNEL_OUT_9POINT1POINT4:
+ result.append("9.1.4");
+ break;
+ case CHANNEL_OUT_9POINT1POINT6:
+ result.append("9.1.6");
+ break;
+ case CHANNEL_OUT_13POINT_360RA:
+ result.append("360RA 13ch");
+ break;
+ case CHANNEL_OUT_22POINT2:
+ result.append("22.2");
+ break;
+ default:
+ result.append("0x").append(Integer.toHexString(javaMask));
+ break;
+ }
+ if ((haptics & (CHANNEL_OUT_HAPTIC_A | CHANNEL_OUT_HAPTIC_B)) != 0) {
+ result.append("(+haptic ");
+ if ((haptics & CHANNEL_OUT_HAPTIC_A) == CHANNEL_OUT_HAPTIC_A) {
+ result.append("A");
+ }
+ if ((haptics & CHANNEL_OUT_HAPTIC_B) == CHANNEL_OUT_HAPTIC_B) {
+ result.append("B");
+ }
+ result.append(")");
+ }
+ return result.toString();
+ }
+
public static final int CHANNEL_IN_DEFAULT = 1;
// These directly match native
public static final int CHANNEL_IN_LEFT = 0x4;
diff --git a/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl b/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
index 63c52a1..8f03057 100644
--- a/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
+++ b/media/java/android/media/IMediaRoute2ProviderServiceCallback.aidl
@@ -25,7 +25,6 @@
* @hide
*/
oneway interface IMediaRoute2ProviderServiceCallback {
- // TODO: Change it to updateRoutes?
void notifyProviderUpdated(in MediaRoute2ProviderInfo providerInfo);
void notifySessionCreated(long requestId, in RoutingSessionInfo sessionInfo);
void notifySessionsUpdated(in List<RoutingSessionInfo> sessionInfo);
diff --git a/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
new file mode 100644
index 0000000..7a64965
--- /dev/null
+++ b/packages/SettingsLib/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManager.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright (C) 2024 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.settingslib.bluetooth;
+
+import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.util.ArrayMap;
+import android.util.KeyValueListParser;
+import android.util.Log;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+/**
+ * The class to manage hearing device local data from Settings.
+ *
+ * <p><b>Note:</b> Before calling any methods to get or change the local data, you must first call
+ * the {@code start()} method to load the data from Settings. Whenever the data is modified, you
+ * must call the {@code stop()} method to save the data into Settings. After calling {@code stop()},
+ * you should not call any methods to get or change the local data without again calling
+ * {@code start()}.
+ */
+public class HearingDeviceLocalDataManager {
+ private static final String TAG = "HearingDeviceDataMgr";
+ private static final boolean DEBUG = true;
+
+ /** Interface for listening hearing device local data changed */
+ public interface OnDeviceLocalDataChangeListener {
+ /**
+ * The method is called when the local data of the device with the address is changed.
+ *
+ * @param address the device anonymized address
+ * @param data the updated data
+ */
+ void onDeviceLocalDataChange(@NonNull String address, @Nullable Data data);
+ }
+
+ static final String KEY_ADDR = "addr";
+ static final String KEY_AMBIENT = "ambient";
+ static final String KEY_GROUP_AMBIENT = "group_ambient";
+ static final String KEY_AMBIENT_CONTROL_EXPANDED = "control_expanded";
+ static final String LOCAL_AMBIENT_VOLUME_SETTINGS =
+ Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME;
+
+ private static final Object sLock = new Object();
+
+ private final Context mContext;
+ private Executor mListenerExecutor;
+ @GuardedBy("sLock")
+ private final Map<String, Data> mAddrToDataMap = new HashMap<>();
+ private OnDeviceLocalDataChangeListener mListener;
+ private SettingsObserver mSettingsObserver;
+ private boolean mIsStarted = false;
+
+ public HearingDeviceLocalDataManager(@NonNull Context context) {
+ mContext = context;
+ mSettingsObserver = new SettingsObserver(ThreadUtils.getUiThreadHandler());
+ }
+
+ /** Starts the manager. Loads the data from Settings and start observing any changes. */
+ public synchronized void start() {
+ if (mIsStarted) {
+ return;
+ }
+ mIsStarted = true;
+ getLocalDataFromSettings();
+ mSettingsObserver.register(mContext.getContentResolver());
+ }
+
+ /** Stops the manager. Flushes the data into Settings and stop observing. */
+ public synchronized void stop() {
+ if (!mIsStarted) {
+ return;
+ }
+ putAmbientVolumeSettings();
+ mSettingsObserver.unregister(mContext.getContentResolver());
+ mIsStarted = false;
+ }
+
+ /**
+ * Sets a listener which will be be notified when hearing device local data is changed.
+ *
+ * @param listener the listener to be notified
+ * @param executor the executor to run the
+ * {@link OnDeviceLocalDataChangeListener#onDeviceLocalDataChange(String,
+ * Data)} callback
+ */
+ public void setOnDeviceLocalDataChangeListener(
+ @NonNull OnDeviceLocalDataChangeListener listener, @NonNull Executor executor) {
+ mListener = listener;
+ mListenerExecutor = executor;
+ }
+
+ /**
+ * Gets the local data of the corresponding hearing device. This should be called after
+ * {@link #start()} is called().
+ *
+ * @param device the device to query the local data
+ */
+ @NonNull
+ public Data get(@NonNull BluetoothDevice device) {
+ if (!mIsStarted) {
+ Log.w(TAG, "Manager is not started. Please call start() first.");
+ return new Data();
+ }
+ synchronized (sLock) {
+ return mAddrToDataMap.getOrDefault(device.getAnonymizedAddress(), new Data());
+ }
+ }
+
+ /**
+ * Puts the local data of the corresponding hearing device.
+ *
+ * @param device the device to update the local data
+ */
+ private void put(BluetoothDevice device, Data data) {
+ if (device == null) {
+ return;
+ }
+ synchronized (sLock) {
+ final String addr = device.getAnonymizedAddress();
+ mAddrToDataMap.put(addr, data);
+ if (mListener != null && mListenerExecutor != null) {
+ mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data));
+ }
+ }
+ }
+
+ /**
+ * Updates the ambient volume of the corresponding hearing device. This should be called after
+ * {@link #start()} is called().
+ *
+ * @param device the device to update
+ * @param value the ambient value
+ * @return if the local data is updated
+ */
+ public boolean updateAmbient(@Nullable BluetoothDevice device, int value) {
+ if (!mIsStarted) {
+ Log.w(TAG, "Manager is not started. Please call start() first.");
+ return false;
+ }
+ if (device == null) {
+ return false;
+ }
+ synchronized (sLock) {
+ Data data = get(device);
+ if (value == data.ambient) {
+ return false;
+ }
+ put(device, new Data.Builder(data).ambient(value).build());
+ return true;
+ }
+ }
+
+ /**
+ * Updates the group ambient volume of the corresponding hearing device. This should be called
+ * after {@link #start()} is called().
+ *
+ * @param device the device to update
+ * @param value the group ambient value
+ * @return if the local data is updated
+ */
+ public boolean updateGroupAmbient(@Nullable BluetoothDevice device, int value) {
+ if (!mIsStarted) {
+ Log.w(TAG, "Manager is not started. Please call start() first.");
+ return false;
+ }
+ if (device == null) {
+ return false;
+ }
+ synchronized (sLock) {
+ Data data = get(device);
+ if (value == data.groupAmbient) {
+ return false;
+ }
+ put(device, new Data.Builder(data).groupAmbient(value).build());
+ return true;
+ }
+ }
+
+ /**
+ * Updates the ambient control is expanded or not of the corresponding hearing device. This
+ * should be called after {@link #start()} is called().
+ *
+ * @param device the device to update
+ * @param expanded the ambient control is expanded or not
+ * @return if the local data is updated
+ */
+ public boolean updateAmbientControlExpanded(@Nullable BluetoothDevice device,
+ boolean expanded) {
+ if (!mIsStarted) {
+ Log.w(TAG, "Manager is not started. Please call start() first.");
+ return false;
+ }
+ if (device == null) {
+ return false;
+ }
+ synchronized (sLock) {
+ Data data = get(device);
+ if (expanded == data.ambientControlExpanded) {
+ return false;
+ }
+ put(device, new Data.Builder(data).ambientControlExpanded(expanded).build());
+ return true;
+ }
+ }
+
+ void getLocalDataFromSettings() {
+ synchronized (sLock) {
+ Map<String, Data> updatedAddrToDataMap = parseFromSettings();
+ notifyIfDataChanged(mAddrToDataMap, updatedAddrToDataMap);
+ mAddrToDataMap.clear();
+ mAddrToDataMap.putAll(updatedAddrToDataMap);
+ if (DEBUG) {
+ Log.v(TAG, "getLocalDataFromSettings, " + mAddrToDataMap + ", manager: " + this);
+ }
+ }
+ }
+
+ void putAmbientVolumeSettings() {
+ synchronized (sLock) {
+ StringBuilder builder = new StringBuilder();
+ for (Map.Entry<String, Data> entry : mAddrToDataMap.entrySet()) {
+ builder.append(KEY_ADDR).append("=").append(entry.getKey());
+ builder.append(entry.getValue().toSettingsFormat()).append(";");
+ }
+ if (DEBUG) {
+ Log.v(TAG, "putAmbientVolumeSettings, " + builder + ", manager: " + this);
+ }
+ Settings.Global.putStringForUser(mContext.getContentResolver(),
+ LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(),
+ UserHandle.USER_SYSTEM);
+ }
+ }
+
+ @GuardedBy("sLock")
+ private Map<String, Data> parseFromSettings() {
+ String settings = Settings.Global.getStringForUser(mContext.getContentResolver(),
+ LOCAL_AMBIENT_VOLUME_SETTINGS, UserHandle.USER_SYSTEM);
+ Map<String, Data> addrToDataMap = new ArrayMap<>();
+ if (settings != null && !settings.isEmpty()) {
+ String[] localDataArray = settings.split(";");
+ for (String localData : localDataArray) {
+ KeyValueListParser parser = new KeyValueListParser(',');
+ parser.setString(localData);
+ String address = parser.getString(KEY_ADDR, "");
+ if (!address.isEmpty()) {
+ Data data = new Data.Builder()
+ .ambient(parser.getInt(KEY_AMBIENT, INVALID_VOLUME))
+ .groupAmbient(parser.getInt(KEY_GROUP_AMBIENT, INVALID_VOLUME))
+ .ambientControlExpanded(
+ parser.getBoolean(KEY_AMBIENT_CONTROL_EXPANDED, false))
+ .build();
+ addrToDataMap.put(address, data);
+ }
+ }
+ }
+ return addrToDataMap;
+ }
+
+ @GuardedBy("sLock")
+ private void notifyIfDataChanged(Map<String, Data> oldAddrToDataMap,
+ Map<String, Data> newAddrToDataMap) {
+ newAddrToDataMap.forEach((addr, data) -> {
+ Data oldData = oldAddrToDataMap.get(addr);
+ if (oldData == null || !oldData.equals(data)) {
+ if (mListener != null) {
+ mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data));
+ }
+ }
+ });
+ }
+
+ private final class SettingsObserver extends ContentObserver {
+ private final Uri mAmbientVolumeUri = Settings.Global.getUriFor(
+ LOCAL_AMBIENT_VOLUME_SETTINGS);
+
+ SettingsObserver(Handler handler) {
+ super(handler);
+ }
+
+ void register(ContentResolver contentResolver) {
+ contentResolver.registerContentObserver(mAmbientVolumeUri, false, this,
+ UserHandle.USER_SYSTEM);
+ }
+
+ void unregister(ContentResolver contentResolver) {
+ contentResolver.unregisterContentObserver(this);
+ }
+
+ @Override
+ public void onChange(boolean selfChange, @Nullable Uri uri) {
+ if (mAmbientVolumeUri.equals(uri)) {
+ Log.v(TAG, "Local data on change, manager: " + HearingDeviceLocalDataManager.this);
+ getLocalDataFromSettings();
+ }
+ }
+ }
+
+ public record Data(int ambient, int groupAmbient, boolean ambientControlExpanded) {
+
+ public static int INVALID_VOLUME = Integer.MIN_VALUE;
+
+ private Data() {
+ this(INVALID_VOLUME, INVALID_VOLUME, false);
+ }
+
+ /**
+ * Return {@code true} if one of {@link #ambient} or {@link #groupAmbient} is assigned to
+ * a valid value.
+ */
+ public boolean hasAmbientData() {
+ return ambient != INVALID_VOLUME || groupAmbient != INVALID_VOLUME;
+ }
+
+ /**
+ * @return the composed string which is used to store the local data in
+ * {@link Settings.Global#HEARING_DEVICE_LOCAL_AMBIENT_VOLUME}
+ */
+ @NonNull
+ public String toSettingsFormat() {
+ String string = "";
+ if (ambient != INVALID_VOLUME) {
+ string += ("," + KEY_AMBIENT + "=" + ambient);
+ }
+ if (groupAmbient != INVALID_VOLUME) {
+ string += ("," + KEY_GROUP_AMBIENT + "=" + groupAmbient);
+ }
+ string += ("," + KEY_AMBIENT_CONTROL_EXPANDED + "=" + ambientControlExpanded);
+ return string;
+ }
+
+ /** Builder for a Data object */
+ public static final class Builder {
+ private int mAmbient;
+ private int mGroupAmbient;
+ private boolean mAmbientControlExpanded;
+
+ public Builder() {
+ this.mAmbient = INVALID_VOLUME;
+ this.mGroupAmbient = INVALID_VOLUME;
+ this.mAmbientControlExpanded = false;
+ }
+
+ public Builder(@NonNull Data other) {
+ this.mAmbient = other.ambient;
+ this.mGroupAmbient = other.groupAmbient;
+ this.mAmbientControlExpanded = other.ambientControlExpanded;
+ }
+
+ /** Sets the ambient volume */
+ @NonNull
+ public Builder ambient(int ambient) {
+ this.mAmbient = ambient;
+ return this;
+ }
+
+ /** Sets the group ambient volume */
+ @NonNull
+ public Builder groupAmbient(int groupAmbient) {
+ this.mGroupAmbient = groupAmbient;
+ return this;
+ }
+
+ /** Sets the ambient control expanded */
+ @NonNull
+ public Builder ambientControlExpanded(boolean ambientControlExpanded) {
+ this.mAmbientControlExpanded = ambientControlExpanded;
+ return this;
+ }
+
+ /** Build the Data object */
+ @NonNull
+ public Data build() {
+ return new Data(mAmbient, mGroupAmbient, mAmbientControlExpanded);
+ }
+ }
+ }
+}
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt b/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
index 23be7ba..496c3e6 100644
--- a/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/data/repository/FakeZenModeRepository.kt
@@ -74,10 +74,6 @@
mutableModesFlow.value = mutableModesFlow.value.filter { it.id != id }
}
- fun replaceMode(modeId: String, mode: ZenMode) {
- mutableModesFlow.value = (mutableModesFlow.value.filter { it.id != modeId }) + mode
- }
-
fun clearModes() {
mutableModesFlow.value = listOf()
}
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
index 6842d0a..abc1638 100644
--- a/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/TestModeBuilder.java
@@ -41,32 +41,24 @@
private String mId;
private AutomaticZenRule mRule;
private ZenModeConfig.ZenRule mConfigZenRule;
+ private boolean mIsManualDnd;
public static final ZenMode EXAMPLE = new TestModeBuilder().build();
- public static final ZenMode MANUAL_DND_ACTIVE = manualDnd(Uri.EMPTY,
+ public static final ZenMode MANUAL_DND_ACTIVE = manualDnd(
INTERRUPTION_FILTER_PRIORITY, true);
- public static final ZenMode MANUAL_DND_INACTIVE = manualDnd(Uri.EMPTY,
+ public static final ZenMode MANUAL_DND_INACTIVE = manualDnd(
INTERRUPTION_FILTER_PRIORITY, false);
@NonNull
public static ZenMode manualDnd(@NotificationManager.InterruptionFilter int filter,
boolean isActive) {
- return manualDnd(Uri.EMPTY, filter, isActive);
- }
-
- private static ZenMode manualDnd(Uri conditionId,
- @NotificationManager.InterruptionFilter int filter, boolean isActive) {
- return ZenMode.manualDndMode(
- new AutomaticZenRule.Builder("Do Not Disturb", conditionId)
- .setInterruptionFilter(filter)
- .setType(AutomaticZenRule.TYPE_OTHER)
- .setManualInvocationAllowed(true)
- .setPackage(SystemZenRules.PACKAGE_ANDROID)
- .setZenPolicy(new ZenPolicy.Builder().disallowAllSounds().build())
- .build(),
- isActive);
+ return new TestModeBuilder()
+ .makeManualDnd()
+ .setInterruptionFilter(filter)
+ .setActive(isActive)
+ .build();
}
public TestModeBuilder() {
@@ -91,6 +83,10 @@
mConfigZenRule.enabled = previous.getRule().isEnabled();
mConfigZenRule.pkg = previous.getRule().getPackageName();
setActive(previous.isActive());
+
+ if (previous.isManualDnd()) {
+ makeManualDnd();
+ }
}
public TestModeBuilder setId(String id) {
@@ -222,7 +218,25 @@
return this;
}
+ public TestModeBuilder makeManualDnd() {
+ mIsManualDnd = true;
+ // Set the "fixed" properties of a DND mode. Other things, such as policy/filter may be set
+ // separately or copied from a preexisting DND, so they are not overwritten here.
+ setId(ZenMode.MANUAL_DND_MODE_ID);
+ setName("Do Not Disturb");
+ setType(AutomaticZenRule.TYPE_OTHER);
+ setManualInvocationAllowed(true);
+ setPackage(SystemZenRules.PACKAGE_ANDROID);
+ setConditionId(Uri.EMPTY);
+ return this;
+ }
+
public ZenMode build() {
- return new ZenMode(mId, mRule, mConfigZenRule);
+ if (mIsManualDnd) {
+ return ZenMode.manualDndMode(mRule, mConfigZenRule.condition != null
+ && mConfigZenRule.condition.state == Condition.STATE_TRUE);
+ } else {
+ return new ZenMode(mId, mRule, mConfigZenRule);
+ }
}
}
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
new file mode 100644
index 0000000..b659c02
--- /dev/null
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/bluetooth/HearingDeviceLocalDataManagerTest.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2024 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.settingslib.bluetooth;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.os.UserHandle;
+import android.provider.Settings;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.shadows.ShadowSettings;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Tests for {@link HearingDeviceLocalDataManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {HearingDeviceLocalDataManagerTest.ShadowGlobal.class})
+public class HearingDeviceLocalDataManagerTest {
+
+ private static final String TEST_ADDRESS = "XX:XX:XX:XX:11:22";
+ private static final int TEST_AMBIENT = 10;
+ private static final int TEST_GROUP_AMBIENT = 20;
+ private static final boolean TEST_AMBIENT_CONTROL_EXPANDED = true;
+ private static final int TEST_UPDATED_AMBIENT = 30;
+ private static final int TEST_UPDATED_GROUP_AMBIENT = 40;
+ private static final boolean TEST_UPDATED_AMBIENT_CONTROL_EXPANDED = false;
+
+ @Rule
+ public MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Mock
+ private BluetoothDevice mDevice;
+ @Mock
+ private HearingDeviceLocalDataManager.OnDeviceLocalDataChangeListener mListener;
+
+ private final Context mContext = ApplicationProvider.getApplicationContext();
+ private HearingDeviceLocalDataManager mLocalDataManager;
+
+ @Before
+ public void setUp() {
+ prepareTestDataInSettings();
+ mLocalDataManager = new HearingDeviceLocalDataManager(mContext);
+ mLocalDataManager.start();
+ mLocalDataManager.setOnDeviceLocalDataChangeListener(mListener,
+ mContext.getMainExecutor());
+
+ when(mDevice.getAnonymizedAddress()).thenReturn(TEST_ADDRESS);
+ }
+
+ @Test
+ public void stop_verifyDataIsSaved() {
+ mLocalDataManager.updateAmbient(mDevice, TEST_UPDATED_AMBIENT);
+ mLocalDataManager.stop();
+
+ String settings = Settings.Global.getStringForUser(mContext.getContentResolver(),
+ Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, UserHandle.USER_SYSTEM);
+ String expectedSettings = generateSettingsString(TEST_ADDRESS, TEST_UPDATED_AMBIENT,
+ TEST_GROUP_AMBIENT, TEST_AMBIENT_CONTROL_EXPANDED);
+ assertThat(settings).isEqualTo(expectedSettings);
+ }
+
+ @Test
+ public void get_correctDataFromSettings() {
+ HearingDeviceLocalDataManager.Data data = mLocalDataManager.get(mDevice);
+
+ assertThat(data.ambient()).isEqualTo(TEST_AMBIENT);
+ assertThat(data.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+ assertThat(data.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+ }
+
+ @Test
+ public void updateAmbient_correctValue_listenerCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+
+ mLocalDataManager.updateAmbient(mDevice, TEST_UPDATED_AMBIENT);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.ambient()).isEqualTo(TEST_UPDATED_AMBIENT);
+ verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+ }
+
+ @Test
+ public void updateAmbient_sameValue_listenerNotCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+
+ mLocalDataManager.updateAmbient(mDevice, TEST_AMBIENT);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.ambient()).isEqualTo(TEST_AMBIENT);
+ verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+ }
+
+ @Test
+ public void updateGroupAmbient_correctValue_listenerCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+
+ mLocalDataManager.updateGroupAmbient(mDevice, TEST_UPDATED_GROUP_AMBIENT);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.groupAmbient()).isEqualTo(TEST_UPDATED_GROUP_AMBIENT);
+ verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+ }
+
+ @Test
+ public void updateGroupAmbient_sameValue_listenerNotCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+
+ mLocalDataManager.updateGroupAmbient(mDevice, TEST_GROUP_AMBIENT);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+ verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+ }
+
+ @Test
+ public void updateAmbientControlExpanded_correctValue_listenerCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+ mLocalDataManager.updateAmbientControlExpanded(mDevice,
+ TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.ambientControlExpanded()).isEqualTo(
+ TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+ verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+ }
+
+ @Test
+ public void updateAmbientControlExpanded_sameValue_listenerNotCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+ mLocalDataManager.updateAmbientControlExpanded(mDevice, TEST_AMBIENT_CONTROL_EXPANDED);
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+ verify(mListener, never()).onDeviceLocalDataChange(any(), any());
+ }
+
+ @Test
+ public void getLocalDataFromSettings_dataChanged_correctValue_listenerCalled() {
+ HearingDeviceLocalDataManager.Data oldData = mLocalDataManager.get(mDevice);
+ assertThat(oldData.ambient()).isEqualTo(TEST_AMBIENT);
+ assertThat(oldData.groupAmbient()).isEqualTo(TEST_GROUP_AMBIENT);
+ assertThat(oldData.ambientControlExpanded()).isEqualTo(TEST_AMBIENT_CONTROL_EXPANDED);
+
+ prepareUpdatedDataInSettings();
+ mLocalDataManager.getLocalDataFromSettings();
+
+ HearingDeviceLocalDataManager.Data newData = mLocalDataManager.get(mDevice);
+ assertThat(newData.ambient()).isEqualTo(TEST_UPDATED_AMBIENT);
+ assertThat(newData.groupAmbient()).isEqualTo(TEST_UPDATED_GROUP_AMBIENT);
+ assertThat(newData.ambientControlExpanded()).isEqualTo(
+ TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+ verify(mListener).onDeviceLocalDataChange(TEST_ADDRESS, newData);
+ }
+
+ private void prepareTestDataInSettings() {
+ String data = generateSettingsString(TEST_ADDRESS, TEST_AMBIENT, TEST_GROUP_AMBIENT,
+ TEST_AMBIENT_CONTROL_EXPANDED);
+ Settings.Global.putStringForUser(mContext.getContentResolver(),
+ Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, data,
+ UserHandle.USER_SYSTEM);
+ }
+
+ private void prepareUpdatedDataInSettings() {
+ String data = generateSettingsString(TEST_ADDRESS, TEST_UPDATED_AMBIENT,
+ TEST_UPDATED_GROUP_AMBIENT, TEST_UPDATED_AMBIENT_CONTROL_EXPANDED);
+ Settings.Global.putStringForUser(mContext.getContentResolver(),
+ Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME, data,
+ UserHandle.USER_SYSTEM);
+ }
+
+ private String generateSettingsString(String addr, int ambient, int groupAmbient,
+ boolean ambientControlExpanded) {
+ return "addr=" + addr + ",ambient=" + ambient + ",group_ambient=" + groupAmbient
+ + ",control_expanded=" + ambientControlExpanded + ";";
+ }
+
+ @Implements(value = Settings.Global.class)
+ public static class ShadowGlobal extends ShadowSettings.ShadowGlobal {
+ private static final Map<ContentResolver, Map<String, String>> sDataMap = new HashMap<>();
+
+ @Implementation
+ protected static boolean putStringForUser(
+ ContentResolver cr, String name, String value, int userHandle) {
+ get(cr).put(name, value);
+ return true;
+ }
+
+ @Implementation
+ protected static String getStringForUser(ContentResolver cr, String name, int userHandle) {
+ return get(cr).get(name);
+ }
+
+ private static Map<String, String> get(ContentResolver cr) {
+ return sDataMap.computeIfAbsent(cr, k -> new HashMap<>());
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
index fcf4662..50ac2619 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/quickaffordance/DoNotDisturbQuickAffordanceConfigTest.kt
@@ -405,7 +405,8 @@
testScope.runTest {
val lockScreenState by collectLastValue(underTest.lockScreenState)
- zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_INACTIVE)
+ val manualDnd = TestModeBuilder.MANUAL_DND_INACTIVE
+ zenModeRepository.addMode(manualDnd)
runCurrent()
assertThat(lockScreenState)
@@ -419,8 +420,7 @@
)
)
- zenModeRepository.removeMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
- zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_ACTIVE)
+ zenModeRepository.activateMode(manualDnd)
runCurrent()
assertThat(lockScreenState)
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
index 74d4178..4e33a59 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/policy/domain/interactor/ZenModeInteractorTest.kt
@@ -378,8 +378,7 @@
assertThat(dndMode!!.isActive).isFalse()
- zenModeRepository.removeMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
- zenModeRepository.addMode(TestModeBuilder.MANUAL_DND_ACTIVE)
+ zenModeRepository.activateMode(TestModeBuilder.MANUAL_DND_INACTIVE.id)
runCurrent()
assertThat(dndMode!!.isActive).isTrue()
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
index 1e6e52a..d8184db 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModelTest.kt
@@ -83,7 +83,7 @@
assertThat(ringerViewModel).isInstanceOf(RingerViewModelState.Available::class.java)
assertThat((ringerViewModel as RingerViewModelState.Available).uiModel.drawerState)
- .isEqualTo(RingerDrawerState.Closed(normalRingerMode))
+ .isEqualTo(RingerDrawerState.Closed(normalRingerMode, normalRingerMode))
}
@Test
@@ -91,8 +91,9 @@
testScope.runTest {
val ringerViewModel by collectLastValue(underTest.ringerViewModel)
val vibrateRingerMode = RingerMode(RINGER_MODE_VIBRATE)
+ val normalRingerMode = RingerMode(RINGER_MODE_NORMAL)
- setUpRingerModeAndOpenDrawer(RingerMode(RINGER_MODE_NORMAL))
+ setUpRingerModeAndOpenDrawer(normalRingerMode)
// Select vibrate ringer mode.
underTest.onRingerButtonClicked(vibrateRingerMode)
controller.getState()
@@ -103,7 +104,8 @@
var uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
.isEqualTo(vibrateRingerMode)
- assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(vibrateRingerMode))
+ assertThat(uiModel.drawerState)
+ .isEqualTo(RingerDrawerState.Closed(vibrateRingerMode, normalRingerMode))
val silentRingerMode = RingerMode(RINGER_MODE_SILENT)
// Open drawer
@@ -120,7 +122,8 @@
uiModel = (ringerViewModel as RingerViewModelState.Available).uiModel
assertThat(uiModel.availableButtons[uiModel.currentButtonIndex]?.ringerMode)
.isEqualTo(silentRingerMode)
- assertThat(uiModel.drawerState).isEqualTo(RingerDrawerState.Closed(silentRingerMode))
+ assertThat(uiModel.drawerState)
+ .isEqualTo(RingerDrawerState.Closed(silentRingerMode, vibrateRingerMode))
assertThat(controller.hasScheduledTouchFeedback).isFalse()
assertThat(vibratorHelper.totalVibrations).isEqualTo(2)
}
diff --git a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
index 877637e..1607121 100644
--- a/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
+++ b/packages/SystemUI/res/xml/volume_dialog_ringer_drawer_motion_scene.xml
@@ -17,10 +17,10 @@
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
- android:id="@+id/transition"
+ android:id="@+id/close_to_open_transition"
app:constraintSetEnd="@+id/volume_dialog_ringer_drawer_open"
app:constraintSetStart="@+id/volume_dialog_ringer_drawer_close"
- app:transitionEasing="path(0.05f, 0.7f, 0.1f, 1f)"
+ app:transitionEasing="cubic(0.05, 0.7, 0.1, 1.0)"
app:duration="400">
</Transition>
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
index 1963ba2..82ac056 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/binder/VolumeDialogRingerViewBinder.kt
@@ -16,6 +16,8 @@
package com.android.systemui.volume.dialog.ringer.ui.binder
+import android.animation.ArgbEvaluator
+import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageButton
@@ -23,6 +25,10 @@
import androidx.compose.ui.util.fastForEachIndexed
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.widget.ConstraintSet
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FloatValueHolder
+import androidx.dynamicanimation.animation.SpringAnimation
+import androidx.dynamicanimation.animation.SpringForce
import com.android.internal.R as internalR
import com.android.settingslib.Utils
import com.android.systemui.lifecycle.WindowLifecycleState
@@ -31,24 +37,44 @@
import com.android.systemui.res.R
import com.android.systemui.util.children
import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonUiModel
import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerButtonViewModel
import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerDrawerState
import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModel
import com.android.systemui.volume.dialog.ringer.ui.viewmodel.RingerViewModelState
import com.android.systemui.volume.dialog.ringer.ui.viewmodel.VolumeDialogRingerDrawerViewModel
+import com.android.systemui.volume.dialog.ui.utils.suspendAnimate
import javax.inject.Inject
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+private const val CLOSE_DRAWER_DELAY = 300L
@VolumeDialogScope
class VolumeDialogRingerViewBinder
@Inject
constructor(private val viewModelFactory: VolumeDialogRingerDrawerViewModel.Factory) {
+ private val roundnessSpringForce =
+ SpringForce(0F).apply {
+ stiffness = 800F
+ dampingRatio = 0.6F
+ }
+ private val colorSpringForce =
+ SpringForce(0F).apply {
+ stiffness = 3800F
+ dampingRatio = 1F
+ }
+ private val rgbEvaluator = ArgbEvaluator()
fun bind(view: View) {
with(view) {
val volumeDialogBackgroundView = requireViewById<View>(R.id.volume_dialog_background)
val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
+ val unselectedButtonUiModel = RingerButtonUiModel.getUnselectedButton(context)
+ val selectedButtonUiModel = RingerButtonUiModel.getSelectedButton(context)
+
repeatWhenAttached {
viewModel(
traceName = "VolumeDialogRingerViewBinder",
@@ -61,26 +87,53 @@
is RingerViewModelState.Available -> {
val uiModel = ringerState.uiModel
- bindDrawerButtons(viewModel, uiModel)
-
// Set up view background and visibility
drawerContainer.visibility = View.VISIBLE
when (uiModel.drawerState) {
is RingerDrawerState.Initial -> {
+ drawerContainer.animateAndBindDrawerButtons(
+ viewModel,
+ uiModel,
+ selectedButtonUiModel,
+ unselectedButtonUiModel,
+ )
drawerContainer.closeDrawer(uiModel.currentButtonIndex)
volumeDialogBackgroundView.setBackgroundResource(
R.drawable.volume_dialog_background
)
}
is RingerDrawerState.Closed -> {
- drawerContainer.closeDrawer(uiModel.currentButtonIndex)
- volumeDialogBackgroundView.setBackgroundResource(
- R.drawable.volume_dialog_background
- )
+ if (
+ uiModel.selectedButton.ringerMode ==
+ uiModel.drawerState.currentMode
+ ) {
+ drawerContainer.animateAndBindDrawerButtons(
+ viewModel,
+ uiModel,
+ selectedButtonUiModel,
+ unselectedButtonUiModel,
+ ) {
+ drawerContainer.closeDrawer(
+ uiModel.currentButtonIndex
+ )
+ volumeDialogBackgroundView
+ .setBackgroundResource(
+ R.drawable.volume_dialog_background
+ )
+ }
+ }
}
is RingerDrawerState.Open -> {
+ drawerContainer.animateAndBindDrawerButtons(
+ viewModel,
+ uiModel,
+ selectedButtonUiModel,
+ unselectedButtonUiModel,
+ )
// Open drawer
- drawerContainer.transitionToEnd()
+ drawerContainer.transitionToState(
+ R.id.volume_dialog_ringer_drawer_open
+ )
if (
uiModel.currentButtonIndex !=
uiModel.availableButtons.size - 1
@@ -106,45 +159,93 @@
}
}
- private fun View.bindDrawerButtons(
+ private suspend fun MotionLayout.animateAndBindDrawerButtons(
viewModel: VolumeDialogRingerDrawerViewModel,
uiModel: RingerViewModel,
+ selectedButtonUiModel: RingerButtonUiModel,
+ unselectedButtonUiModel: RingerButtonUiModel,
+ onAnimationEnd: Runnable? = null,
) {
- val drawerContainer = requireViewById<MotionLayout>(R.id.volume_ringer_drawer)
- val count = uiModel.availableButtons.size
- drawerContainer.ensureChildCount(R.layout.volume_ringer_button, count)
+ ensureChildCount(R.layout.volume_ringer_button, uiModel.availableButtons.size)
+ if (
+ uiModel.drawerState is RingerDrawerState.Closed &&
+ uiModel.drawerState.currentMode != uiModel.drawerState.previousMode
+ ) {
+ val count = uiModel.availableButtons.size
+ val selectedButton =
+ getChildAt(count - uiModel.currentButtonIndex - 1)
+ .requireViewById<ImageButton>(R.id.volume_drawer_button)
+ val previousIndex =
+ uiModel.availableButtons.indexOfFirst {
+ it?.ringerMode == uiModel.drawerState.previousMode
+ }
+ val unselectedButton =
+ getChildAt(count - previousIndex - 1)
+ .requireViewById<ImageButton>(R.id.volume_drawer_button)
+ // On roundness animation end.
+ val roundnessAnimationEndListener =
+ DynamicAnimation.OnAnimationEndListener { _, _, _, _ ->
+ postDelayed(
+ { bindButtons(viewModel, uiModel, onAnimationEnd, isAnimated = true) },
+ CLOSE_DRAWER_DELAY,
+ )
+ }
+
+ // We only need to execute on roundness animation end once.
+ selectedButton.animateTo(selectedButtonUiModel, roundnessAnimationEndListener)
+ unselectedButton.animateTo(unselectedButtonUiModel)
+ } else {
+ bindButtons(viewModel, uiModel, onAnimationEnd)
+ }
+ }
+
+ private fun MotionLayout.bindButtons(
+ viewModel: VolumeDialogRingerDrawerViewModel,
+ uiModel: RingerViewModel,
+ onAnimationEnd: Runnable? = null,
+ isAnimated: Boolean = false,
+ ) {
+ val count = uiModel.availableButtons.size
uiModel.availableButtons.fastForEachIndexed { index, ringerButton ->
ringerButton?.let {
- val view = drawerContainer.getChildAt(count - index - 1)
- // TODO (b/369995871): object animator for button switch ( active <-> inactive )
+ val view = getChildAt(count - index - 1)
if (index == uiModel.currentButtonIndex) {
- view.bindDrawerButton(uiModel.selectedButton, viewModel, isSelected = true)
+ view.bindDrawerButton(
+ uiModel.selectedButton,
+ viewModel,
+ isSelected = true,
+ isAnimated = isAnimated,
+ )
} else {
- view.bindDrawerButton(it, viewModel)
+ view.bindDrawerButton(it, viewModel, isAnimated)
}
}
}
+ onAnimationEnd?.run()
}
private fun View.bindDrawerButton(
buttonViewModel: RingerButtonViewModel,
viewModel: VolumeDialogRingerDrawerViewModel,
isSelected: Boolean = false,
+ isAnimated: Boolean = false,
) {
with(requireViewById<ImageButton>(R.id.volume_drawer_button)) {
setImageResource(buttonViewModel.imageResId)
contentDescription = context.getString(buttonViewModel.contentDescriptionResId)
- if (isSelected) {
+ if (isSelected && !isAnimated) {
setBackgroundResource(R.drawable.volume_drawer_selection_bg)
setColorFilter(
Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary)
)
- } else {
+ background = background.mutate()
+ } else if (!isAnimated) {
setBackgroundResource(R.drawable.volume_ringer_item_bg)
setColorFilter(
Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface)
)
+ background = background.mutate()
}
setOnClickListener {
viewModel.onRingerButtonClicked(buttonViewModel.ringerMode, isSelected)
@@ -171,9 +272,10 @@
}
private fun MotionLayout.closeDrawer(selectedIndex: Int) {
+ setTransition(R.id.close_to_open_transition)
cloneConstraintSet(R.id.volume_dialog_ringer_drawer_close)
.adjustClosedConstraintsForDrawer(selectedIndex, this)
- transitionToStart()
+ transitionToState(R.id.volume_dialog_ringer_drawer_close)
}
private fun ConstraintSet.adjustOpenConstraintsForDrawer(motionLayout: MotionLayout) {
@@ -263,4 +365,47 @@
connect(button.id, ConstraintSet.START, motionLayout.id, ConstraintSet.START)
connect(button.id, ConstraintSet.END, motionLayout.id, ConstraintSet.END)
}
+
+ private suspend fun ImageButton.animateTo(
+ ringerButtonUiModel: RingerButtonUiModel,
+ roundnessAnimationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+ ) {
+ val roundnessAnimation =
+ SpringAnimation(FloatValueHolder(0F)).setSpring(roundnessSpringForce)
+ val colorAnimation = SpringAnimation(FloatValueHolder(0F)).setSpring(colorSpringForce)
+ val radius = (background as GradientDrawable).cornerRadius
+ val cornerRadiusDiff =
+ ringerButtonUiModel.cornerRadius - (background as GradientDrawable).cornerRadius
+ val roundnessAnimationUpdateListener =
+ DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+ (background as GradientDrawable).cornerRadius = radius + value * cornerRadiusDiff
+ background.invalidateSelf()
+ }
+ val colorAnimationUpdateListener =
+ DynamicAnimation.OnAnimationUpdateListener { _, value, _ ->
+ val currentIconColor =
+ rgbEvaluator.evaluate(
+ value.coerceIn(0F, 1F),
+ imageTintList?.colors?.first(),
+ ringerButtonUiModel.tintColor,
+ ) as Int
+ val currentBgColor =
+ rgbEvaluator.evaluate(
+ value.coerceIn(0F, 1F),
+ (background as GradientDrawable).color?.colors?.get(0),
+ ringerButtonUiModel.backgroundColor,
+ ) as Int
+
+ (background as GradientDrawable).setColor(currentBgColor)
+ background.invalidateSelf()
+ setColorFilter(currentIconColor)
+ }
+ coroutineScope {
+ launch { colorAnimation.suspendAnimate(colorAnimationUpdateListener) }
+ roundnessAnimation.suspendAnimate(
+ roundnessAnimationUpdateListener,
+ roundnessAnimationEndListener,
+ )
+ }
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
new file mode 100644
index 0000000..3c46567
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerButtonUiModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2024 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.systemui.volume.dialog.ringer.ui.viewmodel
+
+import android.content.Context
+import com.android.internal.R as internalR
+import com.android.settingslib.Utils
+import com.android.systemui.res.R
+
+/** Models the UI state of ringer button */
+data class RingerButtonUiModel(
+ /** Icon color. */
+ val tintColor: Int,
+ val backgroundColor: Int,
+ val cornerRadius: Int,
+) {
+ companion object {
+ fun getUnselectedButton(context: Context): RingerButtonUiModel {
+ return RingerButtonUiModel(
+ tintColor =
+ Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnSurface),
+ backgroundColor =
+ Utils.getColorAttrDefaultColor(
+ context,
+ internalR.attr.materialColorSurfaceContainerHighest,
+ ),
+ cornerRadius =
+ context.resources.getDimensionPixelSize(
+ R.dimen.volume_dialog_background_square_corner_radius
+ ),
+ )
+ }
+
+ fun getSelectedButton(context: Context): RingerButtonUiModel {
+ return RingerButtonUiModel(
+ tintColor =
+ Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorOnPrimary),
+ backgroundColor =
+ Utils.getColorAttrDefaultColor(context, internalR.attr.materialColorPrimary),
+ cornerRadius =
+ context.resources.getDimensionPixelSize(
+ R.dimen.volume_dialog_ringer_selected_button_background_radius
+ ),
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
index f321837..afb3f68 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/RingerDrawerState.kt
@@ -25,7 +25,8 @@
data class Open(val mode: RingerMode) : RingerDrawerState
/** When clicked to close drawer */
- data class Closed(val mode: RingerMode) : RingerDrawerState
+ data class Closed(val currentMode: RingerMode, val previousMode: RingerMode) :
+ RingerDrawerState
/** Initial state when volume dialog is shown with a closed drawer. */
interface Initial : RingerDrawerState {
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
index 624dcc7..45338e4 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ringer/ui/viewmodel/VolumeDialogRingerDrawerViewModel.kt
@@ -98,7 +98,10 @@
RingerDrawerState.Open(ringerMode)
}
is RingerDrawerState.Open -> {
- RingerDrawerState.Closed(ringerMode)
+ RingerDrawerState.Closed(
+ ringerMode,
+ (drawerState.value as RingerDrawerState.Open).mode,
+ )
}
is RingerDrawerState.Closed -> {
RingerDrawerState.Open(ringerMode)
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
index c7f5801..10cf615 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
@@ -20,6 +20,8 @@
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.view.ViewPropertyAnimator
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.SpringAnimation
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -80,6 +82,27 @@
}
}
+/**
+ * Starts spring animation and suspends until it's finished. Cancels the animation if the running
+ * coroutine is cancelled.
+ */
+suspend fun SpringAnimation.suspendAnimate(
+ animationUpdateListener: DynamicAnimation.OnAnimationUpdateListener? = null,
+ animationEndListener: DynamicAnimation.OnAnimationEndListener? = null,
+) = suspendCancellableCoroutine { continuation ->
+ animationUpdateListener?.let(::addUpdateListener)
+ addEndListener { animation, canceled, value, velocity ->
+ continuation.resumeIfCan(Unit)
+ animationEndListener?.onAnimationEnd(animation, canceled, value, velocity)
+ }
+ animateToFinalPosition(1F)
+ continuation.invokeOnCancellation {
+ animationUpdateListener?.let(::removeUpdateListener)
+ animationEndListener?.let(::removeEndListener)
+ cancel()
+ }
+}
+
private fun <T> CancellableContinuation<T>.resumeIfCan(value: T) {
if (!isCancelled && !isCompleted) {
resume(value)
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
index 2aa6e7b..ae94544 100644
--- a/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/keyguard/ClockEventControllerTest.kt
@@ -24,7 +24,6 @@
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
-import com.android.settingslib.notification.modes.TestModeBuilder.MANUAL_DND_ACTIVE
import com.android.settingslib.notification.modes.TestModeBuilder.MANUAL_DND_INACTIVE
import com.android.systemui.Flags as AConfigFlags
import com.android.systemui.SysuiTestCase
@@ -107,6 +106,7 @@
private lateinit var repository: FakeKeyguardRepository
private val clockBuffers = ClockMessageBuffers(LogcatOnlyMessageBuffer(LogLevel.DEBUG))
private lateinit var underTest: ClockEventController
+ private lateinit var dndModeId: String
@Mock private lateinit var broadcastDispatcher: BroadcastDispatcher
@Mock private lateinit var batteryController: BatteryController
@@ -156,6 +156,7 @@
whenever(largeClockController.theme).thenReturn(ThemeConfig(true, null))
whenever(userTracker.userId).thenReturn(1)
+ dndModeId = MANUAL_DND_INACTIVE.id
zenModeRepository.addMode(MANUAL_DND_INACTIVE)
repository = FakeKeyguardRepository()
@@ -528,7 +529,7 @@
testScope.runTest {
underTest.listenForDnd(testScope.backgroundScope)
- zenModeRepository.replaceMode(MANUAL_DND_INACTIVE.id, MANUAL_DND_ACTIVE)
+ zenModeRepository.activateMode(dndModeId)
runCurrent()
verify(events)
@@ -536,7 +537,7 @@
eq(ZenData(ZenMode.IMPORTANT_INTERRUPTIONS, R.string::dnd_is_on.name))
)
- zenModeRepository.replaceMode(MANUAL_DND_ACTIVE.id, MANUAL_DND_INACTIVE)
+ zenModeRepository.deactivateMode(dndModeId)
runCurrent()
verify(events).onZenDataChanged(eq(ZenData(ZenMode.OFF, R.string::dnd_is_off.name)))
diff --git a/services/core/java/com/android/server/audio/SpatializerHelper.java b/services/core/java/com/android/server/audio/SpatializerHelper.java
index afa90d5..608edbb 100644
--- a/services/core/java/com/android/server/audio/SpatializerHelper.java
+++ b/services/core/java/com/android/server/audio/SpatializerHelper.java
@@ -129,6 +129,8 @@
/** current level as reported by native Spatializer in callback */
private int mSpatLevel = Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE;
private int mCapableSpatLevel = Spatializer.SPATIALIZER_IMMERSIVE_LEVEL_NONE;
+ /** cached version of Spatializer.getSpatializedChannelMasks */
+ private List<Integer> mSpatializedChannelMasks = Collections.emptyList();
private boolean mTransauralSupported = false;
private boolean mBinauralSupported = false;
@@ -1030,6 +1032,17 @@
return;
}
try {
+ final int[] nativeMasks = mSpat.getSpatializedChannelMasks();
+ for (int i = 0; i < nativeMasks.length; i++) {
+ nativeMasks[i] = AudioFormat.convertNativeChannelMaskToOutMask(nativeMasks[i]);
+ }
+ mSpatializedChannelMasks = Arrays.stream(nativeMasks).boxed().toList();
+
+ } catch (Exception e) { // just catch Exception in case nativeMasks is null
+ Log.e(TAG, "Error calling getSpatializedChannelMasks", e);
+ mSpatializedChannelMasks = Collections.emptyList();
+ }
+ try {
//TODO: register heatracking callback only when sensors are registered
if (mIsHeadTrackingSupported) {
mActualHeadTrackingMode =
@@ -1103,20 +1116,7 @@
}
synchronized @NonNull List<Integer> getSpatializedChannelMasks() {
- if (!checkSpatializer("getSpatializedChannelMasks")) {
- return Collections.emptyList();
- }
- try {
- final int[] nativeMasks = new int[0]; // FIXME mSpat query goes here
- for (int i = 0; i < nativeMasks.length; i++) {
- nativeMasks[i] = AudioFormat.convertNativeChannelMaskToOutMask(nativeMasks[i]);
- }
- final List<Integer> masks = Arrays.stream(nativeMasks).boxed().toList();
- return masks;
- } catch (Exception e) { // just catch Exception in case nativeMasks is null
- Log.e(TAG, "Error calling getSpatializedChannelMasks", e);
- return Collections.emptyList();
- }
+ return mSpatializedChannelMasks;
}
//------------------------------------------------------
@@ -1622,6 +1622,14 @@
pw.println("\tmState:" + mState);
pw.println("\tmSpatLevel:" + mSpatLevel);
pw.println("\tmCapableSpatLevel:" + mCapableSpatLevel);
+ List<Integer> speakerMasks = getSpatializedChannelMasks();
+ StringBuilder masks = speakerMasks.isEmpty()
+ ? new StringBuilder("none") : new StringBuilder("");
+ for (Integer mask : speakerMasks) {
+ masks.append(AudioFormat.javaChannelOutMaskToString(mask)).append(" ");
+ }
+ pw.println("\tspatialized speaker masks: " + masks);
+
pw.println("\tmIsHeadTrackingSupported:" + mIsHeadTrackingSupported);
StringBuilder modesString = new StringBuilder();
for (int mode : mSupportedHeadTrackingModes) {
diff --git a/services/core/java/com/android/server/notification/GroupHelper.java b/services/core/java/com/android/server/notification/GroupHelper.java
index f914551..4b41696 100644
--- a/services/core/java/com/android/server/notification/GroupHelper.java
+++ b/services/core/java/com/android/server/notification/GroupHelper.java
@@ -59,6 +59,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
@@ -243,7 +244,7 @@
if (!sbn.isAppGroup()) {
sbnToBeAutogrouped = maybeGroupWithSections(record, autogroupSummaryExists);
} else {
- maybeUngroupWithSections(record);
+ maybeUngroupOnAppGrouped(record);
}
} else {
final StatusBarNotification sbn = record.getSbn();
@@ -553,11 +554,13 @@
}
/**
- * A non-app grouped notification has been added or updated
+ * A non-app-grouped notification has been added or updated
* Evaluate if:
* (a) an existing autogroup summary needs updated attributes
* (b) a new autogroup summary needs to be added with correct attributes
* (c) other non-app grouped children need to be moved to the autogroup
+ * (d) the notification has been updated from a groupable to a non-groupable section and needs
+ * to trigger a cleanup
*
* This method implements autogrouping with sections support.
*
@@ -567,11 +570,11 @@
boolean autogroupSummaryExists) {
final StatusBarNotification sbn = record.getSbn();
boolean sbnToBeAutogrouped = false;
-
final NotificationSectioner sectioner = getSection(record);
if (sectioner == null) {
+ maybeUngroupOnNonGroupableUpdate(record);
if (DEBUG) {
- Log.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
+ Slog.i(TAG, "Skipping autogrouping for " + record + " no valid section found.");
}
return false;
}
@@ -584,7 +587,6 @@
if (record.getGroupKey().equals(fullAggregateGroupKey.toString())) {
return false;
}
-
synchronized (mAggregatedNotifications) {
ArrayMap<String, NotificationAttributes> ungrouped =
mUngroupedAbuseNotifications.getOrDefault(fullAggregateGroupKey, new ArrayMap<>());
@@ -601,11 +603,11 @@
if (ungrouped.size() >= mAutoGroupAtCount || autogroupSummaryExists) {
if (DEBUG) {
if (ungrouped.size() >= mAutoGroupAtCount) {
- Log.i(TAG,
+ Slog.i(TAG,
"Found >=" + mAutoGroupAtCount
+ " ungrouped notifications => force grouping");
} else {
- Log.i(TAG, "Found aggregate summary => force grouping");
+ Slog.i(TAG, "Found aggregate summary => force grouping");
}
}
@@ -642,7 +644,24 @@
}
/**
- * A notification was added that's app grouped.
+ * A notification was added that was previously part of a valid section and needs to trigger
+ * GH state cleanup.
+ */
+ private void maybeUngroupOnNonGroupableUpdate(NotificationRecord record) {
+ maybeUngroupWithSections(record, getPreviousValidSectionKey(record));
+ }
+
+ /**
+ * A notification was added that is app-grouped.
+ */
+ private void maybeUngroupOnAppGrouped(NotificationRecord record) {
+ maybeUngroupWithSections(record, getSectionGroupKeyWithFallback(record));
+ }
+
+ /**
+ * Called when a notification is posted and is either app-grouped or was previously part of
+ * a valid section and needs to trigger GH state cleanup.
+ *
* Evaluate whether:
* (a) an existing autogroup summary needs updated attributes
* (b) if we need to remove our autogroup overlay for this notification
@@ -652,13 +671,20 @@
*
* And updates the internal state of un-app-grouped notifications and their flags.
*/
- private void maybeUngroupWithSections(NotificationRecord record) {
+ private void maybeUngroupWithSections(NotificationRecord record,
+ @Nullable FullyQualifiedGroupKey fullAggregateGroupKey) {
+ if (fullAggregateGroupKey == null) {
+ if (DEBUG) {
+ Slog.i(TAG,
+ "Skipping maybeUngroupWithSections for " + record
+ + " no valid section found.");
+ }
+ return;
+ }
+
final StatusBarNotification sbn = record.getSbn();
final String pkgName = sbn.getPackageName();
final int userId = record.getUserId();
- final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(userId,
- pkgName, getSection(record));
-
synchronized (mAggregatedNotifications) {
// if this notification still exists and has an autogroup overlay, but is now
// grouped by the app, clear the overlay
@@ -675,21 +701,22 @@
mAggregatedNotifications.put(fullAggregateGroupKey, aggregatedNotificationsAttrs);
if (DEBUG) {
- Log.i(TAG, "maybeUngroup removeAutoGroup: " + record);
+ Slog.i(TAG, "maybeUngroup removeAutoGroup: " + record);
}
mCallback.removeAutoGroup(sbn.getKey());
if (aggregatedNotificationsAttrs.isEmpty()) {
if (DEBUG) {
- Log.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
+ Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
}
mCallback.removeAutoGroupSummary(userId, pkgName,
fullAggregateGroupKey.toString());
mAggregatedNotifications.remove(fullAggregateGroupKey);
} else {
if (DEBUG) {
- Log.i(TAG, "Aggregate group not empty, updating: " + fullAggregateGroupKey);
+ Slog.i(TAG,
+ "Aggregate group not empty, updating: " + fullAggregateGroupKey);
}
updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
}
@@ -860,8 +887,15 @@
final StatusBarNotification sbn = record.getSbn();
final String pkgName = sbn.getPackageName();
final int userId = record.getUserId();
- final FullyQualifiedGroupKey fullAggregateGroupKey = new FullyQualifiedGroupKey(userId,
- pkgName, getSection(record));
+
+ final FullyQualifiedGroupKey fullAggregateGroupKey = getSectionGroupKeyWithFallback(record);
+ if (fullAggregateGroupKey == null) {
+ if (DEBUG) {
+ Slog.i(TAG,
+ "Skipping autogroup cleanup for " + record + " no valid section found.");
+ }
+ return;
+ }
synchronized (mAggregatedNotifications) {
ArrayMap<String, NotificationAttributes> ungrouped =
@@ -879,14 +913,15 @@
if (aggregatedNotificationsAttrs.isEmpty()) {
if (DEBUG) {
- Log.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
+ Slog.i(TAG, "Aggregate group is empty: " + fullAggregateGroupKey);
}
mCallback.removeAutoGroupSummary(userId, pkgName,
fullAggregateGroupKey.toString());
mAggregatedNotifications.remove(fullAggregateGroupKey);
} else {
if (DEBUG) {
- Log.i(TAG, "Aggregate group not empty, updating: " + fullAggregateGroupKey);
+ Slog.i(TAG,
+ "Aggregate group not empty, updating: " + fullAggregateGroupKey);
}
updateAggregateAppGroup(fullAggregateGroupKey, sbn.getKey(), true, 0);
}
@@ -901,6 +936,52 @@
}
/**
+ * Get the section key for a notification. If the section is invalid, ie. notification is not
+ * auto-groupable, then return the previous valid section, if any.
+ * @param record the notification
+ * @return a section group key, null if not found
+ */
+ @Nullable
+ private FullyQualifiedGroupKey getSectionGroupKeyWithFallback(final NotificationRecord record) {
+ final NotificationSectioner sectioner = getSection(record);
+ if (sectioner != null) {
+ return new FullyQualifiedGroupKey(record.getUserId(), record.getSbn().getPackageName(),
+ sectioner);
+ } else {
+ return getPreviousValidSectionKey(record);
+ }
+ }
+
+ /**
+ * Get the previous valid section key of a notification that may have been updated to an invalid
+ * section. This is needed in case a notification is updated as an ungroupable (invalid section)
+ * => auto-groups need to be updated/GH state cleanup.
+ * @param record the notification
+ * @return a section group key or null if not found
+ */
+ @Nullable
+ private FullyQualifiedGroupKey getPreviousValidSectionKey(final NotificationRecord record) {
+ synchronized (mAggregatedNotifications) {
+ final String recordKey = record.getKey();
+ // Search in ungrouped
+ for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
+ ungroupedSection : mUngroupedAbuseNotifications.entrySet()) {
+ if (ungroupedSection.getValue().containsKey(recordKey)) {
+ return ungroupedSection.getKey();
+ }
+ }
+ // Search in aggregated
+ for (Entry<FullyQualifiedGroupKey, ArrayMap<String, NotificationAttributes>>
+ aggregatedSection : mAggregatedNotifications.entrySet()) {
+ if (aggregatedSection.getValue().containsKey(recordKey)) {
+ return aggregatedSection.getKey();
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
* Called when a child notification is removed, after some delay, so that this helper can
* trigger a forced grouping if the group has become sparse/singleton
* or only the summary is left.
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
index dd278fc..6cb2429 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
@@ -2338,6 +2338,177 @@
}
@Test
+ @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS})
+ public void testUpdateToUngroupableSection_cleanupUngrouped() {
+ final String pkg = "package";
+ // Post notification w/o group in a valid section
+ NotificationRecord notification = spy(getNotificationRecord(pkg, 0, "", mUser,
+ "", false, IMPORTANCE_LOW));
+ Notification n = mock(Notification.class);
+ StatusBarNotification sbn = spy(getSbn(pkg, 0, "0", UserHandle.SYSTEM));
+ when(notification.getNotification()).thenReturn(n);
+ when(notification.getSbn()).thenReturn(sbn);
+ when(sbn.getNotification()).thenReturn(n);
+ when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+ assertThat(GroupHelper.getSection(notification)).isNotNull();
+ mGroupHelper.onNotificationPosted(notification, false);
+
+ // Update notification to invalid section
+ when(n.isStyle(Notification.CallStyle.class)).thenReturn(true);
+ assertThat(GroupHelper.getSection(notification)).isNull();
+ boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notification, false);
+ assertThat(needsAutogrouping).isFalse();
+
+ // Check that GH internal state (ungrouped list) was cleaned-up
+ // Post AUTOGROUP_AT_COUNT-1 notifications => should not autogroup
+ for (int i = 0; i < AUTOGROUP_AT_COUNT - 1; i++) {
+ int id = 42 + i;
+ notification = getNotificationRecord(pkg, id, "" + id, mUser,
+ null, false, IMPORTANCE_LOW);
+ mGroupHelper.onNotificationPosted(notification, false);
+ }
+
+ verify(mCallback, never()).addAutoGroupSummary(anyInt(), anyString(), anyString(),
+ anyString(), anyInt(), any());
+ verify(mCallback, never()).addAutoGroup(anyString(), anyString(), anyBoolean());
+ }
+
+ @Test
+ @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS,
+ android.app.Flags.FLAG_CHECK_AUTOGROUP_BEFORE_POST})
+ public void testUpdateToUngroupableSection_afterAutogroup_isUngrouped() {
+ final String pkg = "package";
+ final List<NotificationRecord> notificationList = new ArrayList<>();
+ // Post notification w/o group in a valid section
+ for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+ NotificationRecord notification = spy(getNotificationRecord(pkg, i, "" + i, mUser,
+ "", false, IMPORTANCE_LOW));
+ Notification n = mock(Notification.class);
+ StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM));
+ when(notification.getNotification()).thenReturn(n);
+ when(notification.getSbn()).thenReturn(sbn);
+ when(sbn.getNotification()).thenReturn(n);
+ when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+ assertThat(GroupHelper.getSection(notification)).isNotNull();
+ mGroupHelper.onNotificationPosted(notification, false);
+ notificationList.add(notification);
+ }
+
+ final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+ AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+ verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+ eq(expectedGroupKey), anyInt(), any());
+ verify(mCallback, times(AUTOGROUP_AT_COUNT - 1)).addAutoGroup(anyString(),
+ eq(expectedGroupKey), eq(true));
+
+ // Update a notification to invalid section
+ Mockito.reset(mCallback);
+ final NotificationRecord notifToInvalidate = notificationList.get(0);
+ when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+ true);
+ assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+ boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notifToInvalidate, true);
+ assertThat(needsAutogrouping).isFalse();
+
+ // Check that the updated notification was removed from the autogroup
+ verify(mCallback, times(1)).removeAutoGroup(eq(notifToInvalidate.getKey()));
+ verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+ verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+ eq(expectedGroupKey), any());
+ }
+
+ @Test
+ @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS,
+ android.app.Flags.FLAG_CHECK_AUTOGROUP_BEFORE_POST})
+ public void testUpdateToUngroupableSection_onRemoved_isUngrouped() {
+ final String pkg = "package";
+ final List<NotificationRecord> notificationList = new ArrayList<>();
+ // Post notification w/o group in a valid section
+ for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+ NotificationRecord notification = spy(getNotificationRecord(pkg, i, "" + i, mUser,
+ "", false, IMPORTANCE_LOW));
+ Notification n = mock(Notification.class);
+ StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM));
+ when(notification.getNotification()).thenReturn(n);
+ when(notification.getSbn()).thenReturn(sbn);
+ when(sbn.getNotification()).thenReturn(n);
+ when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+ assertThat(GroupHelper.getSection(notification)).isNotNull();
+ mGroupHelper.onNotificationPosted(notification, false);
+ notificationList.add(notification);
+ }
+
+ final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+ AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+ verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+ eq(expectedGroupKey), anyInt(), any());
+ verify(mCallback, times(AUTOGROUP_AT_COUNT - 1)).addAutoGroup(anyString(),
+ eq(expectedGroupKey), eq(true));
+
+ // Update a notification to invalid section and removed it
+ Mockito.reset(mCallback);
+ final NotificationRecord notifToInvalidate = notificationList.get(0);
+ when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+ true);
+ assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+ notificationList.remove(notifToInvalidate);
+ mGroupHelper.onNotificationRemoved(notifToInvalidate, notificationList);
+
+ // Check that the autogroup was updated
+ verify(mCallback, never()).removeAutoGroup(anyString());
+ verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+ verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+ eq(expectedGroupKey), any());
+ }
+
+ @Test
+ @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_SINGLETONS})
+ public void testUpdateToUngroupableSection_afterForceGrouping_isUngrouped() {
+ final String pkg = "package";
+ final String groupName = "testGroup";
+ final List<NotificationRecord> notificationList = new ArrayList<>();
+ final ArrayMap<String, NotificationRecord> summaryByGroup = new ArrayMap<>();
+ // Post valid section summary notifications without children => force group
+ for (int i = 0; i < AUTOGROUP_AT_COUNT; i++) {
+ NotificationRecord notification = spy(getNotificationRecord(mPkg, i, "" + i, mUser,
+ groupName, true, IMPORTANCE_LOW));
+ Notification n = mock(Notification.class);
+ StatusBarNotification sbn = spy(getSbn(pkg, i, "" + i, UserHandle.SYSTEM, groupName));
+ when(notification.getNotification()).thenReturn(n);
+ when(notification.getSbn()).thenReturn(sbn);
+ when(n.getGroup()).thenReturn(groupName);
+ when(sbn.getNotification()).thenReturn(n);
+ when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
+ assertThat(GroupHelper.getSection(notification)).isNotNull();
+ notificationList.add(notification);
+ mGroupHelper.onNotificationPostedWithDelay(notification, notificationList,
+ summaryByGroup);
+ }
+
+ final String expectedGroupKey = GroupHelper.getFullAggregateGroupKey(pkg,
+ AGGREGATE_GROUP_KEY + "SilentSection", UserHandle.SYSTEM.getIdentifier());
+ verify(mCallback, times(1)).addAutoGroupSummary(anyInt(), eq(pkg), anyString(),
+ eq(expectedGroupKey), anyInt(), any());
+ verify(mCallback, times(AUTOGROUP_AT_COUNT)).addAutoGroup(anyString(),
+ eq(expectedGroupKey), eq(true));
+
+ // Update a notification to invalid section
+ Mockito.reset(mCallback);
+ final NotificationRecord notifToInvalidate = notificationList.get(0);
+ when(notifToInvalidate.getNotification().isStyle(Notification.CallStyle.class)).thenReturn(
+ true);
+ assertThat(GroupHelper.getSection(notifToInvalidate)).isNull();
+ boolean needsAutogrouping = mGroupHelper.onNotificationPosted(notifToInvalidate, true);
+ assertThat(needsAutogrouping).isFalse();
+
+ // Check that GH internal state (ungrouped list) was cleaned-up
+ verify(mCallback, times(1)).removeAutoGroup(eq(notifToInvalidate.getKey()));
+ verify(mCallback, never()).removeAutoGroupSummary(anyInt(), anyString(), anyString());
+ verify(mCallback, times(1)).updateAutogroupSummary(anyInt(), anyString(),
+ eq(expectedGroupKey), any());
+ }
+
+ @Test
@EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
public void testMoveAggregateGroups_updateChannel() {
final String pkg = "package";